diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..feaf570b8323de75398ed7a6eab69be96cd298e5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,37 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text +*.graffle filter=lfs diff=lfs merge=lfs -text +docs/assets/textgraphs.graffle filter=lfs diff=lfs merge=lfs -text diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000000000000000000000000000000000..ff5aebe20aae97ecd7ec498d3793bcfcbb297b33 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: ceteri diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000000000000000000000000000000000..85006f328bf2e933bbd5376e87a3ac54c2ef0412 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..dfecbe31352b9b9dfd1da134998c1278a6996c1f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: [pull_request, workflow_dispatch] + +jobs: +# pre-commit: +# name: Run pre-commit +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v3 +# - uses: actions/setup-python@v3 +# - uses: pre-commit/action@v3.0.0 + + test: + name: Tests for Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10'] + fail-fast: false +# needs: pre-commit + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install -e . + pip install -r requirements-dev.txt + + - name: Run tests + run: | + pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..fb05fdbed26fbcd57d39e83e5f60ea990573ca7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,173 @@ +# local files +*~ +chromedriver +lemma.json +lemma.ttl +lemma.zip +lemma_graph.zip +examples/tmp.*.html +vis.html +gor.html +txg.tgz +s2v_old/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..749eea3a13bc7e27b7df9e7558157c00fd5506b4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +default_stages: [commit, push] +default_language_version: + python: python3 +exclude: "deprecated" +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + exclude: ^docs/ + - id: check-builtin-literals + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: check-json + - id: check-yaml + - id: debug-statements + - id: detect-private-key +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.4.1 + hooks: + - id: mypy # type annotations + exclude: ^tests/,^venv/ +- repo: https://github.com/PyCQA/pylint + rev: v2.17.4 + hooks: + - id: pylint + exclude: error.py +- repo: https://github.com/codespell-project/codespell + rev: v2.2.4 + hooks: + - id: codespell # spell-check source code + args: ["-L", "basf,textgraph,udo"] # comma separated stop words + exclude: ^README.md|^NOTES.md|^examples|^docs/ack.md|^docs/biblio.md + language: python + types: [text] diff --git a/CITATION b/CITATION new file mode 100644 index 0000000000000000000000000000000000000000..962f4ea7f5f87620d47e3cfe2262b98b3bd73a8b --- /dev/null +++ b/CITATION @@ -0,0 +1,8 @@ +@software{TextGraphs, + author = {Paco Nathan}, + title = {{TextGraphs + LLMs + graph ML for entity extraction, linking, ranking, and constructing a lemma graph}}, + year = 2023, + publisher = {Derwen}, + doi = {10.5281/zenodo.10431783}, + url = {https://github.com/DerwenAI/textgraphs} +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..de622b8049b0c97aff84e797a7489c53b424a9ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-2024 Derwen, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000000000000000000000000000000000..4616dd20e854333d45ac4221a807e4cdb268e45c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,10 @@ +include LICENSE +include README.md +include pyproject.toml +include requirements.txt +include setup.py +include tests/*.py +include textgraphs/*.py +prune .ipynb_checkpoints +prune docs +prune venv diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000000000000000000000000000000000000..1b30de306a51929a0c4ff59d4796b279aff05b15 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,39 @@ +TODO: + + * can we build a causal graph of the provenance? + - https://www.pywhy.org/dowhy/v0.11.1/ + + * target publications: + - https://drops.dagstuhl.de/entities/issue/TGDK-volume-1-issue-1 + + * impl a _semantic random walk_ from a source KG + + * link entities for lemmas, noun chunks using MediaWiki lookups? + - apply default semantics: `skos:related` + + * eval clustering/community detection for GOR? + - https://github.com/MengLiuPurdue/LocalGraphClustering + + * RAG example + - https://docs.llamaindex.ai/en/latest/examples/index_structs/knowledge_graph/KuzuGraphDemo.html#query-with-embeddings + + * extend GOR to replicate NodePiece/ULTRA ? + + * reify GOR, then use FastRP to generate embeddings? + - https://github.com/Knorreman/fastRP + + * eval community detection to condense nodes using k-medoids? + - https://medium.com/neo4j/clustering-graph-data-with-k-medoids-3b6a67ea0873 + + * add conda packaging + - https://conda.github.io/grayskull/ + + + * SPARQL the DBPedia/Wikidata equivs + + * other NER/RE: + - https://github.com/dwadden/dygiepp?tab=readme-ov-file#pretrained-models + + * check out https://github.com/wikipedia2vec/wikipedia2vec + + * link `sense2vec` synonyms; make affordances for UI to annotate synonyms diff --git a/PROMPT.md b/PROMPT.md new file mode 100644 index 0000000000000000000000000000000000000000..03c16e811975fda57d99af1d8b0f403b78d77782 --- /dev/null +++ b/PROMPT.md @@ -0,0 +1,15 @@ +https://medium.com/@nizami_muhammad/extracting-relation-from-sentence-using-llm-597d0c0310a8 + +Sentence: Werner Herzog is the son of Dietrich Herzog +Extract RDF predicate from the sentence in this format: +subject: +predicate: +object: + +--- + +Sentence: Werner Herzog is a remarkable filmmaker and an intellectual originally from Germany, the son of Dietrich Herzog. After the war, Werner fled to America to become famous. Instead he became President and decided to nuke Slovenia. +Be brief, extract the top RDF predicate in DBPedia for the relation between in this format: +subject: +predicate: +object: \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..972677e6c2ce6f5ee260097f3c31976ffd7fdaa8 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +--- +title: TextGraphs +emoji: ✴ +colorFrom: green +colorTo: gray +sdk: streamlit +sdk_version: 1.28.2 +app_file: app.py +pinned: false +license: mit +--- + + +# TextGraphs + +[![DOI](https://zenodo.org/badge/735568863.svg)](https://zenodo.org/doi/10.5281/zenodo.10431783) +![Licence](https://img.shields.io/github/license/DerwenAI/textgraphs) +[![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) +![CI](https://github.com/DerwenAI/textgraphs/workflows/CI/badge.svg) +
+![Repo size](https://img.shields.io/github/repo-size/DerwenAI/textgraphs) +![downloads](https://img.shields.io/pypi/dm/textgraphs) +![sponsor](https://img.shields.io/github/sponsors/ceteri) + +TextGraphs logo + + +## project info + +Project home: + +Full documentation: + +Sample code is provided in `demo.py` + + +## requirements + + * Python 3.10+ + + +## deploy library from PyPi + +Prepare the virtual environment: + +```bash +python3 -m venv venv +source venv/bin/activate +python3 -m pip install -U pip wheel setuptools +``` + +Install from [PyPi](https://pypi.python.org/pypi/textgraphs): + +```bash +python3 -m pip install -U textgraphs +``` + + +## run demos locally + +```bash +python3 demo.py +``` + +```bash +streamlit run app.py +``` + + +## install library from source locally + +```bash +python3 -m venv venv +source venv/bin/activate + +python3 -m pip install -U pip wheel setuptools +python3 -m pip install -e . +``` + +To run the Streamlit or JupyterLab demos, also install: + +```bash +python3 -m pip install -r requirements-dev.txt +``` + + +## license and copyright + +Source code for **TextGraphs** plus its logo, documentation, and +examples have an [MIT license](https://spdx.org/licenses/MIT.html) +which is succinct and simplifies use in commercial applications. + +All materials herein are Copyright © 2023-2024 Derwen, Inc. + + +## attribution + +Please use the following BibTeX entry for citing **TextGraphs** if you +use it in your research or software: +```bibtex +@software{TextGraphs, + author = {Paco Nathan}, + title = {{TextGraphs + LLMs + graph ML for entity extraction, linking, ranking, and constructing a lemma graph}}, + year = 2023, + publisher = {Derwen}, + doi = {10.5281/zenodo.10431783}, + url = {https://github.com/DerwenAI/textgraphs} +} +``` + + +## star history + +[![Star History Chart](https://api.star-history.com/svg?repos=derwenai/textgraphs&type=Date)](https://star-history.com/#derwenai/textgraphs&Date) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000000000000000000000000000000000..a1c0f232d8547ce943d636c7b14ed08a69d00d31 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Supported Versions + +Versions which are currently being supported with security updates: + +| Version | Supported | +| ------- | ------------------ | +| > 0.2 | :white_check_mark: | + +## Reporting a Vulnerability + +To report a vulnerability, please create a new [*issue*](https://github.com/DerwenAI/textgraphs/issues). +We will be notified immediately, and will attempt to respond on the reported issue immediately. diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..3a70af227e5035d4d60bfb864a26bf32c781a38e --- /dev/null +++ b/app.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pylint: disable=C0301 + +""" +HuggingFace Spaces demo of the `TextGraphs` library using Streamlit + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +import pathlib +import time +import typing + +import matplotlib.pyplot as plt # pylint: disable=E0401 +import pandas as pd # pylint: disable=E0401 +import pyvis # pylint: disable=E0401 +import spacy # pylint: disable=E0401 +import streamlit as st # pylint: disable=E0401 + +import textgraphs + + +if __name__ == "__main__": + # default text input + SRC_TEXT: str = """ +Werner Herzog is a remarkable filmmaker and intellectual originally from Germany, the son of Dietrich Herzog. + """ + + # store the initial value of widgets in session state + if "visibility" not in st.session_state: + st.session_state.visibility = "visible" + st.session_state.disabled = False + + with st.container(): + st.title("demo: TextGraphs + LLMs to construct a 'lemma graph'") + st.markdown( + """ +docs: +    +DOI: 10.5281/zenodo.10431783 + """, + unsafe_allow_html = True, + ) + + + # collect input + config + st.subheader("configure", divider = "rainbow") + + text_input: str = st.text_area( + "Source Text:", + value = SRC_TEXT.strip(), + ) + + llm_ner = st.checkbox( + "enhance spaCy NER using: SpanMarker", + value = False, + ) + + link_ents = st.checkbox( + "link entities using: DBPedia Spotlight, WikiMedia API", + value = False, + ) + + infer_rel = st.checkbox( + "infer relations using: REBEL, OpenNRE, qwikidata", + value = False, + ) + + if text_input or llm_ner or link_ents or infer_rel: + ## parse the document + st.subheader("parse the raw text", divider = "rainbow") + start_time: float = time.time() + + # generally it is fine to use factory defaults, + # although let's illustrate these settings here + infer_rels: list = [] + + if infer_rel: + with st.spinner(text = "load rel models..."): + infer_rels = [ + textgraphs.InferRel_OpenNRE( + model = textgraphs.OPENNRE_MODEL, + max_skip = textgraphs.MAX_SKIP, + min_prob = textgraphs.OPENNRE_MIN_PROB, + ), + textgraphs.InferRel_Rebel( + lang = "en_XX", + mrebel_model = textgraphs.MREBEL_MODEL, + ), + ] + + ner: typing.Optional[ textgraphs.Component ] = None + + if llm_ner: + ner = textgraphs.NERSpanMarker( + ner_model = textgraphs.NER_MODEL, + ) + + tg: textgraphs.TextGraphs = textgraphs.TextGraphs( + factory = textgraphs.PipelineFactory( + spacy_model = textgraphs.SPACY_MODEL, + ner = ner, + kg = textgraphs.KGWikiMedia( + spotlight_api = textgraphs.DBPEDIA_SPOTLIGHT_API, + dbpedia_search_api = textgraphs.DBPEDIA_SEARCH_API, + dbpedia_sparql_api = textgraphs.DBPEDIA_SPARQL_API, + wikidata_api = textgraphs.WIKIDATA_API, + min_alias = textgraphs.DBPEDIA_MIN_ALIAS, + min_similarity = textgraphs.DBPEDIA_MIN_SIM, + ), + infer_rels = infer_rels, + ), + ) + + duration: float = round(time.time() - start_time, 3) + st.write(f"set up: {round(duration, 3)} sec") + + with st.spinner(text = "parse text..."): + start_time = time.time() + + pipe: textgraphs.Pipeline = tg.create_pipeline( + text_input.strip(), + ) + + duration = round(time.time() - start_time, 3) + st.write(f"parse text: {round(duration, 3)} sec, {len(text_input)} characters") + + # render the entity html + ent_html: str = spacy.displacy.render( + pipe.ner_doc, + style = "ent", + jupyter = False, + ) + + st.markdown( + ent_html, + unsafe_allow_html = True, + ) + + # generate dependencies as an SVG + dep_svg = spacy.displacy.render( + pipe.ner_doc, + style = "dep", + jupyter = False, + ) + + st.image( + dep_svg, + width = 800, + use_column_width = "never", + ) + + + ## collect graph elements from the parse + st.subheader("construct the base level of the lemma graph", divider = "rainbow") + start_time = time.time() + + tg.collect_graph_elements( + pipe, + debug = False, + ) + + duration = round(time.time() - start_time, 3) + st.write(f"collect elements: {round(duration, 3)} sec, {len(tg.nodes)} nodes, {len(tg.edges)} edges") + + ## perform entity linking + if link_ents: + st.subheader("extract entities and perform entity linking", divider = "rainbow") + + with st.spinner(text = "entity linking..."): + start_time = time.time() + + tg.perform_entity_linking( + pipe, + debug = False, + ) + + duration = round(time.time() - start_time, 3) + st.write(f"entity linking: {round(duration, 3)} sec") + + + ## perform relation extraction + if infer_rel: + st.subheader("infer relations", divider = "rainbow") + st.write("NB: this part runs an order of magnitude more *slooooooowly* on HF Spaces") + + with st.spinner(text = "relation extraction..."): + start_time = time.time() + + # NB: run this iteratively since Streamlit on HF Spaces is *sloooooooooow* + inferred_edges: list = tg.infer_relations( + pipe, + debug = False, + ) + + duration = round(time.time() - start_time, 3) + + n_list: list = list(tg.nodes.values()) + + df_rel: pd.DataFrame = pd.DataFrame.from_dict([ + { + "src": n_list[edge.src_node].text, + "dst": n_list[edge.dst_node].text, + "rel": edge.rel, + "weight": edge.prob, + } + for edge in inferred_edges + ]) + + st.dataframe(df_rel) + st.write(f"relation extraction: {round(duration, 3)} sec, {len(df_rel)} edges") + + + ## construct the _lemma graph_ + start_time = time.time() + + tg.construct_lemma_graph( + debug = False, + ) + + duration = round(time.time() - start_time, 3) + st.write(f"construct graph: {round(duration, 3)} sec") + + + ## rank the extracted phrases + st.subheader("rank the extracted phrases", divider = "rainbow") + start_time = time.time() + + tg.calc_phrase_ranks( + pr_alpha = textgraphs.PAGERANK_ALPHA, + debug = False, + ) + + df_ent: pd.DataFrame = tg.get_phrases_as_df() + + duration = round(time.time() - start_time, 3) + st.write(f"extract: {round(duration, 3)} sec, {len(df_ent)} entities") + + st.dataframe(df_ent) + + + ## generate a word cloud + st.subheader("generate a word cloud", divider = "rainbow") + + render: textgraphs.RenderPyVis = tg.create_render() + wordcloud = render.generate_wordcloud() + + st.image( + wordcloud.to_image(), + width = 700, + use_column_width = "never", + ) + + + ## visualize the lemma graph + st.subheader("visualize the lemma graph", divider = "rainbow") + st.markdown( + """ + what you get at this stage is a relatively noisy, + low-level detailed graph of the parsed text + + the most interesting nodes will probably be either + subjects (`nsubj`) or direct objects (`pobj`) + """ + ) + + pv_graph: pyvis.network.Network = render.render_lemma_graph( + debug = False, + ) + + pv_graph.force_atlas_2based( + gravity = -38, + central_gravity = 0.01, + spring_length = 231, + spring_strength = 0.7, + damping = 0.8, + overlap = 0, + ) + + pv_graph.show_buttons(filter_ = [ "physics" ]) + pv_graph.toggle_physics(True) + + py_html: pathlib.Path = pathlib.Path("vis.html") + pv_graph.save_graph(py_html.as_posix()) + + st.components.v1.html( + py_html.read_text(encoding = "utf-8"), + height = render.HTML_HEIGHT_WITH_CONTROLS, + scrolling = False, + ) + + + ## cluster the communities + st.subheader("cluster the communities", divider = "rainbow") + st.markdown( + """ +
+ About this clustering... +

+In the tutorial +"How to Convert Any Text Into a Graph of Concepts", +Rahul Nayak uses the +girvan-newman +algorithm to split the graph into communities, then clusters on those communities. +His approach works well for unsupervised clustering of key phrases which have been extracted from a collection of many documents. +

+

+While Nayak was working with entities extracted from "chunks" of text, not with a text graph per se, this approach is useful for identifying network motifs which can be condensed, e.g., to extract a semantic graph overlay as an abstraction layer atop a lemma graph. +

+
+
+ """, + unsafe_allow_html = True, + ) + + spring_dist_val = st.slider( + "spring distance for NetworkX clusters", + min_value = 0.0, + max_value = 10.0, + value = 1.2, + ) + + if spring_dist_val: + start_time = time.time() + fig, ax = plt.subplots() + + comm_map: dict = render.draw_communities( + spring_distance = spring_dist_val, + ) + + st.pyplot(fig) + + duration = round(time.time() - start_time, 3) + st.write(f"cluster: {round(duration, 3)} sec, {max(comm_map.values()) + 1} clusters") + + + ## transform a graph of relations + st.subheader("transform as a graph of relations", divider = "rainbow") + st.markdown( + """ +Using the topological transform given in `lee2023ingram`, construct a +_graph of relations_ for enhancing graph inference. + +
+ What does this transform provide? +

+By using a graph of relations dual representation of our graph data, first and foremost we obtain a more compact representation of the relations in the graph, and means of making inferences (e.g., link prediction) where there is substantially more invariance in the training data. +

+

+Also recognize that for a parse graph of a paragraph in the English language, the most interesting nodes will probably be either subjects (nsubj) or direct objects (pobj). Here in the graph of relations we can see illustrated how the important details from entity linking tend to cluster near either nsubj or pobj entities, connected through punctuation. This aspect is not as readily observed in the earlier visualization of the lemma graph. +

+
+ """, + unsafe_allow_html = True, + ) + + start_time = time.time() + + gor: textgraphs.GraphOfRelations = textgraphs.GraphOfRelations(tg) + gor.seeds() + gor.construct_gor() + + scores: typing.Dict[ tuple, float ] = gor.get_affinity_scores() + pv_graph = gor.render_gor_pyvis(scores) + + pv_graph.force_atlas_2based( + gravity = -38, + central_gravity = 0.01, + spring_length = 231, + spring_strength = 0.7, + damping = 0.8, + overlap = 0, + ) + + pv_graph.show_buttons(filter_ = [ "physics" ]) + pv_graph.toggle_physics(True) + + py_html = pathlib.Path("gor.html") + pv_graph.save_graph(py_html.as_posix()) + + st.components.v1.html( + py_html.read_text(encoding = "utf-8"), + height = render.HTML_HEIGHT_WITH_CONTROLS, + scrolling = False, + ) + + duration = round(time.time() - start_time, 3) + st.write(f"transform: {round(duration, 3)} sec, {len(gor.rel_list)} relations") + + ## download lemma graph + st.subheader("download the results", divider = "rainbow") + st.markdown( + """ +Download a serialized lemma graph in multiple formats: + """, + unsafe_allow_html = True, + ) + + col1, col2, col3 = st.columns(3) + + with col1: + st.download_button( + label = "download node-link", + data = tg.dump_lemma_graph(), + file_name = "lemma_graph.json", + mime = "application/json", + ) + + st.markdown( + """ +node-link: JSON data suitable for import to Neo4j, NetworkX, etc. + """, + unsafe_allow_html = True, + ) + + with col2: + st.download_button( + label = "download RDF", + data = tg.export_rdf(), + file_name = "lemma_graph.ttl", + mime = "text/turtle", + ) + + st.markdown( + """ +Turtle/N3: W3C semantic graph representation, based on RDF, OWL, SKOS, etc. + """, + unsafe_allow_html = True, + ) + + with col3: + st.download_button( + label = "download KùzuDB", + data = tg.export_kuzu(zip_name = "lemma_graph.zip"), + file_name = "lemma.zip", + mime = "application/x-zip-compressed", + ) + + st.markdown( + """ +openCypher: ZIP file of a labeled property graph in KùzuDB + """, + unsafe_allow_html = True, + ) + + + ## WIP + st.divider() + st.write("(WIP)") + + thanks: str = """ +This demo has completed, and thank you for running a Derwen space! + """ + + st.toast( + thanks, + icon ="😍", + ) diff --git a/bin/nb_md.sh b/bin/nb_md.sh new file mode 100755 index 0000000000000000000000000000000000000000..81061b9209ad9043f0564f8ffe3144cdc633a943 --- /dev/null +++ b/bin/nb_md.sh @@ -0,0 +1,15 @@ +#!/bin/bash -e -x + +for notebook_path in examples/*.ipynb; do + [ -e "$notebook_path" ] || continue + + notebook=`basename $notebook_path` + stem=`basename $notebook_path .ipynb` + + cp $notebook_path docs/$notebook + jupyter nbconvert docs/$notebook --to markdown + #exit 0 + + python3 bin/vis_doc.py docs/"$stem".md + rm docs/$notebook +done diff --git a/bin/preview.py b/bin/preview.py new file mode 100755 index 0000000000000000000000000000000000000000..31324541dc50c755902624a4abcad91dfe82c198 --- /dev/null +++ b/bin/preview.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Preview the `MkDocs` build of the online documentation. +""" + +from pathlib import PurePosixPath +import os + +from flask import Flask, redirect, send_from_directory, url_for # pylint: disable=E0401 + +DOCS_ROUTE = "/docs/" +DOCS_FILES = "../site" +DOCS_PORT = 8000 + +APP = Flask(__name__, static_folder=DOCS_FILES, template_folder=DOCS_FILES) + +APP.config["DEBUG"] = False +APP.config["MAX_CONTENT_LENGTH"] = 52428800 +APP.config["SECRET_KEY"] = "Technically, I remain uncommitted." +APP.config["SEND_FILE_MAX_AGE_DEFAULT"] = 3000 + + +@APP.route(DOCS_ROUTE, methods=["GET"]) +@APP.route(DOCS_ROUTE + "", methods=["GET"], defaults={"path": None}) +@APP.route(DOCS_ROUTE + "", methods=["GET"]) +def static_proxy (path=""): + """static route for an asset""" + if not path: + suffix = "" + else: + suffix = PurePosixPath(path).suffix + + if suffix not in [".css", ".js", ".map", ".png", ".svg", ".xml"]: + path = os.path.join(path, "index.html") + + return send_from_directory(DOCS_FILES, path) + + +@APP.route("/index.html") +@APP.route("/home/") +@APP.route("/") +def home_redirects (): + """redirect for home page""" + return redirect(url_for("static_proxy")) + + +if __name__ == "__main__": + APP.run(host="0.0.0.0", port=DOCS_PORT, debug=True) diff --git a/bin/push_pypi.sh b/bin/push_pypi.sh new file mode 100755 index 0000000000000000000000000000000000000000..2c1a9eff5ea341db10f44fdcd107f122e42aad69 --- /dev/null +++ b/bin/push_pypi.sh @@ -0,0 +1,10 @@ +#!/bin/bash -e -x + +rm -rf dist build textgraphs.egg-info +python3 -m build +twine check dist/* + +# this assumes the use of `~/.pypirc` +# https://packaging.python.org/en/latest/specifications/pypirc/ + +twine upload ./dist/* --verbose diff --git a/bin/vis_doc.py b/bin/vis_doc.py new file mode 100755 index 0000000000000000000000000000000000000000..1325e3d6a3ce02b6007e2e7a0deebd40c3814b8d --- /dev/null +++ b/bin/vis_doc.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Convert the markdown generated from Jupyter notebooks to preserve +rendered images, etc. +""" + +import os +import pathlib +import re +import sys +import time +import traceback +import typing + +from icecream import ic # pylint: disable=E0401 +from selenium import webdriver # pylint: disable=E0401 + + +class Converter: + """ +HTML/Markdown conversion + """ + PAT_HEADER = re.compile(r"^(```python\n\# for use.*production:\n.*\n```\n)", re.MULTILINE) + PAT_SOURCE = re.compile(r"\s+src\=\"(\S+)\"") + REPLACEMENT_HEADER: str = """ +!!! note + To run this notebook in JupyterLab, load [`examples/{}.ipynb`]({}/examples/{}.ipynb) + + """ + + def __init__ ( + self, + src_url: str, + ) -> None: + """ +Constructor. + """ + self.src_url: str = src_url + + + def replace_sys_header ( + self, + text: str, + stem: str, + *, + debug: bool = False, + ) -> str: + """ +Replace the initial cell in a tutorial notebook. + """ + output: typing.List[ str ] = [] + + for chunk in self.PAT_HEADER.split(text): + m_header: typing.Optional[ re.Match ] = self.PAT_HEADER.match(chunk) + + if debug: + ic(m_header) + + if m_header: + header: str = self.REPLACEMENT_HEADER.format(stem, self.src_url, stem) + output.append(header) + else: + output.append(chunk) + + return "\n".join(output) + + + def get_pyvis_html ( + self, + iframe: str, + *, + debug: bool = False, + ) -> str: + """ +Locate the HTML files generated by `PyVis` if any. +This assumes the HTML files are named `tmp.fig*.*` + """ + source_html: typing.Optional[ str ] = None + m_source: typing.Optional[ re.Match ] = self.PAT_SOURCE.search(iframe) + + if m_source: + source_html = m_source.group(1) + + if debug: + ic(source_html) + + if "tmp.fig" not in source_html: # type: ignore + # "): + in_iframe = False + + return "\n".join(output) + + +if __name__ == "__main__": + try: + conv: Converter = Converter( + "https://github.com/DerwenAI/textgraphs/blob/main", + ) + + filename: pathlib.Path = pathlib.Path(sys.argv[1]) + _parent: pathlib.Path = filename.parent + _stem: str = filename.stem + + ic(filename, _parent, _stem) + + with open(filename, "r", encoding = "utf-8") as fp: + html: str = fp.read() + + html = conv.replace_sys_header( # pylint: disable=C0103 + html, + _stem, + debug = False, # True + ) + + #print(text) + #sys.exit(0) + + html = conv.replace_pyvis_iframe( # pylint: disable=C0103 + html, + _parent, + _stem, + debug = True, # False + ) + + with open(filename, "w", encoding = "utf-8") as fp: + fp.write(html) + + except Exception as ex: # pylint: disable=W0718 + ic(ex) + traceback.print_exc() diff --git a/demo.py b/demo.py new file mode 100644 index 0000000000000000000000000000000000000000..911f8a350af8a41179432827a8201f2017b53c58 --- /dev/null +++ b/demo.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Sample application to demo the `TextGraphs` library. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +import asyncio +import sys # pylint: disable=W0611 +import traceback +import time +import typing + +from icecream import ic # pylint: disable=E0401 +from pyinstrument import Profiler # pylint: disable=E0401 +import matplotlib.pyplot as plt # pylint: disable=E0401 +import pandas as pd # pylint: disable=E0401 + +import textgraphs + + +if __name__ == "__main__": + SRC_TEXT: str = """ +Werner Herzog is a remarkable filmmaker and an intellectual originally from Germany, the son of Dietrich Herzog. +After the war, Werner fled to America to become famous. +""" + + ## set up + ## NB: profiler raises handler exceptions when `concur = False` + debug: bool = False # True + concur: bool = True # False + profile: bool = True # False + + if profile: + profiler: Profiler = Profiler() + profiler.start() + + try: + start_time: float = time.time() + + tg: textgraphs.TextGraphs = textgraphs.TextGraphs( + factory = textgraphs.PipelineFactory( + spacy_model = textgraphs.SPACY_MODEL, + ner = None, #textgraphs.NERSpanMarker(), + kg = textgraphs.KGWikiMedia( + spotlight_api = textgraphs.DBPEDIA_SPOTLIGHT_API, + dbpedia_search_api = textgraphs.DBPEDIA_SEARCH_API, + dbpedia_sparql_api = textgraphs.DBPEDIA_SPARQL_API, + wikidata_api = textgraphs.WIKIDATA_API, + ), + infer_rels = [ + textgraphs.InferRel_OpenNRE( + model = textgraphs.OPENNRE_MODEL, + max_skip = textgraphs.MAX_SKIP, + min_prob = textgraphs.OPENNRE_MIN_PROB, + ), + textgraphs.InferRel_Rebel( + lang = "en_XX", + mrebel_model = textgraphs.MREBEL_MODEL, + ), + ], + ), + ) + + duration: float = round(time.time() - start_time, 3) + print(f"{duration:7.3f} sec: set up") + + + ## NLP parse + start_time = time.time() + + pipe: textgraphs.Pipeline = tg.create_pipeline( + SRC_TEXT.strip(), + ) + + duration = round(time.time() - start_time, 3) + print(f"{duration:7.3f} sec: parse text") + + + ## collect graph elements from the parse + start_time = time.time() + + tg.collect_graph_elements( + pipe, + debug = debug, + ) + + duration = round(time.time() - start_time, 3) + print(f"{duration:7.3f} sec: collect elements") + + + ## perform entity linking + start_time = time.time() + + tg.perform_entity_linking( + pipe, + debug = debug, + ) + + duration = round(time.time() - start_time, 3) + print(f"{duration:7.3f} sec: entity linking") + + + ## perform concurrent relation extraction + start_time = time.time() + + if concur: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + inferred_edges: list = loop.run_until_complete( + tg.infer_relations_async( + pipe, + debug = debug, + ) + ) + else: + inferred_edges = tg.infer_relations( + pipe, + debug = debug, + ) + + duration = round(time.time() - start_time, 3) + print(f"{duration:7.3f} sec: relation extraction") + + n_list: list = list(tg.nodes.values()) + + df_rel: pd.DataFrame = pd.DataFrame.from_dict([ + { + "src": n_list[edge.src_node].text, + "dst": n_list[edge.dst_node].text, + "rel": pipe.kg.normalize_prefix(edge.rel), + "weight": edge.prob, + } + for edge in inferred_edges + ]) + + ic(df_rel) + + + ## construct the _lemma graph_ + start_time = time.time() + + tg.construct_lemma_graph( + debug = debug, + ) + + duration = round(time.time() - start_time, 3) + print(f"{duration:7.3f} sec: construct graph") + + + ## rank the extracted phrases + start_time = time.time() + + tg.calc_phrase_ranks( + pr_alpha = textgraphs.PAGERANK_ALPHA, + debug = debug, + ) + + duration = round(time.time() - start_time, 3) + print(f"{duration:7.3f} sec: rank phrases") + + + ## show the extracted phrase results + ic(tg.get_phrases_as_df()) + + if debug: # pylint: disable=W0101 + for key, node in tg.nodes.items(): + print(key, node) + + for key, edge in tg.edges.items(): + print(key, edge) + + except Exception as ex: # pylint: disable=W0718 + ic(ex) + traceback.print_exc() + + + ## transform graph data to a _graph of relations_ + start_time = time.time() + + gor: textgraphs.GraphOfRelations = textgraphs.GraphOfRelations( + tg, + ) + + gor.seeds( + debug = False, # True + ) + + gor.construct_gor( + debug = False, # True + ) + + _scores: typing.Dict[ tuple, float ] = gor.get_affinity_scores( + debug = False, # True + ) + + duration = round(time.time() - start_time, 3) + print(f"{duration:7.3f} sec: graph of relations") + + gor.render_gor_plt(_scores) + plt.show() + + #sys.exit(0) + + + ###################################################################### + ## stack profiler report + if profile: + profiler.stop() + profiler.print() + + ## output lemma graph as JSON + with open("lemma.json", "w", encoding = "utf-8") as fp: + fp.write(tg.dump_lemma_graph()) diff --git a/docs/abstract.md b/docs/abstract.md new file mode 100644 index 0000000000000000000000000000000000000000..89ba2ffce9c03a330c6871019a1b54a8f01b2072 --- /dev/null +++ b/docs/abstract.md @@ -0,0 +1,47 @@ +# Introduction + +**DRAFT** (WIP) + +The primary goal of this project is to improve semi-automated KG construction from large collections of unstructured text sources, while leveraging feedback from domain experts and maintaining quality checks for the aggregated results. + +Typical downstream use cases for these KGs include collecting data for industrial optimization use cases based on _operations research_, as mechanisms enabling structured LLM reasoning [#besta2024topo](biblio.md#besta2024topo), and potentially new methods of integrating KG linked data directly into LLM inference [#wen2023mindmap](biblio.md#wen2023mindmap) + +To this point, this project explores hybrid applications which leverage LLMs to improve _natural language processing_ (NLP) pipeline components, which are also complemented by other deep learning models, graph queries, semantic inference, and related APIs. + +Notably, LLMs come from NLP research. +Amidst an overwhelming avalanche of contemporary news headlines, pre-print papers, celebrity researchers, industry pundits, and so on ... +the hype begs a simple question: how good are LLMs at improving the results of natural language parsing and annotation in practice? + +Granted, it is possible to use LLM chat interfaces to generate entire KGs from unstructured text sources. +Results from this brute-force approach tend to be mixed, especially when KGs rely on non-trivial controlled vocabularies and overlapping concepts. +For examples, see [#lawrence2024ttg](biblio.md#lawrence2024ttg) and [#nizami2023llm](biblio.md#nizami2023llm). + +Issues with LLM accuracy (e.g., hallucinations) may be partially addressed through use of _retrieval augmented generation_ (RAG). +Even so, this approach tends to be expensive, especially when large number of PDF documents need to be used as input. +Use of a fully-automated "black box" based on a LLM chat agent in production use cases also tends to contradict the benefits of curating a KG to collect representations of an organization's domain expertise. + +There are perhaps some deeper issues implied in this work. +To leverage "generative AI" for KGs, we must cross multiple boundaries of representation. +For example, graph ML approaches which start from graph-theoretic descriptions are losing vital information. +On the one hand, these are generally focused on _node prediction_ or _edge prediction_ tasks, which seems overly reductionist and simplistic in the context of trying to generate streams of _composable elements_ for building graphs. +On the other hand, these approaches typically get trained on _node embeddings_, _edge embeddings_, or _graph embeddings_ -- which may not quite fit the problem at hand. +Rolling back even further, the transition from NLP parsing of unstructured text sources to the construction of KGs also tends to throw away a lot of potentially useful annotations and context available from the NLP workflows. +Commonly accepted means for training LLMs from text sources directly often use tokenization which is relatively naïve about what might be structured within the data, other than linear sequences of characters. +Notably, this ignores the relationships among surface forms of text and their co-occurence with predicted entities or relations. +Some contemporary approaches to RAG use "chunked" text, attempting to link between chunks, even though this approach arguably destroys information about what is structured within that input data. +These multiple disconnects between the source data, the representation methods used in training models, and the tactics employed for applications; however, quite arguably the "applications" targeted in research projects generally stop at comparisons of benchmarks. +Overall, these disconnects indicate the need for rethinking the problem at multiple points. + +For industry uses of KGs, one frequent observation from those leading production projects is that the "last mile" of applications generally relies on _operations research_, not ML. +We must keep these needs in mind when applying "generative AI" approaches to industry use cases. +Are we developing representations which can subsequently be leveraged for dynamic programming, convex optimization, etc.? + +This project explores a different definition for "generative AI" in the context of working with KGs for production use cases. +Rather than pursue an LLM to perform all required tasks, is it possible to combine the use of smaller, more specialized models for specific tasks within the reasonably well-understood process of KG construction? +In broad strokes, can this work alternative provide counterfactuals to the contemporary trends for chat-based _prompt engineering_? + +Seeking to integrate results from several other research projects implies substantial amounts of code reuse. +It would be intractable in terms of time and funding to rewrite code and then re-evaluate models for the many research projects which are within the scope of this work. +Therefore reproducibilty of published results -- based on open source code, models, evals, etc. -- becomes a crucial factor for determining whether others projects are suitable to be adapted into KG workflows. + +For the sake of brevity, we do not define all of the terminology used, instead relying on broadly used terms in the literature. diff --git a/docs/ack.md b/docs/ack.md new file mode 100644 index 0000000000000000000000000000000000000000..e237e02db618697106c3ac79ff72f4880f59a0ed --- /dev/null +++ b/docs/ack.md @@ -0,0 +1,11 @@ +# Acknowledgements + +Community by Aneeque Ahmed from the Noun Project + +Contributors: + + - Jürgen Müller, Zahid Abul-Basher, Nihatha Lathiff, et al., @ BASF + - open source sponsors for Derwen.ai + - perspectives from the KùzuDB.com team + - perspectives from the Argilla.io team + - feedback and suggestions from participants at [Dagstuhl Seminar 24061](https://www.dagstuhl.de/24061) diff --git a/docs/assets/favicon.png b/docs/assets/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..fa810528e0968da9767e1b65b4bb30379a353c93 Binary files /dev/null and b/docs/assets/favicon.png differ diff --git a/docs/assets/hitl.png b/docs/assets/hitl.png new file mode 100644 index 0000000000000000000000000000000000000000..3ffc389c6b0a9339c80739c5b055947c8678e3f7 Binary files /dev/null and b/docs/assets/hitl.png differ diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8a80c4bccd4bb323092e3d840df0b7c3d22284c0 Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/docs/assets/nouns/api.png b/docs/assets/nouns/api.png new file mode 100644 index 0000000000000000000000000000000000000000..0fdda8015f568fa78d8ee622567e4dc2a71e9b07 Binary files /dev/null and b/docs/assets/nouns/api.png differ diff --git a/docs/assets/nouns/biblio.png b/docs/assets/nouns/biblio.png new file mode 100644 index 0000000000000000000000000000000000000000..12cb95bc9fc11792234b3c32bf799639b916735c Binary files /dev/null and b/docs/assets/nouns/biblio.png differ diff --git a/docs/assets/nouns/community.png b/docs/assets/nouns/community.png new file mode 100644 index 0000000000000000000000000000000000000000..3df2db4c4f6d3cdd4df079fd23b40e3f40442509 Binary files /dev/null and b/docs/assets/nouns/community.png differ diff --git a/docs/assets/nouns/concepts.png b/docs/assets/nouns/concepts.png new file mode 100644 index 0000000000000000000000000000000000000000..194b88e34ae142238450ab1a6147784506660f4f Binary files /dev/null and b/docs/assets/nouns/concepts.png differ diff --git a/docs/assets/nouns/discovery.png b/docs/assets/nouns/discovery.png new file mode 100644 index 0000000000000000000000000000000000000000..4ea0768d3446f4e7a46d3a7eac67d27c1a7a4db8 Binary files /dev/null and b/docs/assets/nouns/discovery.png differ diff --git a/docs/assets/nouns/evidence.png b/docs/assets/nouns/evidence.png new file mode 100644 index 0000000000000000000000000000000000000000..f638b1dcc065a72fbc595bf84eb8ac27758f75b1 Binary files /dev/null and b/docs/assets/nouns/evidence.png differ diff --git a/docs/assets/nouns/feedback.png b/docs/assets/nouns/feedback.png new file mode 100644 index 0000000000000000000000000000000000000000..fa3abbc124100216783f6c379bbcdc43dfb03b50 Binary files /dev/null and b/docs/assets/nouns/feedback.png differ diff --git a/docs/assets/nouns/howto.png b/docs/assets/nouns/howto.png new file mode 100644 index 0000000000000000000000000000000000000000..bbf717983bb11d50faeed1fe90b7ce5127ba096b Binary files /dev/null and b/docs/assets/nouns/howto.png differ diff --git a/docs/assets/nouns/tutorial.png b/docs/assets/nouns/tutorial.png new file mode 100644 index 0000000000000000000000000000000000000000..63c428de5255d09a892e3ba095d16a75ab748370 Binary files /dev/null and b/docs/assets/nouns/tutorial.png differ diff --git a/docs/assets/textgraphs.graffle b/docs/assets/textgraphs.graffle new file mode 100644 index 0000000000000000000000000000000000000000..40ea164acd5f9d19f8ac51acd35ee39cc0f67e1f --- /dev/null +++ b/docs/assets/textgraphs.graffle @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2177f30434db8dc6534ed39b3f5a9bed3b0fbd00db26afd841f6e77c788910f2 +size 1410392 diff --git a/docs/biblio.md b/docs/biblio.md new file mode 100644 index 0000000000000000000000000000000000000000..a0cd367e40b0968a814742b332bdb428038f666b --- /dev/null +++ b/docs/biblio.md @@ -0,0 +1,232 @@ +# Bibliography + +books by b a r z i n from the Noun Project + +Where possible, the bibliography entries use conventions at + +for [*citation keys*](https://bibdesk.sourceforge.io/manual/BibDeskHelp_2.html). +Journal abbreviations come from + +based on [*ISO 4*](https://en.wikipedia.org/wiki/ISO_4) standards. +Links to online versions of cited works use +[DOI](https://www.doi.org/) +for [*persistent identifiers*](https://www.crossref.org/education/metadata/persistent-identifiers/). +When available, +[*open access*](https://peerj.com/preprints/3119v1/) +URLs are listed. + + +## – A – + +### aarsen2023ner + +["SpanMarker for Named Entity Recognition"](https://raw.githubusercontent.com/tomaarsen/SpanMarkerNER/main/thesis.pdf) +**Tom Aarsen** +*Radboud University* (2023-06-01) +> A span-level Named Entity Recognition (NER) model that aims to improve performance while reducing computational requirements. SpanMarker leverages special marker tokens and utilizes BERT-style encoders with position IDs and attention mask matrices to capture contextual information effectively. + +### auer07dbpedia + +["DBpedia: A Nucleus for a Web of Open Data"](https://doi.org/10.1007/978-3-540-76298-0_52) +**Sören Auer**, **Christian Bizer**, **Georgi Kobilarov**, **Jens Lehmann**, **Richard Cyganiak**, **Zachary Ives** +*ISWC* (2007-11-11) +> DBpedia is a community effort to extract structured information from Wikipedia and to make this information available on the Web. DBpedia allows you to ask sophisticated queries against datasets derived from Wikipedia and to link other datasets on the Web to Wikipedia data. + +## – B – + +### bachbhg17 + +["Hinge-Loss Markov Random Fields and Probabilistic Soft Logic"](https://arxiv.org/abs/1505.04406) +**Stephen Bach**, **Matthias Broecheler**, **Bert Huang**, **Lise Getoor** +*JMLR* (2017–11–17) +> We introduce two new formalisms for modeling structured data, and show that they can both capture rich structure and scale to big data. The first, hinge-loss Markov random fields (HL-MRFs), is a new kind of probabilistic graphical model that generalizes different approaches to convex inference. + +### barrière2016elsf + +["Entities, Labels, and Surface Forms"](https://doi.org/10.1007/978-3-319-41337-2_2) +**Caroline Barrière** +_Springer_ (2016-11-19) +> We will look into a first obstacle toward this seemingly simple IE goal: the fact that entities do not have normalized names. Instead, entities can be referred to by many different surface forms. + +### besta2024topo + +["Topologies of Reasoning: Demystifying Chains, Trees, and Graphs of Thoughts"](https://arxiv.org/abs/2401.14295) +**Maciej Besta**, **Florim Memedi**, **Zhenyu Zhang**, **Robert Gerstenberger**, **Nils Blach**, **Piotr Nyczyk**, **Marcin Copik**, **Grzegorz Kwasniewski**, **Jurgen Müller**, **Lukas Gianinazzi**, **Ales Kubicek**, **Hubert Niewiadomski**, **Onur Mutlu**, **Torsten Hoefler** +_ETH Zurich_ (2024-01-25) +> Introducing a blueprint and an accompanying taxonomy of prompting schemes, focusing on the underlying structure of reasoning. + +## – C – + +### cabot2023redfm + +["REDFM: a Filtered and Multilingual Relation Extraction Dataset"](https://arxiv.org/abs/2306.09802) +**Pere-Lluís Huguet Cabot**, **Simone Tedeschi**, **Axel-Cyrille Ngonga Ngomo**, **Roberto Navigli** +_ACL_ (2023-06-19) +> Relation Extraction (RE) is a task that identifies relationships between entities in a text, enabling the acquisition of relational facts and bridging the gap between natural language and structured knowledge. However, current RE models often rely on small datasets with low coverage of relation types, particularly when working with languages other than English. In this paper, we address the above issue and provide two new resources that enable the training and evaluation of multilingual RE systems. + +## – E – + +### erxlebengkmv14 + +["Introducing Wikidata to the Linked Data Web"](https://doi.org/10.1007/978-3-319-11964-9_4) +**Fredo Erxleben**, **Michael Günther**, **Markus Krötzsch**, **Julian Mendez**, **Denny Vrandečić** +_ISWC_ (2014-10-19) +> We introduce new RDF exports that connect Wikidata to the Linked Data Web. We explain the data model of Wikidata and discuss its encoding in RDF. Moreover, we introduce several partial exports that provide more selective or simplified views on the data. + +## – F – + +### feng2023kuzu + +["KÙZU Graph Database Management System"](https://www.cidrdb.org/cidr2023/papers/p48-jin.pdf) +**Xiyang Feng**, **Guodong Jin**, **Ziyi Chen**, **Chang Liu**, **Semih Salihoğlu** +_CIDR_ (2023-01-08) +> We present Kùzu, a new GDBMS we are developing at University of Waterloo that aims to integrate state-of-art storage, indexing, and query processing techniques to highly optimize for this feature set. + +## – G – + +### galkin2023ultra + +["Towards Foundation Models for Knowledge Graph Reasoning"](https://arxiv.org/abs/2310.04562) +**Mikhail Galkin**, **Xinyu Yuan**, **Hesham Mostafa**, **Jian Tang**, **Zhaocheng Zhu** +preprint (2023–10–06) +> ULTRA builds relational representations as a function conditioned on their interactions. Such a conditioning strategy allows a pre-trained ULTRA model to inductively generalize to any unseen KG with any relation vocabulary and to be fine-tuned on any graph. + +## – H – + +### hagberg2008 + +["Exploring network structure, dynamics, and function using NetworkX"](https://conference.scipy.org/proceedings/SciPy2008/paper_2/) +**Aric A. Hagberg**, **Daniel A. Schult**, **Pieter J. Swart** +_SciPy2008_ (2008-08-19) +> NetworkX is a Python language package for exploration and analysis of networks and network algorithms. The core package provides data structures for representing many types of networks, or graphs, including simple graphs, directed graphs, and graphs with parallel edges and self loops. + +### hahnr88 + +["Automatic generation of hypertext knowledge bases"](https://doi.org/10.1145/966861.45429) +**Udo Hahn**, **Ulrich Reimer** +_ACM SIGOIS_ 9:2 (1988-04-01) +> The condensation process transforms the text representation structures resulting from the text parse into a more abstract thematic description of what the text is about, filtering out irrelevant knowledge structures and preserving only the most salient concepts. + +### hamilton2020grl + +[_Graph Representation Learning_](https://www.cs.mcgill.ca/~wlh/grl_book/) +**William Hamilton** +Morgan and Claypool (pre-print 2020) +> A brief but comprehensive introduction to graph representation learning, including methods for embedding graph data, graph neural networks, and deep generative models of graphs. + +### hangyyls19 + +["OpenNRE: An Open and Extensible Toolkit for Neural Relation Extraction"](https://doi.org/10.18653/v1/D19-3029) +**Xu Han**, **Tianyu Gao**, **Yuan Yao**, **Deming Ye**, **Zhiyuan Liu**, **Maosong Sun** +*EMNLP* (2019-11-03) +> OpenNRE is an open-source and extensible toolkit that provides a unified framework to implement neural models for relation extraction (RE). + +### hartig14 + +["Reconciliation of RDF* and Property Graphs"](https://arxiv.org/abs/1409.3288) +**Olaf Hartig** +_CoRR_ (2014-11-14) +> The document proposes a formalization of the PG model and introduces well-defined transformations between PGs and RDF. + +### honnibal2020spacy + +["spaCy: Industrial-strength Natural Language Processing in Python"](https://doi.org/10.5281/zenodo.1212303) +**Matthew Honnibal**, **Ines Montani**, **Sofie Van Landeghem**, **Adriane Boyd** +*Explosion AI* (2016-10-18) +> spaCy is a library for advanced Natural Language Processing in Python and Cython. It's built on the very latest research, and was designed from day one to be used in real products. + +## – L – + +### lee2023ingram + +["InGram: Inductive Knowledge Graph Embedding via Relation Graphs"](https://arxiv.org/abs/2305.19987) +**Jaejun Lee**, **Chanyoung Chung**, **Joyce Jiyoung Whang** +_ICML_ (2023–08–17) +> In this paper, we propose an INductive knowledge GRAph eMbedding method, InGram, that can generate embeddings of new relations as well as new entities at inference time. + +### loganlpgs19 + +["Barack's Wife Hillary: Using Knowledge-Graphs for Fact-Aware Language Modeling"](https://arxiv.org/abs/1906.07241) +**Robert L. Logan IV**, **Nelson F. Liu**, **Matthew E. Peters**, **Matt Gardner**, **Sameer Singh** +_ACL_ (2019-06-20) +> We introduce the knowledge graph language model (KGLM), a neural language model with mechanisms for selecting and copying facts from a knowledge graph that are relevant to the context. + +## – M – + +### martonsv17 + +["Formalising openCypher Graph Queries in Relational Algebra"](https://doi.org/10.1007/978-3-319-66917-5_13) +**József Marton**, **Gábor Szárnyas**, **Dániel Varró** +_ADBIS_ (2017-08-25) +> We present a formal specification for openCypher, a high-level declarative graph query language with an ongoing standardisation effort. + +### mihalcea04textrank + +["TextRank: Bringing Order into Text"](https://www.aclweb.org/anthology/W04-3252/) +**Rada Mihalcea**, **Paul Tarau** +*EMNLP* pp. 404-411 (2004-07-25) +> In this paper, the authors introduce TextRank, a graph-based ranking model for text processing, and show how this model can be successfully used in natural language applications. + +## – N – + +### nathan2016ptr + +["PyTextRank, a Python implementation of TextRank for phrase extraction and summarization of text documents"](https://doi.org/10.5281/zenodo.4637885) +**Paco Nathan**, et al. +*Derwen* (2016-10-03) +> Python implementation of TextRank algorithms ("textgraphs") for phrase extraction + +### nathan2023glod + +["Graph Levels of Detail"](https://blog.derwen.ai/graph-levels-of-detail-ea4226abba55) +**Paco Nathan** +*Derwen* (2023-11-12) +> How can we work with graph data in more abstracted, aggregate perspectives? While we can run queries on graph data to compute aggregate measures, we don’t have programmatic means of “zooming out” to consider a large graph the way that one zooms out when using an online map. + +## - Q - + +### qin2023sgr + +["Semantic Random Walk for Graph Representation Learning in Attributed Graphs"](https://arxiv.org/abs/2305.06531) +**Meng Qin** +*Hong Kong University of Science and Technology* (2023-05-11) +> We introduced a novel SGR method to generally formulate the network embedding in attributed graphs as a high-order proximity based embedding task of an auxilairy weighted graph with heterogeneous entities. + +### qin2024irwe + +["IRWE: Inductive Random Walk for Joint Inference of Identity and Position Network Embedding"](https://arxiv.org/abs/2401.00651) +**Meng Qin**, **Dit-Yan Yeung** +*Hong Kong University of Science and Technology* (2024-01-01) +> Since nodes in a community should be densely connected, nodes within the same community are more likely to be reached via RWs compared with those in different communities. Therefore, nodes with similar positions (e.g., in the same community) are highly believed to have similar RW statistics. + +## - R - + +### ramage2009rwt + +["Random walks for text semantic similarity"](https://dl.acm.org/doi/10.5555/1708124.1708131) +**Daniel Ramage**, **Anna Rafferty**, **Christopher Manning** +_ACL-IJCNLP_ (2009-09-07) +> Our algorithm aggregates local relatedness information via a random walk over a graph constructed from an underlying lexical resource. The stationary distribution of the graph walk forms a “semantic signature” that can be compared to another such distribution to get a relatedness score for texts. + +## – W – + +### warmerdam2023pydata + +["Natural Intelligence is All You Need™"](https://youtu.be/C9p7suS-NGk?si=7Ohq3BV654ia2Im4) +**Vincent Warmerdam** +*PyData Amsterdam* (2023-09-15) +> In this talk I will try to show you what might happen if you allow yourself the creative freedom to rethink and reinvent common practices once in a while. As it turns out, in order to do that, natural intelligence is all you need. And we may start needing a lot of it in the near future. + +### wen2023mindmap + +["MindMap: Knowledge Graph Prompting Sparks Graph of Thoughts in Large Language Models"](https://arxiv.org/abs/2308.09729) +**Yilin Wen**, **Zifeng Wang**, **Jimeng Sun** +_arXiv_ (2023-08-17) +> We build a prompting pipeline that endows LLMs with the capability of comprehending KG inputs and inferring with a combined implicit knowledge and the retrieved external knowledge. + +### wolf2020transformers + +["Transformers: State-of-the-Art Natural Language Processing"](https://doi.org/10.18653/v1/2020.emnlp-demos.6) +**Thomas Wolf**, **Lysandre Debut**, **Victor Sanh**, **Julien Chaumond**, **Clement Delangue**, **Anthony Moi**, **Pierric Cistac**, **Tim Rault**, **Remi Louf**, **Morgan Funtowicz**, **Joe Davison**, **Sam Shleifer**, **Patrick von Platen**, **Clara Ma**, **Yacine Jernite**, **Julien Plu**, **Canwen Xu**, **Teven Le Scao**, **Sylvain Gugger**, **Mariama Drame**, **Quentin Lhoest**, **Alexander Rush** +*EMNLP* (2020-11-16) +> The library consists of carefully engineered state-of-the art Transformer architectures under a unified API. Backing this library is a curated collection of pretrained models made by and available for the community. diff --git a/docs/build.md b/docs/build.md new file mode 100644 index 0000000000000000000000000000000000000000..b42f3f0db1e5675b116224b1586411f9f61bd938 --- /dev/null +++ b/docs/build.md @@ -0,0 +1,132 @@ +# Build Instructions + +API by Adnen Kadri from the Noun Project + +!!! note + In most cases you won't need to build this package locally. + +Unless you're doing development work on the **textgraphs** library itself, +simply install based on the instructions in +["Getting Started"](https://derwen.ai/docs/txg/start/). + + +## Setup + +To set up the build environment locally: +``` +python3 -m venv venv +source venv/bin/activate +python3 -m pip install -U pip wheel setuptools + +python3 -m pip install -e . +python3 -m pip install -r requirements-dev.txt +``` + +We use *pre-commit hooks* based on [`pre-commit`](https://pre-commit.com/) +and to configure that locally: +``` +pre-commit install --hook-type pre-commit +``` + + +## Test Coverage + +This project uses +[`pytest`](https://docs.pytest.org/) +for *unit test* coverage. +Source for unit tests is in the +[`tests`](https://github.com/DerwenAI/textgraphs/tree/main/tests) +subdirectory. + +To run the unit tests: +``` +python3 -m pytest +``` + +Note that these tests run as part of the CI workflow +whenever code is updated on the GitHub repo. + + +## Online Documentation + +To generate documentation pages, you will also need to download +[`ChromeDriver`](https://googlechromelabs.github.io/chrome-for-testing/) +for your version of the `Chrome` browser, saved as `chromedriver` in +this directory. + +Source for the documentation is in the +[`docs`](https://github.com/DerwenAI/textgraphs/tree/main/docs) +subdirectory. + +To build the documentation: +``` +./bin/nb_md.sh +./pkg_doc.py docs/ref.md +mkdocs build +``` + +Then run `./bin/preview.py` and load +in your browser to preview the generated microsite locally. + +To package the generated microsite for deployment on a +web server: +``` +tar cvzf txg.tgz site/ +``` + + +## Remote Repo Updates + +To update source code repo on GitHub: + +``` +git remote set-url origin https://github.com/DerwenAI/textgraphs.git +git push +``` + +Create new releases on GitHub then run `git pull` locally prior to +updating Hugging Face or making a new package release. + +To update source code repo+demo on Hugging Face: + +``` +git remote set-url origin https://huggingface.co/spaces/DerwenAI/textgraphs +git push +``` + + +## Package Release + +To update the [release on PyPi](https://pypi.org/project/textgraphs/): +``` +./bin/push_pypi.sh +``` + + +## Packaging + +Both the spaCy and PyPi teams induce packaging errors since they +have "opinionated" views which conflict against each other and also +don't quite follow the [Python packaging standards](https://peps.python.org/pep-0621/). + +Moreover, the various dependencies here use a wide range of approaches +for model downloads: quite appropriately, the spaCy team does not want +to package their language models on PyPi. +However, they don't use more contemporary means of model download, +such as HF transformers, either -- and that triggers logging problems. +Overall, logging approaches used by the dependencies here for errors/warnings +are mostly ad-hoc. + +These three issues (packaging, model downloads, logging) pose a small nightmare +for managing Python library packaging downstream. +To that point, this project implements several workarounds so that +applications can download from PyPi. + +Meanwhile keep watch on developments of the following dependencies, +if they introduce breaking changes or move toward more standard +packaging practices: + + * `spaCy` -- model downloads, logging + * `OpenNRE` -- PyPi packaging, logging + * HF `transformers` and `tokenizers` -- logging + * WikiMedia APIs -- SSL certificate expiry diff --git a/docs/conclude.md b/docs/conclude.md new file mode 100644 index 0000000000000000000000000000000000000000..235c2081018c11b7ef783bcc613fb4cade6158ab --- /dev/null +++ b/docs/conclude.md @@ -0,0 +1,53 @@ +# Conclusions + +**DRAFT** (WIP) + +`TextGraphs` library provides a highly configurable and extensible open source Python library for the integration and evaluation of several LLM components. This has been built with attention to allowing for concurrency and parallelism for high-performance computing on distributed systems. + +TODO: + + - leverage co-reference + - leverage closure constrained by domain/range + - general => specific, uncertain => confident + +The state of _relation extraction_ is arguably immature. +While the papers in this area compare against benchmarks, their training datasets mostly have been built from Wikidata sources, and inferred relations result in _labels_ not IRIs. +This precludes downstream use of the inferred relations for semantic inference. +Ultimately, how can better training data be developed -- e.g., for relation extraction -- to improve large models used in constructing/augmenting knowledge graphs? + +## Questions for Follow Up Research + +Many existing projects produce results which are **descriptive, but not computable**. +However, given recent innovations, such as _DPO_, there appear to be many opportunities for reworking the training datasets used in +NRE and RE models, following the pattern of `Notus` + +**R1**: we have demonstrated how to leverage LLM components while emphasizing HITL (domain experts) and quality of results + + +**R2**: we have suggested areas where investments in data quality +may provide substantial gains + +One key take-away from this project is that the model deployments are relatively haphazard across a wide spectrum of performance: some of the open source dependencies use efficient frameworks such as Hugging Face `transformers` to load models, while others use ad-hoc approaches which are much less performant. + +Granted, use of LLMs and other deep learning models is expected to increase computational requirements substantially. +Given the integration of APIs, the compute, memory, and network requirements for running the `TextGraphs` library in product can be quite large. +Software engineering optimizations can reduce these requirements substantially through use of hardware acceleration, localized services, proxy/caching, and concurrency. + +However, a more effective approach would be to make investments in data quality (training datasets, benchmarks, evals, etc.) for gains within the core technologies used here: NER, RE, etc. +Data-first iterations on the model dependencies can alleviate much of this problem. + + +**R3**: we have proposed a rubric for evaluating/rating ML open source +w.r.t. production use cases + +This project integrates available open source projects across a wide range of NLP topics. +Perspectives were gained from evaluating many open source LLM projects related to NLP components, and the state of readiness for their use in production libraries overall. + +Note that reproducibility rates are abysmally low for open source which accompanies machine learning research papers. +Few project install correctly, and fewer still run without exceptions. +Even among the better available OSS project for a given research topic (e.g., _graph embeddings_, _relation extraction_) tend to not have been maintained for years. Of the projects which run, few reproduce their published results, and most are oriented toward command-line (CLI) use to prove specific benchmarks claims. +These tend to be difficult to rework into production-quality libraries, due to concerns about performance, security, licensing, etc. + +As an outcome of this inquiry, this project presents a rubric for evaluating research papers and their associated code, based on reproducibility and eventual usefulness in software implementations. + +The views expressed are those of the authors and do not reflect the official policy or position of the funding organizations. diff --git a/docs/details.md b/docs/details.md new file mode 100644 index 0000000000000000000000000000000000000000..1e31f07e8247ca532c9a3abb118e89f989f15e8d --- /dev/null +++ b/docs/details.md @@ -0,0 +1,64 @@ +This project Implements an LLM-augmented `textgraph` algorithm for +constructing a _lemma graph_ from raw, unstructured text source. + +The `TextGraphs` library is based on work developed by +[Derwen](https://derwen.ai/graph) +in 2023 Q2 for customer apps and used in our `Cysoni` +product. + +This library integrates code from: + + * [`SpanMarker`](https://github.com/tomaarsen/SpanMarkerNER/) + * [`spaCy-DBpedia-Spotlight`](https://github.com/MartinoMensio/spacy-dbpedia-spotlight) + * [`REBEL`](https://github.com/Babelscape/rebel) + * [`OpenNRE`](https://github.com/thunlp/OpenNRE/) + * [`qwikidata`](https://github.com/kensho-technologies/qwikidata) + * [`pulp`](https://github.com/coin-or/pulp) + * [`spaCy`](https://spacy.io/) + * [`HF transformers`](https://huggingface.co/docs/transformers/index) + * [`PyTextRank`](https://github.com/DerwenAI/pytextrank/) + + +For more background about early efforts which led to this line of inquiry, see the recent talks: + + * ["Language, Graphs, and AI in Industry"](https://derwen.ai/s/mqqm) + **Paco Nathan**, K1st World (2023-10-11) ([video](https://derwen.ai/s/4h2kswhrm3gc)) + * ["Language Tools for Creators"](https://derwen.ai/s/rhvg) + **Paco Nathan**, FOSSY (2023-07-13) + + +The `TextGraphs` library shows integrations of several of these kinds +of components, complemented with use of graph queries, graph algorithms, +and other related tooling. +Admittedly, the results present a "hybrid" approach: +it's not purely "generative" -- whatever that might mean. + +A core principle here is to provide results from the natural language +workflows which may be used for expert feedback. +In other words, how can we support means for leveraging +_human-in-the-loop_ (HITL) process? + +Another principle has been to create a Python library built to produced +configurable, extensible pipelines. +Care has been given to writing code that can be run concurrently +(e.g., leveraging `asyncio`), using dependencies which have +business-friendly licenses, and paying attention to security concerns. + +The library provides three main affordances for AI applications: + + 1. With the default settings, one can use `TextGraphs` to extracti ranked key phrases from raw text -- even without using any of the additional deep learning models. + + 2. Going a few further steps, one can generate an RDF or LPG graph from raw texts, and make use of _entity linking_, _relation extraction_, and other techniques to ground the natural language parsing by leveraging some knowledge graph which represents a particular domain. Default examples use WikiMedia graphs: DBPedia, Wikidata, etc. + + 3. A third set of goals for `TextGraphs` is to provide a "playground" or "gym" for evaluating _graph levels of detail_, i.e., abstraction layers for knowledge graphs, and explore some the emerging work to produced _foundation models_ for knowledge graphs through topological transforms. + +Regarding the third point, consider how language parsing produces +graphs by definition, although NLP results tend to be quite _noisy_. +The annotations inferred by NLP pipelines often get thrown out. +This seemed like a good opportunity to generate sample data for +"condensing" graphs into more abstracted representations. +In other words, patterns within the relatively noisy parse results +can be condensed into relatively refined knowledge graph elements. + +Note that while the `spaCy` library for NLP plays a central role, the +`TextGraphs` library is not intended to become a `spaCy` pipeline. diff --git a/docs/ex0_0.md b/docs/ex0_0.md new file mode 100644 index 0000000000000000000000000000000000000000..a2c4950e8696d27a6da8d67533884d519c80dd01 --- /dev/null +++ b/docs/ex0_0.md @@ -0,0 +1,689 @@ + + +!!! note + To run this notebook in JupyterLab, load [`examples/ex0_0.ipynb`](https://github.com/DerwenAI/textgraphs/blob/main/examples/ex0_0.ipynb) + + + +# demo: TextGraphs + LLMs to construct a 'lemma graph' + +_TextGraphs_ library is intended for iterating through a sequence of paragraphs. + +## environment + + +```python +from IPython.display import display, HTML, Image, SVG +import pathlib +import typing + +from icecream import ic +from pyinstrument import Profiler +import matplotlib.pyplot as plt +import pandas as pd +import pyvis +import spacy + +import textgraphs +``` + + +```python +%load_ext watermark +``` + + +```python +%watermark +``` + + Last updated: 2024-01-16T17:41:51.229985-08:00 + + Python implementation: CPython + Python version : 3.10.11 + IPython version : 8.20.0 + + Compiler : Clang 13.0.0 (clang-1300.0.29.30) + OS : Darwin + Release : 21.6.0 + Machine : x86_64 + Processor : i386 + CPU cores : 8 + Architecture: 64bit + + + + +```python +%watermark --iversions +``` + + sys : 3.10.11 (v3.10.11:7d4cc5aa85, Apr 4 2023, 19:05:19) [Clang 13.0.0 (clang-1300.0.29.30)] + spacy : 3.7.2 + pandas : 2.1.4 + matplotlib: 3.8.2 + textgraphs: 0.5.0 + pyvis : 0.3.2 + + + +## parse a document + +provide the source text + + +```python +SRC_TEXT: str = """ +Werner Herzog is a remarkable filmmaker and an intellectual originally from Germany, the son of Dietrich Herzog. +After the war, Werner fled to America to become famous. +""" +``` + +set up the statistical stack profiling + + +```python +profiler: Profiler = Profiler() +profiler.start() +``` + +set up the `TextGraphs` pipeline + + +```python +tg: textgraphs.TextGraphs = textgraphs.TextGraphs( + factory = textgraphs.PipelineFactory( + spacy_model = textgraphs.SPACY_MODEL, + ner = None, + kg = textgraphs.KGWikiMedia( + spotlight_api = textgraphs.DBPEDIA_SPOTLIGHT_API, + dbpedia_search_api = textgraphs.DBPEDIA_SEARCH_API, + dbpedia_sparql_api = textgraphs.DBPEDIA_SPARQL_API, + wikidata_api = textgraphs.WIKIDATA_API, + min_alias = textgraphs.DBPEDIA_MIN_ALIAS, + min_similarity = textgraphs.DBPEDIA_MIN_SIM, + ), + infer_rels = [ + textgraphs.InferRel_OpenNRE( + model = textgraphs.OPENNRE_MODEL, + max_skip = textgraphs.MAX_SKIP, + min_prob = textgraphs.OPENNRE_MIN_PROB, + ), + textgraphs.InferRel_Rebel( + lang = "en_XX", + mrebel_model = textgraphs.MREBEL_MODEL, + ), + ], + ), +) + +pipe: textgraphs.Pipeline = tg.create_pipeline( + SRC_TEXT.strip(), +) +``` + +## visualize the parse results + + +```python +spacy.displacy.render( + pipe.ner_doc, + style = "ent", + jupyter = True, +) +``` + + +
+ + Werner Herzog + PERSON + + is a remarkable filmmaker and an intellectual originally from + + Germany + GPE + +, the son of + + Dietrich Herzog + PERSON + +.
After the war, + + Werner + PERSON + + fled to + + America + GPE + + to become famous.
+ + + +```python +parse_svg: str = spacy.displacy.render( + pipe.ner_doc, + style = "dep", + jupyter = False, +) + +display(SVG(parse_svg)) +``` + + + +![svg](ex0_0_files/ex0_0_17_0.svg) + + + +## collect graph elements from the parse + + +```python +tg.collect_graph_elements( + pipe, + debug = False, +) +``` + + +```python +ic(len(tg.nodes.values())); +ic(len(tg.edges.values())); +``` + + ic| len(tg.nodes.values()): 36 + ic| len(tg.edges.values()): 42 + + +## perform entity linking + + +```python +tg.perform_entity_linking( + pipe, + debug = False, +) +``` + +## infer relations + + +```python +inferred_edges: list = await tg.infer_relations_async( + pipe, + debug = False, +) + +inferred_edges +``` + + + + + [Edge(src_node=0, dst_node=10, kind=, rel='https://schema.org/nationality', prob=1.0, count=1), + Edge(src_node=15, dst_node=0, kind=, rel='https://schema.org/children', prob=1.0, count=1), + Edge(src_node=27, dst_node=22, kind=, rel='https://schema.org/event', prob=1.0, count=1)] + + + +## construct a lemma graph + + +```python +tg.construct_lemma_graph( + debug = False, +) +``` + +## extract ranked entities + + +```python +tg.calc_phrase_ranks( + pr_alpha = textgraphs.PAGERANK_ALPHA, + debug = False, +) +``` + +show the resulting entities extracted from the document + + +```python +df: pd.DataFrame = tg.get_phrases_as_df() +df +``` + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
node_idtextposlabelcountweight
00Werner HerzogPROPNdbr:Werner_Herzog10.080547
110GermanyPROPNdbr:Germany10.080437
215Dietrich HerzogPROPNdbo:Person10.079048
327AmericaPROPNdbr:United_States10.079048
424WernerPROPNdbo:Person10.077633
54filmmakerNOUNowl:Thing10.076309
622warNOUNowl:Thing10.076309
732a remarkable filmmakernoun_chunkNone10.076077
87intellectualNOUNowl:Thing10.074725
913sonNOUNowl:Thing10.074725
1033an intellectualnoun_chunkNone10.074606
1134the sonnoun_chunkNone10.074606
1235the warnoun_chunkNone10.074606
+
+ + + +## visualize the lemma graph + + +```python +render: textgraphs.RenderPyVis = tg.create_render() + +pv_graph: pyvis.network.Network = render.render_lemma_graph( + debug = False, +) +``` + +initialize the layout parameters + + +```python +pv_graph.force_atlas_2based( + gravity = -38, + central_gravity = 0.01, + spring_length = 231, + spring_strength = 0.7, + damping = 0.8, + overlap = 0, +) + +pv_graph.show_buttons(filter_ = [ "physics" ]) +pv_graph.toggle_physics(True) +``` + + +```python +pv_graph.prep_notebook() +pv_graph.show("tmp.fig01.html") +``` + + tmp.fig01.html + + + + + + +![png](ex0_0_files/tmp.fig01.png) + + + + +## generate a word cloud + + +```python +wordcloud = render.generate_wordcloud() +display(wordcloud.to_image()) +``` + + + +![png](ex0_0_files/ex0_0_37_0.png) + + + +## cluster communities in the lemma graph + +In the tutorial +"How to Convert Any Text Into a Graph of Concepts", +Rahul Nayak uses the +girvan-newman +algorithm to split the graph into communities, then clusters on those communities. +His approach works well for unsupervised clustering of key phrases which have been extracted from many documents. +In contrast, Nayak was working with entities extracted from "chunks" of text, not with a text graph. + + +```python +render.draw_communities(); +``` + + + +![png](ex0_0_files/ex0_0_40_0.png) + + + +## graph of relations transform + +Show a transformed graph, based on _graph of relations_ (see: `lee2023ingram`) + + +```python +graph: textgraphs.GraphOfRelations = textgraphs.GraphOfRelations( + tg +) + +graph.seeds() +graph.construct_gor() +``` + + +```python +scores: typing.Dict[ tuple, float ] = graph.get_affinity_scores() +pv_graph: pyvis.network.Network = graph.render_gor_pyvis(scores) + +pv_graph.force_atlas_2based( + gravity = -38, + central_gravity = 0.01, + spring_length = 231, + spring_strength = 0.7, + damping = 0.8, + overlap = 0, +) + +pv_graph.show_buttons(filter_ = [ "physics" ]) +pv_graph.toggle_physics(True) + +pv_graph.prep_notebook() +pv_graph.show("tmp.fig02.html") +``` + + tmp.fig02.html + + + + + + +![png](ex0_0_files/tmp.fig02.png) + + + + +*What does this transform provide?* + +By using a _graph of relations_ dual representation of our graph data, first and foremost we obtain a more compact representation of the relations in the graph, and means of making inferences (e.g., _link prediction_) where there is substantially more invariance in the training data. + +Also recognize that for a parse graph of a paragraph in the English language, the most interesting nodes will probably be either subjects (`nsubj`) or direct objects (`pobj`). Here in the _graph of relations_ we see illustrated how the important details from _entity linking_ tend to cluster near either `nsubj` or `pobj` entities, connected through punctuation. This is not as readily observed in the earlier visualization of the _lemma graph_. + +## extract as RDF triples + +Extract the nodes and edges which have IRIs, to create an "abstraction layer" as a semantic graph at a higher level of detail above the _lemma graph_: + + +```python +triples: str = tg.export_rdf() +print(triples) +``` + + @base . + @prefix dbo: . + @prefix dbr: . + @prefix schema: . + @prefix skos: . + @prefix wd_ent: . + + dbr:Germany skos:definition "Germany (German: Deutschland, German pronunciation: [ˈdɔʏtʃlant]), constitutionally the Federal"@en ; + skos:prefLabel "Germany"@en . + + dbr:United_States skos:definition "The United States of America (USA), commonly known as the United States (U.S. or US) or America"@en ; + skos:prefLabel "United States"@en . + + dbr:Werner_Herzog skos:definition "Werner Herzog (German: [ˈvɛɐ̯nɐ ˈhɛɐ̯tsoːk]; born 5 September 1942) is a German film director"@en ; + skos:prefLabel "Werner Herzog"@en . + + wd_ent:Q183 skos:definition "country in Central Europe"@en ; + skos:prefLabel "Germany"@en . + + wd_ent:Q44131 skos:definition "German film director, producer, screenwriter, actor and opera director"@en ; + skos:prefLabel "Werner Herzog"@en . + + a dbo:Country ; + skos:prefLabel "America"@en ; + schema:event . + + a dbo:Person ; + skos:prefLabel "Dietrich Herzog"@en ; + schema:children . + + skos:prefLabel "filmmaker"@en . + + skos:prefLabel "intellectual"@en . + + skos:prefLabel "son"@en . + + a dbo:Person ; + skos:prefLabel "Werner"@en . + + a dbo:Country ; + skos:prefLabel "Germany"@en . + + skos:prefLabel "war"@en . + + a dbo:Person ; + skos:prefLabel "Werner Herzog"@en ; + schema:nationality . + + dbo:Country skos:definition "Countries, cities, states"@en ; + skos:prefLabel "country"@en . + + dbo:Person skos:definition "People, including fictional"@en ; + skos:prefLabel "person"@en . + + + + +## statistical stack profile instrumentation + + +```python +profiler.stop() +``` + + + + + + + + + +```python +profiler.print() +``` + + + _ ._ __/__ _ _ _ _ _/_ Recorded: 17:41:51 Samples: 11163 + /_//_/// /_\ / //_// / //_'/ // Duration: 57.137 CPU time: 72.235 + / _/ v4.6.1 + + Program: /Users/paco/src/textgraphs/venv/lib/python3.10/site-packages/ipykernel_launcher.py -f /Users/paco/Library/Jupyter/runtime/kernel-8ffadb7d-3b45-4e0e-a94f-f098e5ad9fbe.json + + 57.136 _UnixSelectorEventLoop._run_once asyncio/base_events.py:1832 + └─ 57.135 Handle._run asyncio/events.py:78 + [12 frames hidden] asyncio, ipykernel, IPython + 41.912 ZMQInteractiveShell.run_ast_nodes IPython/core/interactiveshell.py:3394 + ├─ 20.701 ../ipykernel_5151/1245857438.py:1 + │ └─ 20.701 TextGraphs.perform_entity_linking textgraphs/doc.py:534 + │ └─ 20.701 KGWikiMedia.perform_entity_linking textgraphs/kg.py:306 + │ ├─ 10.790 KGWikiMedia._link_kg_search_entities textgraphs/kg.py:932 + │ │ └─ 10.787 KGWikiMedia.dbpedia_search_entity textgraphs/kg.py:641 + │ │ └─ 10.711 get requests/api.py:62 + │ │ [37 frames hidden] requests, urllib3, http, socket, ssl,... + │ ├─ 9.143 KGWikiMedia._link_spotlight_entities textgraphs/kg.py:851 + │ │ └─ 9.140 KGWikiMedia.dbpedia_search_entity textgraphs/kg.py:641 + │ │ └─ 9.095 get requests/api.py:62 + │ │ [37 frames hidden] requests, urllib3, http, socket, ssl,... + │ └─ 0.768 KGWikiMedia._secondary_entity_linking textgraphs/kg.py:1060 + │ └─ 0.768 KGWikiMedia.wikidata_search textgraphs/kg.py:575 + │ └─ 0.765 KGWikiMedia._wikidata_endpoint textgraphs/kg.py:444 + │ └─ 0.765 get requests/api.py:62 + │ [7 frames hidden] requests, urllib3 + └─ 19.514 ../ipykernel_5151/1708547378.py:1 + ├─ 14.502 InferRel_Rebel.__init__ textgraphs/rel.py:121 + │ └─ 14.338 pipeline transformers/pipelines/__init__.py:531 + │ [39 frames hidden] transformers, torch, , json + ├─ 3.437 PipelineFactory.__init__ textgraphs/pipe.py:434 + │ └─ 3.420 load spacy/__init__.py:27 + │ [20 frames hidden] spacy, en_core_web_sm, catalogue, imp... + ├─ 0.900 InferRel_OpenNRE.__init__ textgraphs/rel.py:33 + │ └─ 0.888 get_model opennre/pretrain.py:126 + └─ 0.672 TextGraphs.create_pipeline textgraphs/doc.py:103 + └─ 0.672 PipelineFactory.create_pipeline textgraphs/pipe.py:508 + └─ 0.672 Pipeline.__init__ textgraphs/pipe.py:216 + └─ 0.672 English.__call__ spacy/language.py:1016 + [11 frames hidden] spacy, spacy_dbpedia_spotlight, reque... + 14.363 InferRel_Rebel.gen_triples_async textgraphs/pipe.py:188 + ├─ 13.670 InferRel_Rebel.gen_triples textgraphs/rel.py:259 + │ ├─ 12.439 InferRel_Rebel.tokenize_sent textgraphs/rel.py:145 + │ │ └─ 12.436 TranslationPipeline.__call__ transformers/pipelines/text2text_generation.py:341 + │ │ [42 frames hidden] transformers, torch, + │ └─ 1.231 KGWikiMedia.resolve_rel_iri textgraphs/kg.py:370 + │ └─ 0.753 get_entity_dict_from_api qwikidata/linked_data_interface.py:21 + │ [8 frames hidden] qwikidata, requests, urllib3 + └─ 0.693 InferRel_OpenNRE.gen_triples textgraphs/rel.py:58 + + + + +## outro + +_\[ more parts are in progress, getting added to this demo \]_ diff --git a/docs/ex0_0_files/ex0_0_17_0.svg b/docs/ex0_0_files/ex0_0_17_0.svg new file mode 100644 index 0000000000000000000000000000000000000000..bb7a5e2f0e0d57f63ace93f83d218576ea60644e --- /dev/null +++ b/docs/ex0_0_files/ex0_0_17_0.svg @@ -0,0 +1,324 @@ + + + Werner Herzog + PROPN + + + + is + AUX + + + + a + DET + + + + remarkable + ADJ + + + + filmmaker + NOUN + + + + and + CCONJ + + + + an + DET + + + + intellectual + NOUN + + + + originally + ADV + + + + from + ADP + + + + Germany, + PROPN + + + + the + DET + + + + son + NOUN + + + + of + ADP + + + + Dietrich Herzog. + PUNCT + + + + + + SPACE + + + + After + ADP + + + + the + DET + + + + war, + NOUN + + + + Werner + PROPN + + + + fled + VERB + + + + to + ADP + + + + America + PROPN + + + + to + PART + + + + become + VERB + + + + famous. + ADJ + + + + + + nsubj + + + + + + + + det + + + + + + + + amod + + + + + + + + attr + + + + + + + + cc + + + + + + + + det + + + + + + + + conj + + + + + + + + advmod + + + + + + + + prep + + + + + + + + pobj + + + + + + + + det + + + + + + + + appos + + + + + + + + prep + + + + + + + + punct + + + + + + + + dep + + + + + + + + prep + + + + + + + + det + + + + + + + + pobj + + + + + + + + nsubj + + + + + + + + prep + + + + + + + + pobj + + + + + + + + aux + + + + + + + + advcl + + + + + + + + acomp + + + + \ No newline at end of file diff --git a/docs/ex0_0_files/ex0_0_37_0.jpg b/docs/ex0_0_files/ex0_0_37_0.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b635cc43be11ce16925b1df75f65777206e75cf5 Binary files /dev/null and b/docs/ex0_0_files/ex0_0_37_0.jpg differ diff --git a/docs/ex0_0_files/ex0_0_37_0.png b/docs/ex0_0_files/ex0_0_37_0.png new file mode 100644 index 0000000000000000000000000000000000000000..d0e38cbd38a5bf00cdfb224481d8deb75269f945 Binary files /dev/null and b/docs/ex0_0_files/ex0_0_37_0.png differ diff --git a/docs/ex0_0_files/ex0_0_39_0.jpg b/docs/ex0_0_files/ex0_0_39_0.jpg new file mode 100644 index 0000000000000000000000000000000000000000..06f8e7ad3e4f5d76fadd952120efcd21ef2b1de8 Binary files /dev/null and b/docs/ex0_0_files/ex0_0_39_0.jpg differ diff --git a/docs/ex0_0_files/ex0_0_39_0.png b/docs/ex0_0_files/ex0_0_39_0.png new file mode 100644 index 0000000000000000000000000000000000000000..79e6f18166f8ffa4be89492c69059339d0b187bd Binary files /dev/null and b/docs/ex0_0_files/ex0_0_39_0.png differ diff --git a/docs/ex0_0_files/ex0_0_40_0.png b/docs/ex0_0_files/ex0_0_40_0.png new file mode 100644 index 0000000000000000000000000000000000000000..60937b66f2a1f8d875d9c699a9e1cb9fcc0576f4 Binary files /dev/null and b/docs/ex0_0_files/ex0_0_40_0.png differ diff --git a/docs/ex0_0_files/ex0_0_42_0.png b/docs/ex0_0_files/ex0_0_42_0.png new file mode 100644 index 0000000000000000000000000000000000000000..7f77eac5b002bf632b1f749d2b22d438350ef40c Binary files /dev/null and b/docs/ex0_0_files/ex0_0_42_0.png differ diff --git a/docs/ex0_0_files/tmp.fig01.png b/docs/ex0_0_files/tmp.fig01.png new file mode 100644 index 0000000000000000000000000000000000000000..b6ad119b26f2ac556da37f1e59af628fa527cfd4 Binary files /dev/null and b/docs/ex0_0_files/tmp.fig01.png differ diff --git a/docs/ex0_0_files/tmp.fig02.png b/docs/ex0_0_files/tmp.fig02.png new file mode 100644 index 0000000000000000000000000000000000000000..eea8c4a4f81eb9f9489e1df8f47da02cbe228674 Binary files /dev/null and b/docs/ex0_0_files/tmp.fig02.png differ diff --git a/docs/ex1_0.md b/docs/ex1_0.md new file mode 100644 index 0000000000000000000000000000000000000000..264a8b2b5422cde6e02ad46958b4300549d8cb56 --- /dev/null +++ b/docs/ex1_0.md @@ -0,0 +1,776 @@ + + +!!! note + To run this notebook in JupyterLab, load [`examples/ex1_0.ipynb`](https://github.com/DerwenAI/textgraphs/blob/main/examples/ex1_0.ipynb) + + + +# reproduce results from the "InGram" paper + +This is an attempt to reproduce the _graph of relations_ example given in `lee2023ingram` + +## environment + + +```python +import os +import pathlib +import typing + +from icecream import ic +from pyinstrument import Profiler +import matplotlib.pyplot as plt +import pandas as pd +import pyvis + +import textgraphs +``` + + +```python +%load_ext watermark +``` + + +```python +%watermark +``` + + Last updated: 2024-01-16T17:35:45.550539-08:00 + + Python implementation: CPython + Python version : 3.10.11 + IPython version : 8.20.0 + + Compiler : Clang 13.0.0 (clang-1300.0.29.30) + OS : Darwin + Release : 21.6.0 + Machine : x86_64 + Processor : i386 + CPU cores : 8 + Architecture: 64bit + + + + +```python +%watermark --iversions +``` + + matplotlib: 3.8.2 + pandas : 2.1.4 + pyvis : 0.3.2 + textgraphs: 0.5.0 + sys : 3.10.11 (v3.10.11:7d4cc5aa85, Apr 4 2023, 19:05:19) [Clang 13.0.0 (clang-1300.0.29.30)] + + + +## load example graph + +load from a JSON file which replicates the data for the "Figure 3" example + + +```python +graph: textgraphs.GraphOfRelations = textgraphs.GraphOfRelations( + textgraphs.SimpleGraph() +) + +ingram_path: pathlib.Path = pathlib.Path(os.getcwd()) / "ingram.json" + +graph.load_ingram( + ingram_path, + debug = False, +) +``` + +set up the statistical stack profiling + + +```python +profiler: Profiler = Profiler() +profiler.start() +``` + +## decouple graph edges into "seeds" + + +```python +graph.seeds( + debug = True, +) +``` + + + --- triples in source graph --- + + + ic| edge.src_node: 0, rel_id: 1, edge.dst_node: 1 + ic| edge.src_node: 0, rel_id: 0, edge.dst_node: 2 + ic| edge.src_node: 0, rel_id: 0, edge.dst_node: 3 + ic| edge.src_node: 4, rel_id: 2, edge.dst_node: 2 + ic| edge.src_node: 4, rel_id: 2, edge.dst_node: 3 + ic| edge.src_node: 4, rel_id: 1, edge.dst_node: 5 + ic| edge.src_node: 6, rel_id: 1, edge.dst_node: 5 + ic| edge.src_node: 6, rel_id: 2, edge.dst_node: 7 + ic| edge.src_node: 6, rel_id: 4, edge.dst_node: 8 + ic| edge.src_node: 9, + + Steven_Spielberg Profession Director + Steven_Spielberg Directed Catch_Me_If_Can + Steven_Spielberg Directed Saving_Private_Ryan + Tom_Hanks ActedIn Catch_Me_If_Can + Tom_Hanks ActedIn Saving_Private_Ryan + Tom_Hanks Profession Actor + Mark_Hamil Profession Actor + Mark_Hamil ActedIn Star_Wars + Mark_Hamil BornIn California + + + rel_id: 5, edge.dst_node: 10 + ic| edge.src_node: 9, rel_id: 4, edge.dst_node: 10 + ic| edge.src_node: 9, rel_id: 3, edge.dst_node: 8 + ic| edge.src_node: 11, rel_id: 4, edge.dst_node: 12 + ic| edge.src_node: 11, rel_id: 3, edge.dst_node: 12 + ic| edge.src_node: 11, rel_id: 3, edge.dst_node: 8 + + + Brad_Pitt Nationality USA + Brad_Pitt BornIn USA + Brad_Pitt LivedIn California + Clint_Eastwood BornIn San_Francisco + Clint_Eastwood LivedIn San_Francisco + Clint_Eastwood LivedIn California + + + +```python +graph.trace_source_graph() +``` + + + --- nodes in source graph --- + n: 0, Steven_Spielberg + head: [] + tail: [(0, 'Profession', 1), (0, 'Directed', 2), (0, 'Directed', 3)] + n: 1, Director + head: [(0, 'Profession', 1)] + tail: [] + n: 2, Catch_Me_If_Can + head: [(0, 'Directed', 2), (4, 'ActedIn', 2)] + tail: [] + n: 3, Saving_Private_Ryan + head: [(0, 'Directed', 3), (4, 'ActedIn', 3)] + tail: [] + n: 4, Tom_Hanks + head: [] + tail: [(4, 'ActedIn', 2), (4, 'ActedIn', 3), (4, 'Profession', 5)] + n: 5, Actor + head: [(4, 'Profession', 5), (6, 'Profession', 5)] + tail: [] + n: 6, Mark_Hamil + head: [] + tail: [(6, 'Profession', 5), (6, 'ActedIn', 7), (6, 'BornIn', 8)] + n: 7, Star_Wars + head: [(6, 'ActedIn', 7)] + tail: [] + n: 8, California + head: [(6, 'BornIn', 8), (9, 'LivedIn', 8), (11, 'LivedIn', 8)] + tail: [] + n: 9, Brad_Pitt + head: [] + tail: [(9, 'Nationality', 10), (9, 'BornIn', 10), (9, 'LivedIn', 8)] + n: 10, USA + head: [(9, 'Nationality', 10), (9, 'BornIn', 10)] + tail: [] + n: 11, Clint_Eastwood + head: [] + tail: [(11, 'BornIn', 12), (11, 'LivedIn', 12), (11, 'LivedIn', 8)] + n: 12, San_Francisco + head: [(11, 'BornIn', 12), (11, 'LivedIn', 12)] + tail: [] + + --- edges in source graph --- + e: 0, Directed + e: 1, Profession + e: 2, ActedIn + e: 3, LivedIn + e: 4, BornIn + e: 5, Nationality + + +## construct a _graph of relations_ + +Transform the graph data into _graph of relations_ + + +```python +graph.construct_gor( + debug = True, +) +``` + + ic| node_id: 0, len(seeds + + + --- transformed triples --- + + + ): 3 + ic| trans_arc: TransArc(pair_key=(0, 1), + a_rel=1, + b_rel=0, + node_id=0, + a_dir=, + b_dir=) + ic| trans_arc: TransArc(pair_key=(0, 1), + a_rel=1, + b_rel=0, + node_id=0, + a_dir=, + b_dir=) + ic| trans_arc: TransArc(pair_key=(0, 0), + a_rel=0, + b_rel=0, + node_id=0, + a_dir=, + b_dir=) + ic| node_id: 1, len(seeds + + + + + ): 1 + ic| node_id: 2, len(seeds): 2 + ic| trans_arc: TransArc(pair_key=(0, 2), + a_rel=0, + b_rel=2, + node_id=2, + a_dir=, + b_dir=< + + (0, 2) Directed.head Catch_Me_If_Can ActedIn.head + + + RelDir.HEAD: 0>) + ic| node_id: 3, len(seeds): 2 + ic| trans_arc: TransArc(pair_key=(0, 2), + a_rel=0, + b_rel=2, + node_id=3, + a_dir=, + b_dir=) + ic| node_id + + + (0, 2) Directed.head Saving_Private_Ryan ActedIn.head + + + + : 4, len(seeds): 3 + ic| trans_arc: TransArc(pair_key=(2, 2), + a_rel=2, + b_rel=2, + node_id=4, + a_dir=, + b_dir=) + ic| trans_arc: TransArc(pair_key=(1, 2), + a_rel=2, + b_rel=1, + node_id=4, + a_dir=, + b_dir=) + ic| trans_arc: TransArc(pair_key=(1, 2) + + (2, 2) ActedIn.tail Tom_Hanks ActedIn.tail + + (1, 2) ActedIn.tail Tom_Hanks Profession.tail + + (1, 2) ActedIn.tail Tom_Hanks Profession.tail + + + , + a_rel=2, + b_rel=1, + node_id=4, + a_dir=, + b_dir=) + ic| + + + + + node_id: 5, len(seeds): 2 + ic| trans_arc: TransArc(pair_key=(1, 1), + a_rel=1, + b_rel=1, + + + (1, 1) Profession.head Actor Profession.head + + + node_id=5, + a_dir=, + b_dir=) + ic| node_id: 6, len(seeds): 3 + ic| trans_arc: TransArc(pair_key=(1, 2), + a_rel=1, + b_rel=2, + node_id=6, + a_dir=, + b_dir=) + ic| trans_arc: TransArc(pair_key=(1, 4), + a_rel=1, + b_rel=4, + node_id=6, + a_dir + + + (1, 4) Profession.tail Mark_Hamil BornIn.tail + + + =, + b_dir=) + ic| trans_arc: TransArc(pair_key=(2, 4), + a_rel=2, + b_rel=4, + node_id=6, + + + + (2, 4) ActedIn.tail Mark_Hamil BornIn.tail + + + a_dir=, + b_dir=) + ic| node_id: 7, len(seeds): 1 + ic| node_id: 8, len(seeds): 3 + ic| trans_arc: TransArc(pair_key=(3, 4), + a_rel=4, + b_rel=3, + node_id=8, + a_dir=, + b_dir=) + ic| trans_arc: TransArc(pair_key=(3, 4), + a_rel=4, + b_rel=3, + node_id=8, + a_dir=, + b_dir=) + ic| trans_arc: TransArc(pair_key=(3, 3), + a_rel=3, + b_rel=3, + node_id=8, + a_dir=, + b_dir=) + ic| node_id: 9, len(seeds): 3 + ic + + + (3, 4) BornIn.head California LivedIn.head + + (3, 3) LivedIn.head California LivedIn.head + + (4, 5) Nationality.tail Brad_Pitt BornIn.tail + + + | trans_arc: TransArc(pair_key=(4, 5), + a_rel=5, + b_rel=4, + node_id=9, + a_dir=, + b_dir=) + ic| trans_arc: TransArc(pair_key=(3, 5), + a_rel=5, + b_rel=3, + node_id=9, + a_dir=, + b_dir=< + + + (3, 5) Nationality.tail Brad_Pitt LivedIn.tail + + + RelDir.TAIL: 1>) + ic| trans_arc: TransArc(pair_key=(3, 4), + a_rel=4, + b_rel=3, + node_id=9, + a_dir=, + b_dir=) + ic| node_id: 10, len(seeds): 2 + ic| trans_arc: TransArc(pair_key=(4, 5), + a_rel=5, + b_rel=4, + node_id=10, + a_dir=, + b_dir=) + ic| node_id: 11, len(seeds): 3 + ic| trans_arc: TransArc(pair_key=(3, + + + (3, 4) BornIn.tail Brad_Pitt LivedIn.tail + + (4, 5) Nationality.head USA BornIn.head + + (3, 4) BornIn.tail Clint_Eastwood LivedIn.tail + + + 4), + a_rel=4, + b_rel=3, + node_id=11, + a_dir=, + b_dir=) + ic + + + (3, 4) BornIn.tail Clint_Eastwood LivedIn.tail + + + | trans_arc: TransArc(pair_key=(3, 4), + a_rel=4, + b_rel=3, + node_id=11, + a_dir=, + b_dir=) + ic| trans_arc: TransArc(pair_key=(3, 3), + a_rel=3, + b_rel=3, + node_id=11, + a_dir=, + b_dir=) + ic| node_id: 12, len(seeds + + + (3, 3) LivedIn.tail Clint_Eastwood LivedIn.tail + + + + ): 2 + ic| trans_arc: TransArc(pair_key=(3, 4), + a_rel=4, + b_rel=3, + node_id=12, + a_dir=, + b_dir=) + + + (3, 4) BornIn.head San_Francisco LivedIn.head + + + + +```python +scores: typing.Dict[ tuple, float ] = graph.get_affinity_scores( + debug = True, +) +``` + + + --- collect shared entity tallies --- + 0 Directed + h: 4 dict_items([(2, 4.0)]) + t: 6 dict_items([(0, 3.0), (1, 3.0)]) + 1 Profession + h: 3 dict_items([(1, 3.0)]) + t: 10 dict_items([(0, 3.0), (2, 5.0), (4, 2.0)]) + 2 ActedIn + h: 4 dict_items([(0, 4.0)]) + t: 10 dict_items([(1, 5.0), (2, 3.0), (4, 2.0)]) + 3 LivedIn + h: 8 dict_items([(3, 3.0), (4, 5.0)]) + t: 10 dict_items([(3, 3.0), (4, 5.0), (5, 2.0)]) + 4 BornIn + h: 7 dict_items([(3, 5.0), (5, 2.0)]) + t: 11 dict_items([(1, 2.0), (2, 2.0), (3, 5.0), (5, 2.0)]) + 5 Nationality + h: 2 dict_items([(4, 2.0)]) + t: 4 dict_items([(3, 2.0), (4, 2.0)]) + + + +```python +ic(scores); +``` + + ic| scores: {(0, 0): 0.3, + (0, 1): 0.2653846153846154, + (0, 2): 0.34285714285714286, + (1, 1): 0.23076923076923078, + (1, 2): 0.3708791208791209, + (1, 4): 0.13247863247863248, + (2, 2): 0.21428571428571427, + (2, 4): 0.12698412698412698, + (3, 3): 0.3333333333333333, + (3, 4): 0.5555555555555556, + (3, 5): 0.2222222222222222, + (4, 5): 0.4444444444444444} + + +## visualize the transform results + + +```python +graph.render_gor_plt(scores) +plt.show() +``` + + + +![png](ex1_0_files/ex1_0_22_0.png) + + + + +```python +pv_graph: pyvis.network.Network = graph.render_gor_pyvis(scores) + +pv_graph.force_atlas_2based( + gravity = -38, + central_gravity = 0.01, + spring_length = 231, + spring_strength = 0.7, + damping = 0.8, + overlap = 0, +) + +pv_graph.show_buttons(filter_ = [ "physics" ]) +pv_graph.toggle_physics(True) + +pv_graph.prep_notebook() +pv_graph.show("tmp.fig03.html") +``` + + tmp.fig03.html + + + + + + +![png](ex1_0_files/tmp.fig03.png) + + + + +## analysis + +As the results below above illustrate, the computed _affinity scores_ differ from what is published in `lee2023ingram`. After trying several different variations of interpretation for the paper's descriptions, the current approach provides the closest approximation that we have obtained. + + +```python +df: pd.DataFrame = graph.trace_metrics(scores) +df +``` + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
pairrel_arel_baffinityexpected
0(0, 0)DirectedDirected0.30NaN
1(0, 1)DirectedProfession0.270.22
2(0, 2)DirectedActedIn0.340.50
3(1, 1)ProfessionProfession0.23NaN
4(1, 2)ProfessionActedIn0.370.33
5(1, 4)ProfessionBornIn0.130.11
6(2, 2)ActedInActedIn0.21NaN
7(2, 4)ActedInBornIn0.130.11
8(3, 3)LivedInLivedIn0.33NaN
9(3, 4)LivedInBornIn0.560.81
10(3, 5)LivedInNationality0.220.11
11(4, 5)BornInNationality0.440.36
+
+ + + +## statistical stack profile instrumentation + + +```python +profiler.stop() +``` + + + + + + + + + +```python +profiler.print() +``` + + + _ ._ __/__ _ _ _ _ _/_ Recorded: 17:35:45 Samples: 2526 + /_//_/// /_\ / //_// / //_'/ // Duration: 3.799 CPU time: 4.060 + / _/ v4.6.1 + + Program: /Users/paco/src/textgraphs/venv/lib/python3.10/site-packages/ipykernel_launcher.py -f /Users/paco/Library/Jupyter/runtime/kernel-27f0c564-73f8-45ab-9f64-8b064ae1de10.json + + 3.799 IPythonKernel.dispatch_queue ipykernel/kernelbase.py:525 + └─ 3.791 IPythonKernel.process_one ipykernel/kernelbase.py:511 + [10 frames hidden] ipykernel, IPython + 3.680 ZMQInteractiveShell.run_ast_nodes IPython/core/interactiveshell.py:3394 + ├─ 2.176 ../ipykernel_4421/3358887201.py:1 + │ └─ 2.176 GraphOfRelations.construct_gor textgraphs/gor.py:311 + │ ├─ 1.607 IceCreamDebugger.__call__ icecream/icecream.py:204 + │ │ [17 frames hidden] icecream, colorama, ipykernel, thread... + │ │ 1.078 lock.acquire + │ └─ 0.566 GraphOfRelations._transformed_triples textgraphs/gor.py:275 + │ └─ 0.563 IceCreamDebugger.__call__ icecream/icecream.py:204 + │ [13 frames hidden] icecream, colorama, ipykernel, zmq, t... + ├─ 0.866 ../ipykernel_4421/4061275008.py:1 + │ └─ 0.866 GraphOfRelations.seeds textgraphs/gor.py:197 + │ └─ 0.865 IceCreamDebugger.__call__ icecream/icecream.py:204 + │ [42 frames hidden] icecream, inspect, posixpath, ../ipykernel_4421/559531165.py:1 + │ ├─ 0.234 show matplotlib/pyplot.py:482 + │ │ [32 frames hidden] matplotlib, matplotlib_inline, IPytho... + │ └─ 0.128 GraphOfRelations.render_gor_plt textgraphs/gor.py:522 + │ └─ 0.104 draw_networkx networkx/drawing/nx_pylab.py:127 + │ [6 frames hidden] networkx, matplotlib + ├─ 0.197 ../ipykernel_4421/1169542473.py:1 + │ └─ 0.197 IceCreamDebugger.__call__ icecream/icecream.py:204 + │ [14 frames hidden] icecream, colorama, ipykernel, thread... + └─ 0.041 ../ipykernel_4421/2247466716.py:1 + + + + +## outro + +_\[ more parts are in progress, getting added to this demo \]_ diff --git a/docs/ex1_0_files/ex1_0_22_0.png b/docs/ex1_0_files/ex1_0_22_0.png new file mode 100644 index 0000000000000000000000000000000000000000..1b967566da2d7f2b99602a8778dc8cf6c7a5a4e4 Binary files /dev/null and b/docs/ex1_0_files/ex1_0_22_0.png differ diff --git a/docs/ex1_0_files/tmp.fig01.png b/docs/ex1_0_files/tmp.fig01.png new file mode 100644 index 0000000000000000000000000000000000000000..fe2f7e8f265ef433420f25e581b47e5a573127c9 Binary files /dev/null and b/docs/ex1_0_files/tmp.fig01.png differ diff --git a/docs/ex1_0_files/tmp.fig03.png b/docs/ex1_0_files/tmp.fig03.png new file mode 100644 index 0000000000000000000000000000000000000000..0c6efe1e0a52816640fde9dc509fd96a0c29fea4 Binary files /dev/null and b/docs/ex1_0_files/tmp.fig03.png differ diff --git a/docs/ex2_0.md b/docs/ex2_0.md new file mode 100644 index 0000000000000000000000000000000000000000..874b505b3e6cef12aefd664d52cac1d05eaf96ff --- /dev/null +++ b/docs/ex2_0.md @@ -0,0 +1,249 @@ + + +!!! note + To run this notebook in JupyterLab, load [`examples/ex2_0.ipynb`](https://github.com/DerwenAI/textgraphs/blob/main/examples/ex2_0.ipynb) + + + +# bootstrap the _lemma graph_ with RDF triples + +Show how to bootstrap definitions in a _lemma graph_ by loading RDF, e.g., for synonyms. + +## environment + + +```python +from icecream import ic +from pyinstrument import Profiler +import pyvis + +import textgraphs +``` + + +```python +%load_ext watermark +``` + + +```python +%watermark +``` + + Last updated: 2024-01-16T17:35:59.608787-08:00 + + Python implementation: CPython + Python version : 3.10.11 + IPython version : 8.20.0 + + Compiler : Clang 13.0.0 (clang-1300.0.29.30) + OS : Darwin + Release : 21.6.0 + Machine : x86_64 + Processor : i386 + CPU cores : 8 + Architecture: 64bit + + + + +```python +%watermark --iversions +``` + + pyvis : 0.3.2 + textgraphs: 0.5.0 + sys : 3.10.11 (v3.10.11:7d4cc5aa85, Apr 4 2023, 19:05:19) [Clang 13.0.0 (clang-1300.0.29.30)] + + + +## load the bootstrap definitions + +Define the bootstrap RDF triples in N3/Turtle format: we define an entity `Werner` as a synonym for `Werner Herzog` by using the [`skos:broader`](https://www.w3.org/TR/skos-reference/#semantic-relations) relation. Keep in mind that this entity may also refer to other Werners... + + +```python +TTL_STR: str = """ +@base . +@prefix dbo: . +@prefix skos: . + + a dbo:Person ; + skos:prefLabel "Werner"@en . + + a dbo:Person ; + skos:prefLabel "Werner Herzog"@en. + +dbo:Person skos:definition "People, including fictional"@en ; + skos:prefLabel "person"@en . + + skos:broader . +""" +``` + +Provide the source text + + +```python +SRC_TEXT: str = """ +Werner Herzog is a remarkable filmmaker and an intellectual originally from Germany, the son of Dietrich Herzog. +After the war, Werner fled to America to become famous. +""" +``` + +set up the statistical stack profiling + + +```python +profiler: Profiler = Profiler() +profiler.start() +``` + +set up the `TextGraphs` pipeline + + +```python +tg: textgraphs.TextGraphs = textgraphs.TextGraphs( + factory = textgraphs.PipelineFactory( + kg = textgraphs.KGWikiMedia( + spotlight_api = textgraphs.DBPEDIA_SPOTLIGHT_API, + dbpedia_search_api = textgraphs.DBPEDIA_SEARCH_API, + dbpedia_sparql_api = textgraphs.DBPEDIA_SPARQL_API, + wikidata_api = textgraphs.WIKIDATA_API, + min_alias = textgraphs.DBPEDIA_MIN_ALIAS, + min_similarity = textgraphs.DBPEDIA_MIN_SIM, + ), + ), +) +``` + +load the bootstrap definitions + + +```python +tg.load_bootstrap_ttl( + TTL_STR, + debug = False, +) +``` + +parse the input text + + +```python +pipe: textgraphs.Pipeline = tg.create_pipeline( + SRC_TEXT.strip(), +) + +tg.collect_graph_elements( + pipe, + debug = False, +) + +tg.construct_lemma_graph( + debug = False, +) +``` + +## visualize the lemma graph + + +```python +render: textgraphs.RenderPyVis = tg.create_render() + +pv_graph: pyvis.network.Network = render.render_lemma_graph( + debug = False, +) +``` + +initialize the layout parameters + + +```python +pv_graph.force_atlas_2based( + gravity = -38, + central_gravity = 0.01, + spring_length = 231, + spring_strength = 0.7, + damping = 0.8, + overlap = 0, +) + +pv_graph.show_buttons(filter_ = [ "physics" ]) +pv_graph.toggle_physics(True) +``` + + +```python +pv_graph.prep_notebook() +pv_graph.show("tmp.fig04.html") +``` + + tmp.fig04.html + + + + + + +![png](ex2_0_files/tmp.fig04.png) + + + + +Notice how the `Werner` and `Werner Herzog` nodes are now linked? This synonym from the bootstrap definitions above provided means to link more portions of the _lemma graph_ than the demo in `ex0_0` with the same input text. + +## statistical stack profile instrumentation + + +```python +profiler.stop() +``` + + + + + + + + + +```python +profiler.print() +``` + + + _ ._ __/__ _ _ _ _ _/_ Recorded: 17:35:59 Samples: 2846 + /_//_/// /_\ / //_// / //_'/ // Duration: 4.111 CPU time: 3.294 + / _/ v4.6.1 + + Program: /Users/paco/src/textgraphs/venv/lib/python3.10/site-packages/ipykernel_launcher.py -f /Users/paco/Library/Jupyter/runtime/kernel-4365d4ba-2d4d-4d4b-83e2-eb5ef8abfe26.json + + 4.111 IPythonKernel.dispatch_shell ipykernel/kernelbase.py:378 + └─ 4.075 IPythonKernel.execute_request ipykernel/kernelbase.py:721 + [9 frames hidden] ipykernel, IPython + 3.995 ZMQInteractiveShell.run_ast_nodes IPython/core/interactiveshell.py:3394 + ├─ 3.250 ../ipykernel_4433/1372904243.py:1 + │ └─ 3.248 PipelineFactory.__init__ textgraphs/pipe.py:434 + │ └─ 3.232 load spacy/__init__.py:27 + │ [98 frames hidden] spacy, en_core_web_sm, catalogue, imp... + │ 0.496 tokenizer_factory spacy/language.py:110 + │ └─ 0.108 _validate_special_case spacy/tokenizer.pyx:573 + │ 0.439 spacy/language.py:2170 + │ └─ 0.085 _validate_special_case spacy/tokenizer.pyx:573 + ├─ 0.672 ../ipykernel_4433/3257668275.py:1 + │ └─ 0.669 TextGraphs.create_pipeline textgraphs/doc.py:103 + │ └─ 0.669 PipelineFactory.create_pipeline textgraphs/pipe.py:508 + │ └─ 0.669 Pipeline.__init__ textgraphs/pipe.py:216 + │ └─ 0.669 English.__call__ spacy/language.py:1016 + │ [31 frames hidden] spacy, spacy_dbpedia_spotlight, reque... + └─ 0.055 ../ipykernel_4433/72966960.py:1 + └─ 0.046 Network.prep_notebook pyvis/network.py:552 + [5 frames hidden] pyvis, jinja2 + + + + +## outro + +_\[ more parts are in progress, getting added to this demo \]_ diff --git a/docs/ex2_0_files/tmp.fig01.png b/docs/ex2_0_files/tmp.fig01.png new file mode 100644 index 0000000000000000000000000000000000000000..b2051fb64f6060ac21f9c107ee331787f5303d6c Binary files /dev/null and b/docs/ex2_0_files/tmp.fig01.png differ diff --git a/docs/ex2_0_files/tmp.fig04.png b/docs/ex2_0_files/tmp.fig04.png new file mode 100644 index 0000000000000000000000000000000000000000..e5fb2a1e160a6aa6b17bf0b4af77ddb0e495956a Binary files /dev/null and b/docs/ex2_0_files/tmp.fig04.png differ diff --git a/docs/glod.md b/docs/glod.md new file mode 100644 index 0000000000000000000000000000000000000000..c7b539b936de88bf7acbbbfb14f1ce8e343b8bf8 --- /dev/null +++ b/docs/glod.md @@ -0,0 +1,6 @@ +**TODO**: summarize from + +Overall, this approach relies on a notion of developing "abstraction layers" atop graph data: how can graphs be analyze at differing levels of detail? + +For this one must understand patterns in graphs such as network motifs and how to run topological transforms to help identify patterns. +A side benefit is that such transform can help boost the invariance of graph representation used when training models. diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000000000000000000000000000000000000..568afd28348df803ede3a0816f93d536adc3aecc --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,25 @@ +books by b a r z i n from the Noun Project + +**DRAFT** + +- controlled vocabulary +- entity extraction +- entity linking +- generative AI +- graph levels of detail (GLOD) +- human-in-the-loop (HITL) +- internationalized resource identifier (IRI) +- knowledge graph construction +- knowledge graph (KG) +- labeled property graph (LPG) +- large language models (LLM) +- named entity recognition (NER) +- natural language processing (NLP) +- network motifs +- prompt engineering +- relation extraction (RE) +- retrieval augmented generation (RAG) +- semantic random walk +- statistical relational learning (SRL) +- topological decomposition of graphs +- topological transforms diff --git a/docs/graph.md b/docs/graph.md new file mode 100644 index 0000000000000000000000000000000000000000..6f656a1e9bd1124290ddb0e395df0c7805c7b2cf --- /dev/null +++ b/docs/graph.md @@ -0,0 +1,28 @@ +While many papers proceed from a graph-theoretic definition `G = (V, E)` these typically fail to take into account two important aspects of graph technologies in industry practice: + + 1. _labels_ and _properties_ (key/value attribute pairs) for more effective modeling of linked data + 2. _internationalized resource identifiers_ (IRIs) as unique identifiers that map into controlled vocabularies, which can be leveraged for graph queries and semantic inference + +Industry analysts sometimes point to these two concerns being represented by competiting approaches, namely +_labeled property graphs_ (LPG) representation versus +_semantic web standards_ defined by the World Wide Web Consortium (W3C). +Efforts are in progress to harmonize both of these needs within the same graphs, such as [#hartig14](biblio.md#hartig14) for eventual standards. +However, with some discipline in data modeling practices, both of these criteria can be met within current graph frameworks, provided that: + + * nodes and edges each have specific labels which serve as IRIs that map to a set of controlled vocabularies + * nodes and edges each have properties, which include probabilities from the point of generation + +Building on definitions given in [#martonsv17](biblio.md#martonsv17), [#qin2023sgr](biblio.md#qin2023sgr), this project proceeds from the perspective of primarily using LPG graph representation, while adhering to the aforementioned data modeling discipline. + +`G = (V, E, src, tgt, lbl, P)` is an edge-labeled directed multigraph with: + + - a set of nodes V + - a set of edges E + - function `src`: E → V` that associates each edge with its source vertex + - function `tgt: E → V` that associates each edge with its target vertex + - function `lbl: E → dom(S)` that associates each edge its label + - function `P: (V ∪ E) → 2p` that associates nodes and edges with their properties + +The project architecture enables a "map-reduce" style of distributed processing, so that "chunks" of text (e.g., paragraphs) can be processed independently, with results being aggregated at the end of a batch. +The intermediate processing of each "chunk" uses `NetworkX` [#hagberg2008](biblio.md#hagberg2008) to allow for running in-memory graph algorithms and analytics, and integrate more efficiently with graph machine learning libraries. +Then an `openCypher` representation [#martonsv17](biblio.md#martonsv17) is used to serialize end results, which get aggregated using the open source `KùzuDB` graph database [#feng2023kuzu](biblio.md#feng2023kuzu) and its Python API. diff --git a/docs/hitl.md b/docs/hitl.md new file mode 100644 index 0000000000000000000000000000000000000000..2f44b66eff58e513b521c93f97bfb899de63bf04 --- /dev/null +++ b/docs/hitl.md @@ -0,0 +1,21 @@ +Rather than fully automatic KG construction, this approach emphasizes means of incorporating _domain experts_ through "human-in-the-loop" (HITL) techniques. + +Multiple techniques can be employed to construct gradients for both the generated nodes and edges, starting with the quantitative scores from model inference. + + - gradient for recommending extracted entities: _named entity recognition_, _textrank_, _probabilistic soft logic_, etc. + - gradient for recommending extracted relations: _relation extraction_, _graph of relations_, etc. + +Results extracted from _lemma graphs_ provide gradients which can be leveraged to elicit feedback from domain experts: + + - high-pass filter: accept results as valid automated inference + - low-pass filter: reject results as errors and noise + +For the results which fall in-between, a recsys or similar UI can elicit review from domain experts, based on _active learning_, _weak supervision_, etc. see + +subsequent to the HITL validation, the more valuable results collected within a _lemma graph_ can be extracted as the primary output from this approach. + +Based on a process of iterating through a text document in chunks, the results from one iteration can be used to bootstrap the _lemma graph_ for the next iteration. this provides a natural means of accumulating (i.e., aggregating) results from the overall analysis. + +By extension, this bootstrap/accumulation process can be used in the distributed processing of a corpus of documents, where the "data exhaust" of abstracted _lemma graphs_ used to bootstrap analysis workflows effectively becomes a _knowledge graph_, as a side-effect of the analysis. + + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000000000000000000000000000000000000..9845b34871c409abe6112d82dbe420d2ba101939 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,50 @@ +# TextGraphs: raw texts, LLMs, and KGs, oh my! + +illustration of a lemma graph + +Welcome to the **TextGraphs** library... + + - demo: + - code: + - biblio: + - DOI: 10.5281/zenodo.10431783 + + +## Overview + +_Explore uses of large language models (LLMs) in semi-automated knowledge graph (KG) construction from unstructured text sources, with human-in-the-loop (HITL) affordances to incorporate guidance from domain experts._ + +What is "generative AI" in the context of working with knowledge graphs? +Initial attempts tend to fit a simple pattern based on _prompt engineering_: present text sources to a LLM-based chat interface, asking to generate an entire graph. +This is generally expensive and results are often poor. +Moreover, the lack of controls or curation in this approach represents a serious disconnect with how KGs get curated to represent an organization's domain expertise. + +Can the definition of "generative" be reformulated for KGs? +Instead of trying to use a fully-automated "black box", what if it were possible to generate _composable elements_ which then get aggregated into a KG? +Some research in topological analysis of graphs indicates potential ways to decompose graphs, which can then be re-composed probabilistically. +While the mathematics may be sound, these techniques need to be understood in the context of a full range of tasks within KG-construction workflows to assess how they can apply for real-world graph data. + +This project explores the use of LLM-augmented components within natural language workflows, focusing on small well-defined tasks within the scope of KG construction. +To address challenges in this problem, this project considers improved means of tokenization, for handling input. +In addition, a range of methods are considered for filtering and selecting elements of the output stream, re-composing them into KGs. +This has a side-effect of providing steps toward better pattern identification and variable abstraction layers for graph data, for _graph levels of detail_ (GLOD). + +Many papers aim to evaluate benchmarks, in contrast this line of inquiry focuses on integration: +means of combining multiple complementary research projects; +how to evaluate the outcomes of other projects to assess their potential usefulness in production-quality libraries; +and suggested directions for improving the LLM-based components of NLP workflows used to construct KGs. + + +## Index Terms + +_natural language processing_, +_knowledge graph construction_, +_large language models_, +_entity extraction_, +_entity linking_, +_relation extraction_, +_semantic random walk_, +_human-in-the-loop_, +_topological decomposition of graphs_, +_graph levels of detail_, +_network motifs_, diff --git a/docs/javascripts/config.js b/docs/javascripts/config.js new file mode 100644 index 0000000000000000000000000000000000000000..ece598636faadb2127b1bf4ff4ad1fd716bdcc45 --- /dev/null +++ b/docs/javascripts/config.js @@ -0,0 +1,12 @@ +window.MathJax = { + tex: { + inlineMath: [["\\(", "\\)"]], + displayMath: [["\\[", "\\]"]], + processEscapes: true, + processEnvironments: true + }, + options: { + ignoreHtmlClass: ".*|", + processHtmlClass: "arithmatex" + } +}; diff --git a/docs/lemma.md b/docs/lemma.md new file mode 100644 index 0000000000000000000000000000000000000000..60e6699f60990c28456c15b97d92c492e97d9043 --- /dev/null +++ b/docs/lemma.md @@ -0,0 +1,23 @@ +# Lemma Graph + +This project introduces the notion of a _lemma graph_ as an intermediate representation. +Effectively, this provides a kind of cache during the processing of each "chunk" of text. +Think of the end result as "enhanced tokenization" for text used to generate graph data elements. +Other projects might call this by different names: +an "evidence graph" in [#wen2023mindmap](biblio.md#wen2023mindmap) +or a "dynamically growing local KG" in [#loganlpgs19](biblio.md#loganlpgs19). + +The lemma graph collects metadata from NLP parsing, entity linking, etc., which generally get discarded in many applications. +Therefore the lemma graph becomes rather "noisy", and in most cases would be too big to store across the analysis of a large corpus. + +Leveraging this intermediate form, per chunk, collect the valuable information about nodes, edges, properties, probabilities, etc., to aggregate for the document analysis overall. + +Consequently, this project explores the use of topological transforms on graphs to enhance representations for [_graph levels of detail_](https://blog.derwen.ai/graph-levels-of-detail-ea4226abba55), i.e., being able to understand a graph a varying levels of abstraction. +Note that adjacent areas of interest include emerging work on: + + - _graph of relations_ + - _foundation models for KGs_ + +Means for "bootstrapping" a _lemma graph_ with initial semantic relations, allows for "sampling" from a curated KG to enhance the graph algorithms used, e.g., through _semantic random walks_ which allow for incorporating heterogeneous sources and relatively large-scale external KGs. +This mechanism also creates opportunities for distributed processing, because the "chunks" of text can follow a _task parallel_ pattern, accumulating the extracted results from each lemma graph into a graph database. +Augmenting a KG iteratively over time follows a similar pattern. diff --git a/docs/methods.md b/docs/methods.md new file mode 100644 index 0000000000000000000000000000000000000000..08e25394f3a4fc0abce299113086aa69a66ca942 --- /dev/null +++ b/docs/methods.md @@ -0,0 +1,39 @@ +# Technical Approach + +Construct a _lemma graph_, then perform _entity linking_ based on: +`spaCy`, `transformers`, `SpanMarkerNER`, +`spaCy-DBpedia-Spotlight`, `REBEL`, `OpenNRE`, +`qwikidata`, `pulp` + + 1. use `spaCy` to parse a document, augmented by `SpanMarker` use of LLMs for NER + 1. add noun chunks in parallel to entities, as "candidate" phrases for subsequent HITL confirmation + 1. perform _entity linking_: `spaCy-DBpedia-Spotlight`, `WikiMedia API`, etc. + 1. infer relations, plus graph inference: `REBEL`, `OpenNRE`, `qwikidata`, etc. + 1. build a _lemma graph_ in `NetworkX` from the parse results + 1. run a modified `textrank` algorithm plus graph analytics + 1. approximate a _pareto archive_ (hypervolume) to re-rank extracted entities with `pulp` + 1. visualize the _lemma graph_ interactively in `PyVis` + 1. cluster communities within the _lemma graph_ + 1. apply topological transforms to enhance graph ML and embeddings + 1. build ML models from the _graph of relations_ (in progress) + +In other words, this hybrid approach integrates +_NLP parsing_, _LLMs_, _graph algorithms_, _semantic inference_, +_operations research_, and also provides UX affordances for including +_human-in-the-loop_ practices. + +The demo app and the Hugging Face space both illustrate a relatively +small problem, although they address a much broader class of AI problems +in industry. + +This step is a prelude before leveraging +_topological transforms_, _large language models_, _graph representation learning_, +plus _human-in-the-loop_ domain expertise to infer +the nodes, edges, properties, and probabilities needed for the +semi-automated construction of _knowledge graphs_ from +raw unstructured text sources. + +In addition to providing a library for production use cases, +`TextGraphs` creates a "playground" or "gym" +in which to prototype and evaluate abstractions based on +["Graph Levels Of Detail"](https://blog.derwen.ai/graph-levels-of-detail-ea4226abba55) diff --git a/docs/nlp.md b/docs/nlp.md new file mode 100644 index 0000000000000000000000000000000000000000..41f374b50e5fb95cb92bacede0c44f37471df8c5 --- /dev/null +++ b/docs/nlp.md @@ -0,0 +1,15 @@ +The open source `spaCy` library in Python provides full-featured NLP capabilities. +[#honnibal2020spacy](biblio.md#honnibal2020spacy) +This serves as a core component of this project. +Recent releases of `spaCy` have provided features to integrate with selected large models, and also support native features for entity linking. + +On the one hand, `spaCy` pipelines offer a broad range of integrations and "opinionated" selections for both utility and ease of use. +The resulting pipelines are optimized for annotating streams of spans of tokens. +On the other hand, the opinionated API calls and the abstractions use for pipeline construction and configuration present some important constraints: + + - Pipelines are not especially well-suited for propagating other forms of generated data, beyond token/span streams. + - Tokenization used in `spaCy` does not align with the requirements for relation extraction projects of interest. + - Entity linking capabilities rely on using an internally defined "knowledge base" which is not well-suited for integrating with heterogeneous resources. + +Consequently, while `spaCy` serves as a core component for NLP capabilities, this project presents a library of Python class definitions for KG construction which can be extended and configured to accommodate a broad range of LLM components. +These "less opinionated" pipeline definitions, in the broader scope, are optimized for managing streams of KG candidate elements which have been produced by generative AI. diff --git a/docs/objectives.md b/docs/objectives.md new file mode 100644 index 0000000000000000000000000000000000000000..baf59fa5134fa00194abf099333c2e39a096483d --- /dev/null +++ b/docs/objectives.md @@ -0,0 +1,19 @@ +Consider three classes of composable elements which are needed for constructing KGs: *nodes*, *edges*, *properties*. +Several areas of machine learning (ML) research can be leveraged to generate these elements from unstructured text sources: + + - nodes: NER, node prediction + - edges: relation extraction (RE), semantic inference, link prediction + - properties: NLP parse, entity linking, graph analytics + +Weights or probabilities from the analysis can also be used to construct *gradients* for ranking each class of elements in the generated output. +This supports multiple approaches for filtering, selection, and abstraction of the generated composable elements, and helps incorporate domain expertise. + +A set of questions follows from this line of inquiry: + +**RQ1**: can workflows be defined which integrate LLM-based components and generate _composable elements_ for KGs, while managing the quality of the generated results? + +**RQ2**: can topological analysis and decomposition of graph data help inform better ways to generating graph elements, e.g., by leveraging patterns within graphs (network motifs) and graph abstraction layers? + +**RQ3**: where might it be possible to improve data quality for -- training data, benchmarks, evals, etc. -- then iterate to train more effective LLM-based components? + +**RQ4**: how can consistent evaluations of open source related to ML research be made, assessing opportunities for reusing code in production-quality libraries? diff --git a/docs/prob.md b/docs/prob.md new file mode 100644 index 0000000000000000000000000000000000000000..92041a30e6407ee1e4054d3db2118fa908642409 --- /dev/null +++ b/docs/prob.md @@ -0,0 +1,19 @@ +**TODO**: summarize from + +results from the combined analysis get collected into an intermediate form which is a probabilistic structure called a _lemma graph_. + +note: NLP parsers tend to produce a wealth of annotations from raw text, most of which are thrown away in many application. what if instead this parse information got collected together, temporarily while analyzing a chunk of text? + +an application running in production most likely would not want to persist the entirety of _lemma graph_ data generated during analysis of a full corpus. instead, consider this structure as a kind of temporary cache during the analysis for one unit of work, i.e., a "chunk" of text. + +from the pragmatics of writing, editing, and critical review, a natural size for this kind of chunking is to analyze at the paragraph level. in some domains, such as analysis of patent applications, chunking at the level of "claims" might be indicated. + +the probabilistic aspects of the intermediate _lemma graph_ data become especially important in a linguistic context: + + * entities have many _surface forms_ + * synonyms (synsets) change meanings in different domains, especially when abbreviated + * ambiguous references may exist, though not all are important to resolve based on "premature optimization" + +Note that semantic modeling practices using RDF tend to have a relatively trivial notion of "synonyms", notably by annotating a subject with one _preferred label_ and zero or more additional labels. +This may be sufficiently descriptive for building taxonomies manually; however, this approach is not sufficient for making the modeled representation computable in light of the many kinds of _surface forms_ and possible sources of ambiguity. +The RDF representation uses `skos:broader` to connect surface forms, and the LPG representation uses probabilities to manage disambiguation these terms. diff --git a/docs/ref.md b/docs/ref.md new file mode 100644 index 0000000000000000000000000000000000000000..90633b454d15251a921dff8a703dddc456cc0033 --- /dev/null +++ b/docs/ref.md @@ -0,0 +1,1646 @@ +# Reference: `textgraphs` package +API by Adnen Kadri from the Noun Project +Package definitions for the `TextGraphs` library. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md + + +## [`TextGraphs` class](#TextGraphs) + +Construct a _lemma graph_ from the unstructured text source, +then extract ranked phrases using a `textgraph` algorithm. + +--- +#### [`infer_relations_async` method](#textgraphs.TextGraphs.infer_relations_async) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L641) + +```python +infer_relations_async(pipe, debug=False) +``` +Gather triples representing inferred relations and build edges, +concurrently by running an async queue. + + +Make sure to call beforehand: `TextGraphs.collect_graph_elements()` + + * `pipe` : `textgraphs.pipe.Pipeline` +configured pipeline for this document + + * `debug` : `bool` +debugging flag + + * *returns* : `typing.List[textgraphs.elem.Edge]` +a list of the inferred `Edge` objects + + + +--- +#### [`__init__` method](#textgraphs.TextGraphs.__init__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L80) + +```python +__init__(factory=None, iri_base="https://github.com/DerwenAI/textgraphs/ns/") +``` +Constructor. + + * `factory` : `typing.Optional[textgraphs.pipe.PipelineFactory]` +optional `PipelineFactory` used to configure components + + + +--- +#### [`create_pipeline` method](#textgraphs.TextGraphs.create_pipeline) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L103) + +```python +create_pipeline(text_input) +``` +Use the pipeline factory to create a pipeline (e.g., `spaCy.Document`) +for each text input, which are typically paragraph-length. + + * `text_input` : `str` +raw text to be parsed by this pipeline + + * *returns* : `textgraphs.pipe.Pipeline` +a configured pipeline + + + +--- +#### [`create_render` method](#textgraphs.TextGraphs.create_render) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L122) + +```python +create_render() +``` +Create an object for rendering the graph in `PyVis` HTML+JavaScript. + + * *returns* : `textgraphs.vis.RenderPyVis` +a configured `RenderPyVis` object for generating graph visualizations + + + +--- +#### [`collect_graph_elements` method](#textgraphs.TextGraphs.collect_graph_elements) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L381) + +```python +collect_graph_elements(pipe, text_id=0, para_id=0, debug=False) +``` +Collect the elements of a _lemma graph_ from the results of running +the `textgraph` algorithm. These elements include: parse dependencies, +lemmas, entities, and noun chunks. + +Make sure to call beforehand: `TextGraphs.create_pipeline()` + + * `pipe` : `textgraphs.pipe.Pipeline` +configured pipeline for this document + + * `text_id` : `int` +text (top-level document) identifier + + * `para_id` : `int` +paragraph identitifer + + * `debug` : `bool` +debugging flag + + + +--- +#### [`construct_lemma_graph` method](#textgraphs.TextGraphs.construct_lemma_graph) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L474) + +```python +construct_lemma_graph(debug=False) +``` +Construct the base level of the _lemma graph_ from the collected +elements. This gets represented in `NetworkX` as a directed graph +with parallel edges. + +Make sure to call beforehand: `TextGraphs.collect_graph_elements()` + + * `debug` : `bool` +debugging flag + + + +--- +#### [`perform_entity_linking` method](#textgraphs.TextGraphs.perform_entity_linking) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L534) + +```python +perform_entity_linking(pipe, debug=False) +``` +Perform _entity linking_ based on the `KnowledgeGraph` object. + +Make sure to call beforehand: `TextGraphs.collect_graph_elements()` + + * `pipe` : `textgraphs.pipe.Pipeline` +configured pipeline for this document + + * `debug` : `bool` +debugging flag + + + +--- +#### [`infer_relations` method](#textgraphs.TextGraphs.infer_relations) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L705) + +```python +infer_relations(pipe, debug=False) +``` +Gather triples representing inferred relations and build edges. + +Make sure to call beforehand: `TextGraphs.collect_graph_elements()` + + * `pipe` : `textgraphs.pipe.Pipeline` +configured pipeline for this document + + * `debug` : `bool` +debugging flag + + * *returns* : `typing.List[textgraphs.elem.Edge]` +a list of the inferred `Edge` objects + + + +--- +#### [`calc_phrase_ranks` method](#textgraphs.TextGraphs.calc_phrase_ranks) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L893) + +```python +calc_phrase_ranks(pr_alpha=0.85, debug=False) +``` +Calculate the weights for each node in the _lemma graph_, then +stack-rank the nodes so that entities have priority over lemmas. + +Phrase ranks are normalized to sum to 1.0 and these now represent +the ranked entities extracted from the document. + +Make sure to call beforehand: `TextGraphs.construct_lemma_graph()` + + * `pr_alpha` : `float` +optional `alpha` parameter for the PageRank algorithm + + * `debug` : `bool` +debugging flag + + + +--- +#### [`get_phrases` method](#textgraphs.TextGraphs.get_phrases) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L940) + +```python +get_phrases() +``` +Return the entities extracted from the document. + +Make sure to call beforehand: `TextGraphs.calc_phrase_ranks()` + + * *yields* : +extracted entities + + + +--- +#### [`get_phrases_as_df` method](#textgraphs.TextGraphs.get_phrases_as_df) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L973) + +```python +get_phrases_as_df() +``` +Return the ranked extracted entities as a dataframe. + +Make sure to call beforehand: `TextGraphs.calc_phrase_ranks()` + + * *returns* : `pandas.core.frame.DataFrame` +a `pandas.DataFrame` of the extracted entities + + + +--- +#### [`export_rdf` method](#textgraphs.TextGraphs.export_rdf) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L990) + +```python +export_rdf(lang="en") +``` +Extract the entities and relations which have IRIs as RDF triples. + + * `lang` : `str` +language identifier + + * *returns* : `str` +RDF triples N3 (Turtle) format as a string + + + +--- +#### [`denormalize_iri` method](#textgraphs.TextGraphs.denormalize_iri) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L1085) + +```python +denormalize_iri(uri_ref) +``` +Discern between a parsed entity and a linked entity. + + * *returns* : `str` +_lemma_key_ for a parsed entity, the full IRI for a linked entity + + + +--- +#### [`load_bootstrap_ttl` method](#textgraphs.TextGraphs.load_bootstrap_ttl) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L1103) + +```python +load_bootstrap_ttl(ttl_str, debug=False) +``` +Parse a TTL string with an RDF semantic graph representation to load +bootstrap definitions for the _lemma graph_ prior to parsing, e.g., +for synonyms. + + * `ttl_str` : `str` +RDF triples in TTL (Turtle/N3) format + + * `debug` : `bool` +debugging flag + + + +--- +#### [`export_kuzu` method](#textgraphs.TextGraphs.export_kuzu) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/doc.py#L1215) + +```python +export_kuzu(zip_name="lemma.zip", debug=False) +``` +Export a labeled property graph for KùzuDB (openCypher). + + * `debug` : `bool` +debugging flag + + * *returns* : `str` +name of the generated ZIP file + + + +## [`SimpleGraph` class](#SimpleGraph) + +An in-memory graph used to build a `MultiDiGraph` in NetworkX. + +--- +#### [`__init__` method](#textgraphs.SimpleGraph.__init__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/graph.py#L31) + +```python +__init__() +``` +Constructor. + + + +--- +#### [`reset` method](#textgraphs.SimpleGraph.reset) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/graph.py#L42) + +```python +reset() +``` +Re-initialize the data structures, resetting all but the configuration. + + + +--- +#### [`make_node` method](#textgraphs.SimpleGraph.make_node) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/graph.py#L53) + +```python +make_node(tokens, key, span, kind, text_id, para_id, sent_id, label=None, length=1, linked=True) +``` +Lookup and return a `Node` object. +By default, link matching keys into the same node. +Otherwise instantiate a new node if it does not exist already. + + * `tokens` : `typing.List[textgraphs.elem.Node]` +list of parsed tokens + + * `key` : `str` +lemma key (invariant) + + * `span` : `spacy.tokens.token.Token` +token span for the parsed entity + + * `kind` : `` +the kind of this `Node` object + + * `text_id` : `int` +text (top-level document) identifier + + * `para_id` : `int` +paragraph identitifer + + * `sent_id` : `int` +sentence identifier + + * `label` : `typing.Optional[str]` +node label (for a new object) + + * `length` : `int` +length of token span + + * `linked` : `bool` +flag for whether this links to an entity + + * *returns* : `textgraphs.elem.Node` +the constructed `Node` object + + + +--- +#### [`make_edge` method](#textgraphs.SimpleGraph.make_edge) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/graph.py#L167) + +```python +make_edge(src_node, dst_node, kind, rel, prob, key=None, debug=False) +``` +Lookup an edge, creating a new one if it does not exist already, +and increment the count if it does. + + * `src_node` : `textgraphs.elem.Node` +source node in the triple + + * `dst_node` : `textgraphs.elem.Node` +destination node in the triple + + * `kind` : `` +the kind of this `Edge` object + + * `rel` : `str` +relation label + + * `prob` : `float` +probability of this `Edge` within the graph + + * `key` : `typing.Optional[str]` +lemma key (invariant); generate a key if this is not provided + + * `debug` : `bool` +debugging flag + + * *returns* : `typing.Optional[textgraphs.elem.Edge]` +the constructed `Edge` object; this may be `None` if the input parameters indicate skipping the edge + + + +--- +#### [`dump_lemma_graph` method](#textgraphs.SimpleGraph.dump_lemma_graph) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/graph.py#L236) + +```python +dump_lemma_graph() +``` +Dump the _lemma graph_ as a JSON string in _node-link_ format, +suitable for serialization and subsequent use in JavaScript, +Neo4j, Graphistry, etc. + +Make sure to call beforehand: `TextGraphs.calc_phrase_ranks()` + + * *returns* : `str` +a JSON representation of the exported _lemma graph_ in + + + +--- +#### [`load_lemma_graph` method](#textgraphs.SimpleGraph.load_lemma_graph) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/graph.py#L299) + +```python +load_lemma_graph(json_str, debug=False) +``` +Load from a JSON string in +a JSON representation of the exported _lemma graph_ in +[_node-link_](https://networkx.org/documentation/stable/reference/readwrite/json_graph.html) +format + + * `debug` : `bool` +debugging flag + + + +## [`Node` class](#Node) + +A data class representing one node, i.e., an extracted phrase. + +--- +#### [`__repr__` method](#textgraphs.Node.__repr__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/dataclasses.py#L232) + +```python +__repr__() +``` + +--- +#### [`get_linked_label` method](#textgraphs.Node.get_linked_label) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/elem.py#L119) + +```python +get_linked_label() +``` +When this node has a linked entity, return that IRI. +Otherwise return its `label` value. + + * *returns* : `typing.Optional[str]` +a label for the linked entity + + + +--- +#### [`get_name` method](#textgraphs.Node.get_name) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/elem.py#L135) + +```python +get_name() +``` +Return a brief name for the graphical depiction of this Node. + + * *returns* : `str` +brief label to be used in a graph + + + +--- +#### [`get_stacked_count` method](#textgraphs.Node.get_stacked_count) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/elem.py#L152) + +```python +get_stacked_count() +``` +Return a modified count, to redact verbs and linked entities from +the stack-rank partitions. + + * *returns* : `int` +count, used for re-ranking extracted entities + + + +--- +#### [`get_pos` method](#textgraphs.Node.get_pos) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/elem.py#L168) + +```python +get_pos() +``` +Generate a position span for `OpenNRE`. + + * *returns* : `typing.Tuple[int, int]` +a position span needed for `OpenNRE` relation extraction + + + +## [`Edge` class](#Edge) + +A data class representing an edge between two nodes. + +--- +#### [`__repr__` method](#textgraphs.Edge.__repr__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/dataclasses.py#L232) + +```python +__repr__() +``` + +## [`EnumBase` class](#EnumBase) + +A mixin for Enum codecs. + +## [`NodeEnum` class](#NodeEnum) + +Enumeration for the kinds of node categories + +## [`RelEnum` class](#RelEnum) + +Enumeration for the kinds of edge relations + +## [`PipelineFactory` class](#PipelineFactory) + +Factory pattern for building a pipeline, which is one of the more +expensive operations with `spaCy` + +--- +#### [`__init__` method](#textgraphs.PipelineFactory.__init__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L434) + +```python +__init__(spacy_model="en_core_web_sm", ner=None, kg=, infer_rels=[]) +``` +Constructor which instantiates the `spaCy` pipelines: + + * `tok_pipe` -- regular generator for parsed tokens + * `ner_pipe` -- with entities merged + * `aux_pipe` -- spotlight entity linking + +which will be needed for parsing and entity linking. + + * `spacy_model` : `str` +the specific model to use in `spaCy` pipelines + + * `ner` : `typing.Optional[textgraphs.pipe.Component]` +optional custom NER component + + * `kg` : `textgraphs.pipe.KnowledgeGraph` +knowledge graph used for entity linking + + * `infer_rels` : `typing.List[textgraphs.pipe.InferRel]` +a list of components for inferring relations + + + +--- +#### [`create_pipeline` method](#textgraphs.PipelineFactory.create_pipeline) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L508) + +```python +create_pipeline(text_input) +``` +Instantiate the document pipelines needed to parse the input text. + + * `text_input` : `str` +raw text to be parsed + + * *returns* : `textgraphs.pipe.Pipeline` +a configured `Pipeline` object + + + +## [`Pipeline` class](#Pipeline) + +Manage parsing of a document, which is assumed to be paragraph-sized. + +--- +#### [`__init__` method](#textgraphs.Pipeline.__init__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L216) + +```python +__init__(text_input, tok_pipe, ner_pipe, aux_pipe, kg, infer_rels) +``` +Constructor. + + * `text_input` : `str` +raw text to be parsed + + * `tok_pipe` : `spacy.language.Language` +the `spaCy.Language` pipeline used for tallying individual tokens + + * `ner_pipe` : `spacy.language.Language` +the `spaCy.Language` pipeline used for tallying named entities + + * `aux_pipe` : `spacy.language.Language` +the `spaCy.Language` pipeline used for auxiliary components (e.g., `DBPedia Spotlight`) + + * `kg` : `textgraphs.pipe.KnowledgeGraph` +knowledge graph used for entity linking + + * `infer_rels` : `typing.List[textgraphs.pipe.InferRel]` +a list of components for inferring relations + + + +--- +#### [`get_lemma_key` classmethod](#textgraphs.Pipeline.get_lemma_key) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L267) + +```python +get_lemma_key(span, placeholder=False) +``` +Compose a unique, invariant lemma key for the given span. + + * `span` : `typing.Union[spacy.tokens.span.Span, spacy.tokens.token.Token]` +span of tokens within the lemma + + * `placeholder` : `bool` +flag for whether to create a placeholder + + * *returns* : `str` +a composed lemma key + + + +--- +#### [`get_ent_lemma_keys` method](#textgraphs.Pipeline.get_ent_lemma_keys) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L308) + +```python +get_ent_lemma_keys() +``` +Iterate through the fully qualified lemma keys for an extracted entity. + + * *yields* : +the lemma keys within an extracted entity + + + +--- +#### [`link_noun_chunks` method](#textgraphs.Pipeline.link_noun_chunks) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L321) + +```python +link_noun_chunks(nodes, debug=False) +``` +Link any noun chunks which are not already subsumed by named entities. + + * `nodes` : `dict` +dictionary of `Node` objects in the graph + + * `debug` : `bool` +debugging flag + + * *returns* : `typing.List[textgraphs.elem.NounChunk]` +a list of identified noun chunks which are novel + + + +--- +#### [`iter_entity_pairs` method](#textgraphs.Pipeline.iter_entity_pairs) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L373) + +```python +iter_entity_pairs(pipe_graph, max_skip, debug=True) +``` +Iterator for entity pairs for which the algorithm infers relations. + + * `pipe_graph` : `networkx.classes.multigraph.MultiGraph` +a `networkx.MultiGraph` representation of the graph, reused for graph algorithms + + * `max_skip` : `int` +maximum distance between entities for inferred relations + + * `debug` : `bool` +debugging flag + + * *yields* : +pairs of entities within a range, e.g., to use for relation extraction + + + +## [`Component` class](#Component) + +Abstract base class for a `spaCy` pipeline component. + +--- +#### [`augment_pipe` method](#textgraphs.Component.augment_pipe) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L41) + +```python +augment_pipe(factory) +``` +Encapsulate a `spaCy` call to `add_pipe()` configuration. + + * `factory` : `PipelineFactory` +a `PipelineFactory` used to configure components + + + +## [`NERSpanMarker` class](#NERSpanMarker) + +Configures a `spaCy` pipeline component for `SpanMarkerNER` + +--- +#### [`__init__` method](#textgraphs.NERSpanMarker.__init__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/ner.py#L22) + +```python +__init__(ner_model="tomaarsen/span-marker-roberta-large-ontonotes5") +``` +Constructor. + + * `ner_model` : `str` +model to be used in `SpanMarker` + + + +--- +#### [`augment_pipe` method](#textgraphs.NERSpanMarker.augment_pipe) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/ner.py#L36) + +```python +augment_pipe(factory) +``` +Encapsulate a `spaCy` call to `add_pipe()` configuration. + + * `factory` : `textgraphs.pipe.PipelineFactory` +the `PipelineFactory` used to configure this pipeline component + + + +## [`NounChunk` class](#NounChunk) + +A data class representing one noun chunk, i.e., a candidate as an extracted phrase. + +--- +#### [`__repr__` method](#textgraphs.NounChunk.__repr__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/dataclasses.py#L232) + +```python +__repr__() +``` + +## [`KnowledgeGraph` class](#KnowledgeGraph) + +Base class for a _knowledge graph_ interface. + +--- +#### [`augment_pipe` method](#textgraphs.KnowledgeGraph.augment_pipe) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L63) + +```python +augment_pipe(factory) +``` +Encapsulate a `spaCy` call to `add_pipe()` configuration. + + * `factory` : `PipelineFactory` +a `PipelineFactory` used to configure components + + + +--- +#### [`remap_ner` method](#textgraphs.KnowledgeGraph.remap_ner) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L76) + +```python +remap_ner(label) +``` +Remap the OntoTypes4 values from NER output to more general-purpose IRIs. + + * `label` : `typing.Optional[str]` +input NER label, an `OntoTypes4` value + + * *returns* : `typing.Optional[str]` +an IRI for the named entity + + + +--- +#### [`normalize_prefix` method](#textgraphs.KnowledgeGraph.normalize_prefix) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L92) + +```python +normalize_prefix(iri, debug=False) +``` +Normalize the given IRI to use standard namespace prefixes. + + * `iri` : `str` +input IRI, in fully-qualified domain representation + + * `debug` : `bool` +debugging flag + + * *returns* : `str` +the compact IRI representation, using an RDF namespace prefix + + + +--- +#### [`perform_entity_linking` method](#textgraphs.KnowledgeGraph.perform_entity_linking) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L113) + +```python +perform_entity_linking(graph, pipe, debug=False) +``` +Perform _entity linking_ based on "spotlight" and other services. + + * `graph` : `textgraphs.graph.SimpleGraph` +source graph + + * `pipe` : `Pipeline` +configured pipeline for the current document + + * `debug` : `bool` +debugging flag + + + +--- +#### [`resolve_rel_iri` method](#textgraphs.KnowledgeGraph.resolve_rel_iri) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L135) + +```python +resolve_rel_iri(rel, lang="en", debug=False) +``` +Resolve a `rel` string from a _relation extraction_ model which has +been trained on this knowledge graph. + + * `rel` : `str` +relation label, generation these source from Wikidata for many RE projects + + * `lang` : `str` +language identifier + + * `debug` : `bool` +debugging flag + + * *returns* : `typing.Optional[str]` +a resolved IRI + + + +## [`KGSearchHit` class](#KGSearchHit) + +A data class representing a hit from a _knowledge graph_ search. + +--- +#### [`__repr__` method](#textgraphs.KGSearchHit.__repr__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/dataclasses.py#L232) + +```python +__repr__() +``` + +## [`KGWikiMedia` class](#KGWikiMedia) + +Manage access to WikiMedia-related APIs. + +--- +#### [`__init__` method](#textgraphs.KGWikiMedia.__init__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/kg.py#L165) + +```python +__init__(spotlight_api="https://api.dbpedia-spotlight.org/en", dbpedia_search_api="https://lookup.dbpedia.org/api/search", dbpedia_sparql_api="https://dbpedia.org/sparql", wikidata_api="https://www.wikidata.org/w/api.php", ner_map=OrderedDict([('CARDINAL', {'iri': 'http://dbpedia.org/resource/Cardinal_number', 'definition': 'Numerals that do not fall under another type', 'label': 'cardinal number'}), ('DATE', {'iri': 'http://dbpedia.org/ontology/date', 'definition': 'Absolute or relative dates or periods', 'label': 'date'}), ('EVENT', {'iri': 'http://dbpedia.org/ontology/Event', 'definition': 'Named hurricanes, battles, wars, sports events, etc.', 'label': 'event'}), ('FAC', {'iri': 'http://dbpedia.org/ontology/Infrastructure', 'definition': 'Buildings, airports, highways, bridges, etc.', 'label': 'infrastructure'}), ('GPE', {'iri': 'http://dbpedia.org/ontology/Country', 'definition': 'Countries, cities, states', 'label': 'country'}), ('LANGUAGE', {'iri': 'http://dbpedia.org/ontology/Language', 'definition': 'Any named language', 'label': 'language'}), ('LAW', {'iri': 'http://dbpedia.org/ontology/Law', 'definition': 'Named documents made into laws', 'label': 'law'}), ('LOC', {'iri': 'http://dbpedia.org/ontology/Place', 'definition': 'Non-GPE locations, mountain ranges, bodies of water', 'label': 'place'}), ('MONEY', {'iri': 'http://dbpedia.org/resource/Money', 'definition': 'Monetary values, including unit', 'label': 'money'}), ('NORP', {'iri': 'http://dbpedia.org/ontology/nationality', 'definition': 'Nationalities or religious or political groups', 'label': 'nationality'}), ('ORDINAL', {'iri': 'http://dbpedia.org/resource/Ordinal_number', 'definition': 'Ordinal number, i.e., first, second, etc.', 'label': 'ordinal number'}), ('ORG', {'iri': 'http://dbpedia.org/ontology/Organisation', 'definition': 'Companies, agencies, institutions, etc.', 'label': 'organization'}), ('PERCENT', {'iri': 'http://dbpedia.org/resource/Percentage', 'definition': 'Percentage', 'label': 'percentage'}), ('PERSON', {'iri': 'http://dbpedia.org/ontology/Person', 'definition': 'People, including fictional', 'label': 'person'}), ('PRODUCT', {'iri': 'http://dbpedia.org/ontology/product', 'definition': 'Vehicles, weapons, foods, etc. (Not services)', 'label': 'product'}), ('QUANTITY', {'iri': 'http://dbpedia.org/resource/Quantity', 'definition': 'Measurements, as of weight or distance', 'label': 'quantity'}), ('TIME', {'iri': 'http://dbpedia.org/ontology/time', 'definition': 'Times smaller than a day', 'label': 'time'}), ('WORK OF ART', {'iri': 'http://dbpedia.org/resource/Work_of_art', 'definition': 'Titles of books, songs, etc.', 'label': 'work of art'})]), ns_prefix=OrderedDict([('dbc', 'http://dbpedia.org/resource/Category:'), ('dbt', 'http://dbpedia.org/resource/Template:'), ('dbr', 'http://dbpedia.org/resource/'), ('yago', 'http://dbpedia.org/class/yago/'), ('dbd', 'http://dbpedia.org/datatype/'), ('dbo', 'http://dbpedia.org/ontology/'), ('dbp', 'http://dbpedia.org/property/'), ('units', 'http://dbpedia.org/units/'), ('dbpedia-commons', 'http://commons.dbpedia.org/resource/'), ('dbpedia-wikicompany', 'http://dbpedia.openlinksw.com/wikicompany/'), ('dbpedia-wikidata', 'http://wikidata.dbpedia.org/resource/'), ('wd', 'http://www.wikidata.org/'), ('wd_ent', 'http://www.wikidata.org/entity/'), ('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'), ('schema', 'https://schema.org/'), ('owl', 'http://www.w3.org/2002/07/owl#')]), min_alias=0.8, min_similarity=0.9) +``` +Constructor. + + * `spotlight_api` : `str` +`DBPedia Spotlight` API or equivalent local service + + * `dbpedia_search_api` : `str` +`DBPedia Search` API or equivalent local service + + * `dbpedia_sparql_api` : `str` +`DBPedia SPARQL` API or equivalent local service + + * `wikidata_api` : `str` +`Wikidata Search` API or equivalent local service + + * `ner_map` : `dict` +named entity map for standardizing IRIs + + * `ns_prefix` : `dict` +RDF namespace prefixes + + * `min_alias` : `float` +minimum alias probability threshold for accepting linked entities + + * `min_similarity` : `float` +minimum label similarity threshold for accepting linked entities + + + +--- +#### [`augment_pipe` method](#textgraphs.KGWikiMedia.augment_pipe) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/kg.py#L219) + +```python +augment_pipe(factory) +``` +Encapsulate a `spaCy` call to `add_pipe()` configuration. + + * `factory` : `textgraphs.pipe.PipelineFactory` +a `PipelineFactory` used to configure components + + + +--- +#### [`remap_ner` method](#textgraphs.KGWikiMedia.remap_ner) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/kg.py#L237) + +```python +remap_ner(label) +``` +Remap the OntoTypes4 values from NER output to more general-purpose IRIs. + + * `label` : `typing.Optional[str]` +input NER label, an `OntoTypes4` value + + * *returns* : `typing.Optional[str]` +an IRI for the named entity + + + +--- +#### [`normalize_prefix` method](#textgraphs.KGWikiMedia.normalize_prefix) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/kg.py#L266) + +```python +normalize_prefix(iri, debug=False) +``` +Normalize the given IRI using the standard DBPedia namespace prefixes. + + * `iri` : `str` +input IRI, in fully-qualified domain representation + + * `debug` : `bool` +debugging flag + + * *returns* : `str` +the compact IRI representation, using an RDF namespace prefix + + + +--- +#### [`perform_entity_linking` method](#textgraphs.KGWikiMedia.perform_entity_linking) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/kg.py#L306) + +```python +perform_entity_linking(graph, pipe, debug=False) +``` +Perform _entity linking_ based on `DBPedia Spotlight` and other services. + + * `graph` : `textgraphs.graph.SimpleGraph` +source graph + + * `pipe` : `textgraphs.pipe.Pipeline` +configured pipeline for the current document + + * `debug` : `bool` +debugging flag + + + +--- +#### [`resolve_rel_iri` method](#textgraphs.KGWikiMedia.resolve_rel_iri) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/kg.py#L370) + +```python +resolve_rel_iri(rel, lang="en", debug=False) +``` +Resolve a `rel` string from a _relation extraction_ model which has +been trained on this _knowledge graph_, which defaults to using the +`WikiMedia` graphs. + + * `rel` : `str` +relation label, generation these source from Wikidata for many RE projects + + * `lang` : `str` +language identifier + + * `debug` : `bool` +debugging flag + + * *returns* : `typing.Optional[str]` +a resolved IRI + + + +--- +#### [`wikidata_search` method](#textgraphs.KGWikiMedia.wikidata_search) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/kg.py#L575) + +```python +wikidata_search(query, lang="en", debug=False) +``` +Query the Wikidata search API. + + * `query` : `str` +query string + + * `lang` : `str` +language identifier + + * `debug` : `bool` +debugging flag + + * *returns* : `typing.Optional[textgraphs.elem.KGSearchHit]` +search hit, if any + + + +--- +#### [`dbpedia_search_entity` method](#textgraphs.KGWikiMedia.dbpedia_search_entity) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/kg.py#L641) + +```python +dbpedia_search_entity(query, lang="en", debug=False) +``` +Perform a DBPedia API search. + + * `query` : `str` +query string + + * `lang` : `str` +language identifier + + * `debug` : `bool` +debugging flag + + * *returns* : `typing.Optional[textgraphs.elem.KGSearchHit]` +search hit, if any + + + +--- +#### [`dbpedia_sparql_query` method](#textgraphs.KGWikiMedia.dbpedia_sparql_query) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/kg.py#L738) + +```python +dbpedia_sparql_query(sparql, debug=False) +``` +Perform a SPARQL query on DBPedia. + + * `sparql` : `str` +SPARQL query string + + * `debug` : `bool` +debugging flag + + * *returns* : `dict` +dictionary of query results + + + +--- +#### [`dbpedia_wikidata_equiv` method](#textgraphs.KGWikiMedia.dbpedia_wikidata_equiv) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/kg.py#L791) + +```python +dbpedia_wikidata_equiv(dbpedia_iri, debug=False) +``` +Perform a SPARQL query on DBPedia to find an equivalent Wikidata entity. + + * `dbpedia_iri` : `str` +IRI in DBpedia + + * `debug` : `bool` +debugging flag + + * *returns* : `typing.Optional[str]` +equivalent IRI in Wikidata + + + +## [`LinkedEntity` class](#LinkedEntity) + +A data class representing one linked entity. + +--- +#### [`__repr__` method](#textgraphs.LinkedEntity.__repr__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/dataclasses.py#L232) + +```python +__repr__() +``` + +## [`InferRel` class](#InferRel) + +Abstract base class for a _relation extraction_ model wrapper. + +--- +#### [`gen_triples_async` method](#textgraphs.InferRel.gen_triples_async) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L188) + +```python +gen_triples_async(pipe, queue, debug=False) +``` +Infer relations as triples produced to a queue _concurrently_. + + * `pipe` : `Pipeline` +configured pipeline for the current document + + * `queue` : `asyncio.queues.Queue` +queue of inference tasks to be performed + + * `debug` : `bool` +debugging flag + + + +--- +#### [`gen_triples` method](#textgraphs.InferRel.gen_triples) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/pipe.py#L166) + +```python +gen_triples(pipe, debug=False) +``` +Infer relations as triples through a generator _iteratively_. + + * `pipe` : `Pipeline` +configured pipeline for the current document + + * `debug` : `bool` +debugging flag + + * *yields* : +generated triples + + + +## [`InferRel_OpenNRE` class](#InferRel_OpenNRE) + +Perform relation extraction based on the `OpenNRE` model. + + +--- +#### [`__init__` method](#textgraphs.InferRel_OpenNRE.__init__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/rel.py#L33) + +```python +__init__(model="wiki80_cnn_softmax", max_skip=11, min_prob=0.9) +``` +Constructor. + + * `model` : `str` +the specific model to be used in `OpenNRE` + + * `max_skip` : `int` +maximum distance between entities for inferred relations + + * `min_prob` : `float` +minimum probability threshold for accepting an inferred relation + + + +--- +#### [`gen_triples` method](#textgraphs.InferRel_OpenNRE.gen_triples) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/rel.py#L58) + +```python +gen_triples(pipe, debug=False) +``` +Iterate on entity pairs to drive `OpenNRE`, inferring relations +represented as triples which get produced by a generator. + + * `pipe` : `textgraphs.pipe.Pipeline` +configured pipeline for the current document + + * `debug` : `bool` +debugging flag + + * *yields* : +generated triples as candidates for inferred relations + + + +## [`InferRel_Rebel` class](#InferRel_Rebel) + +Perform relation extraction based on the `REBEL` model. + + + +--- +#### [`__init__` method](#textgraphs.InferRel_Rebel.__init__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/rel.py#L121) + +```python +__init__(lang="en_XX", mrebel_model="Babelscape/mrebel-large") +``` +Constructor. + + * `lang` : `str` +language identifier + + * `mrebel_model` : `str` +tokenizer model to be used + + + +--- +#### [`tokenize_sent` method](#textgraphs.InferRel_Rebel.tokenize_sent) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/rel.py#L145) + +```python +tokenize_sent(text) +``` +Apply the tokenizer manually, since we need to extract special tokens. + + * `text` : `str` +input text for the sentence to be tokenized + + * *returns* : `str` +extracted tokens + + + +--- +#### [`extract_triplets_typed` method](#textgraphs.InferRel_Rebel.extract_triplets_typed) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/rel.py#L174) + +```python +extract_triplets_typed(text) +``` +Parse the generated text and extract its triplets. + + * `text` : `str` +input text for the sentence to use in inference + + * *returns* : `list` +a list of extracted triples + + + +--- +#### [`gen_triples` method](#textgraphs.InferRel_Rebel.gen_triples) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/rel.py#L259) + +```python +gen_triples(pipe, debug=False) +``` +Drive `REBEL` to infer relations for each sentence, represented as +triples which get produced by a generator. + + * `pipe` : `textgraphs.pipe.Pipeline` +configured pipeline for the current document + + * `debug` : `bool` +debugging flag + + * *yields* : +generated triples as candidates for inferred relations + + + +## [`RenderPyVis` class](#RenderPyVis) + +Render the _lemma graph_ as a `PyVis` network. + +--- +#### [`__init__` method](#textgraphs.RenderPyVis.__init__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/vis.py#L76) + +```python +__init__(graph, kg) +``` +Constructor. + + * `graph` : `textgraphs.graph.SimpleGraph` +source graph to be visualized + + * `kg` : `textgraphs.pipe.KnowledgeGraph` +knowledge graph used for entity linking + + + +--- +#### [`render_lemma_graph` method](#textgraphs.RenderPyVis.render_lemma_graph) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/vis.py#L94) + +```python +render_lemma_graph(debug=True) +``` +Prepare the structure of the `NetworkX` graph to use for building +and returning a `PyVis` network to render. + +Make sure to call beforehand: `TextGraphs.calc_phrase_ranks()` + + * `debug` : `bool` +debugging flag + + * *returns* : `pyvis.network.Network` +#L2) + +```python +__setattr__(name, value) +``` + +## [`GraphOfRelations` class](#GraphOfRelations) + +Attempt to reproduce results published in +"INGRAM: Inductive Knowledge Graph Embedding via Relation Graphs" + + +--- +#### [`__init__` method](#textgraphs.GraphOfRelations.__init__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/gor.py#L100) + +```python +__init__(source) +``` +Constructor. + + * `source` : `textgraphs.graph.SimpleGraph` +source graph to be transformed + + + +--- +#### [`load_ingram` method](#textgraphs.GraphOfRelations.load_ingram) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/gor.py#L125) + +```python +load_ingram(json_file, debug=False) +``` +Load data for a source graph, as illustrated in _lee2023ingram_ + + * `json_file` : `pathlib.Path` +path for the JSON dataset to load + + * `debug` : `bool` +debugging flag + + + +--- +#### [`seeds` method](#textgraphs.GraphOfRelations.seeds) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/gor.py#L197) + +```python +seeds(debug=False) +``` +Prep data for the topological transform illustrated in _lee2023ingram_ + + * `debug` : `bool` +debugging flag + + + +--- +#### [`trace_source_graph` method](#textgraphs.GraphOfRelations.trace_source_graph) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/gor.py#L241) + +```python +trace_source_graph() +``` +Output a "seed" representation of the source graph. + + + +--- +#### [`construct_gor` method](#textgraphs.GraphOfRelations.construct_gor) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/gor.py#L311) + +```python +construct_gor(debug=False) +``` +Perform the topological transform described by _lee2023ingram_, +constructing a _graph of relations_ (GOR) and calculating +_affinity scores_ between entities in the GOR based on their +definitions: + +> we measure the affinity between two relations by considering how many +entities are shared between them and how frequently they share the same +entity + + * `debug` : `bool` +debugging flag + + + +--- +#### [`tally_frequencies` classmethod](#textgraphs.GraphOfRelations.tally_frequencies) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/gor.py#L348) + +```python +tally_frequencies(counter) +``` +Tally the frequency of shared entities. + + * `counter` : `collections.Counter` +`counter` data collection for the rel_b/entity pairs + + * *returns* : `int` +tallied values for one relation + + + +--- +#### [`get_affinity_scores` method](#textgraphs.GraphOfRelations.get_affinity_scores) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/gor.py#L401) + +```python +get_affinity_scores(debug=False) +``` +Reproduce metrics based on the example published in _lee2023ingram_ + + * `debug` : `bool` +debugging flag + + * *returns* : `typing.Dict[tuple, float]` +the calculated affinity scores + + + +--- +#### [`trace_metrics` method](#textgraphs.GraphOfRelations.trace_metrics) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/gor.py#L454) + +```python +trace_metrics(scores) +``` +Compare the calculated affinity scores with results from a published +example. + + * `scores` : `typing.Dict[tuple, float]` +the calculated affinity scores between pairs of relations (i.e., observed values) + + * *returns* : `pandas.core.frame.DataFrame` +a `pandas.DataFrame` where the rows compare expected vs. observed affinity scores + + + +--- +#### [`render_gor_plt` method](#textgraphs.GraphOfRelations.render_gor_plt) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/gor.py#L522) + +```python +render_gor_plt(scores) +``` +Visualize the _graph of relations_ using `matplotlib` + + * `scores` : `typing.Dict[tuple, float]` +the calculated affinity scores between pairs of relations (i.e., observed values) + + + +--- +#### [`render_gor_pyvis` method](#textgraphs.GraphOfRelations.render_gor_pyvis) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/gor.py#L563) + +```python +render_gor_pyvis(scores) +``` +Visualize the _graph of relations_ interactively using `PyVis` + + * `scores` : `typing.Dict[tuple, float]` +the calculated affinity scores between pairs of relations (i.e., observed values) + + * *returns* : `pyvis.network.Network` +a `pyvis.networkNetwork` representation of the transformed graph + + + +## [`TransArc` class](#TransArc) + +A data class representing one transformed rel-node-rel triple in +a _graph of relations_. + +--- +#### [`__repr__` method](#textgraphs.TransArc.__repr__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/dataclasses.py#L232) + +```python +__repr__() +``` + +## [`RelDir` class](#RelDir) + +Enumeration for the directions of a relation. + +## [`SheafSeed` class](#SheafSeed) + +A data class representing a node from the source graph plus its +partial edge, based on a _Sheaf Theory_ decomposition of a graph. + +--- +#### [`__repr__` method](#textgraphs.SheafSeed.__repr__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/dataclasses.py#L232) + +```python +__repr__() +``` + +## [`Affinity` class](#Affinity) + +A data class representing the affinity scores from one entity +in the transformed _graph of relations_. + +NB: there are much more efficient ways to calculate these +_affinity scores_ using sparse tensor algebra; this approach +illustrates the process -- for research and debugging. + +--- +#### [`__repr__` method](#textgraphs.Affinity.__repr__) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/dataclasses.py#L232) + +```python +__repr__() +``` + +--- +## [module functions](#textgraphs) +--- +#### [`calc_quantile_bins` function](#textgraphs.calc_quantile_bins) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/util.py#L65) + +```python +calc_quantile_bins(num_rows) +``` +Calculate the bins to use for a quantile stripe, +using [`numpy.linspace`](https://numpy.org/doc/stable/reference/generated/numpy.linspace.html) + + * `num_rows` : `int` +number of rows in the target dataframe + + * *returns* : `numpy.ndarray` +calculated bins, as a `numpy.ndarray` + + + +--- +#### [`get_repo_version` function](#textgraphs.get_repo_version) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/version.py#L50) + +```python +get_repo_version() +``` +Access the Git repository information and return items to identify +the version/commit running in production. + + * *returns* : `typing.Tuple[str, str]` +version tag and commit hash + + + +--- +#### [`root_mean_square` function](#textgraphs.root_mean_square) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/util.py#L116) + +```python +root_mean_square(values) +``` +Calculate the [*root mean square*](https://mathworld.wolfram.com/Root-Mean-Square.html) +of the values in the given list. + + * `values` : `typing.List[float]` +list of values to use in the RMS calculation + + * *returns* : `float` +RMS metric as a float + + + +--- +#### [`stripe_column` function](#textgraphs.stripe_column) +[*\[source\]*](https://github.com/DerwenAI/textgraphs/blob/main/textgraphs/util.py#L88) + +```python +stripe_column(values, bins) +``` +Stripe a column in a dataframe, by interpolating quantiles into a set of discrete indexes. + + * `values` : `list` +list of values to stripe + + * `bins` : `int` +quantile bins; see [`calc_quantile_bins()`](#calc_quantile_bins-function) + + * *returns* : `numpy.ndarray` +the striped column values, as a `numpy.ndarray` + + + +--- +## [module types](#textgraphs) diff --git a/docs/related.md b/docs/related.md new file mode 100644 index 0000000000000000000000000000000000000000..540884566f00f0b8da95099054a98dcaaeca7523 --- /dev/null +++ b/docs/related.md @@ -0,0 +1,82 @@ +Other projects have investigated related lines of inquiry, which help frame the problems encountered. + +[#loganlpgs19](biblio.md#loganlpgs19), + + - primary goal is to generate entities and facts from a KG + - emphasis on handling rare facts from a broad domain of topics and on improving perplexity + - "we are interested in LMs that dynamically decide the facts to incorporate from the KG, guided by the discourse" + - con: uses relatively simple `G = V,E` graph-theoretic notions of graph data, which is ostensibly RDF + - "traditional LMs are only capable of remembering facts seen at training time, and often have difficulty recalling them" + - introducing KGLM: enables the model to render information it has never seen before, as well as generate out-of-vocabulary tokens + - generates conditional probability of mapping an entity to a parsed token, based on previous tokens and entities within the same stream + - maintains a dynamically growing local KG, a subset of the KG that contains entities that have already been mentioned in the text, and their related entities + - "one of the primary barriers to incorporating factual knowledge into LMs is that training data is hard to obtain" + - provides the `Linked WikiText-2` dataset for running benchmarks, available on GitHub + - "For most LMs, it is difficult to control their generation since factual knowledge is entangled with generation capabilities of the model" + +> Standard language modeling corpora consist only of text, and thus are unable to describe which entities or facts each token is referring to. In contrast, while relation extraction datasets link text to a knowledge graph, the text is made up of disjoint sentences that do not provide sufficient context to train a powerful language model. + + +[#warmerdam2023pydata](biblio.md#warmerdam2023pydata), 20:35-ff + + - using `spaCy` to parse and annotate tokens with metadata + - parse trees => graph => heuristics to map from phrases to concepts + - `sense2vec` to find neighborhoods for surface forms (acronyms, synonyms, etc.) + - UMAP, etc. => hinting toward: "descriptive but not computable" + - UX: active learning vs. annotations of wrong examples using `prodigy` + - "spend more effort per example" => coining term _active teaching_ + - rethinking beyond the "optimality trap" + - "maybe familiarity is a liability in data analytics?" => doubt can be an advantage + + +[#wen2023mindmap](biblio.md#wen2023mindmap), + + - how to prompt LLMs with KGs + - "build a prompting pipeline that endows LLMs with the capability of comprehending KG inputs and inferring with a combined implicit knowledge and the retrieved external knowledge" + - in contrast, the _prompt engineering_ paradigm: "pre-train, prompt, and predict" + - "goal of this work is to build a plug-and-play prompting approach to elicit the graph-of-thoughts reasoning capability in LLMs" + 1.consolidates the retrieved facts from KGs and the implicit knowledge from LLMs + 2. discovers new patterns in input KGs + 3. reasons over the mind map to yield final outputs + - build multiple _evidence sub-graphs_ which get aggregated into _reasoning graphs_, then prompt LLMs and build a _mind map_ to explain the reasoning process + - conjecture that LLMs can comprehend and extract knowledge from a reasoning graph that is described by natural language + - prompting a GPT-3.5 with `MindMap` yields an overwhelming performance over GPT-4 consistently + + +[#tripathi2024deepnlp](biblio.md#tripathi2024deepnlp), + +["Deep NLP on SF Literature"](https://github.com/kkrishna24/deep_nlp_on_sf_literature) +**Krishna Tripathi** _GitHub_ (2024-01-25) + + - processes texts using customized methods, NLTK, and spaCy + - performs domain-specific named entity recognition in multiple stages + - fine-tunes a RoBERTa model using GPT to generate annotated data + - implements multicore LDA for efficient topic modeling and theme-extraction + - modularized code makes this work highly reusable for other domain-specific literature tasks: code can be easily refitted for legal datasets, a corpus of classics etc. + - goes the additional step of using these results to **rework training data** and train models + + +[#nayak2023tds](biblio.md#nayak2023tds) + +["How to Convert Any Text Into a Graph of Concepts"](https://towardsdatascience.com/how-to-convert-any-text-into-a-graph-of-concepts-110844f22a1a) +**Rahul Nayak**, _Towards Data Science_ (2023-11-09) + + - "a method to convert any text corpus into a _graph of concepts_" (aka KG) + - use KGs to implement RAG and "chat with our documents" + - Q: is this work solid enough to cite in an academic paper?? + + + +## counterexamples + +[#nizami2023llm](biblio.md#nizami2023llm) + +["Extracting Relation from Sentence using LLM"](https://medium.com/@nizami_muhammad/extracting-relation-from-sentence-using-llm-597d0c0310a8) +**Muhammad Nizami** _Medium_ (2023-11-15) + + +[#lawrence2024ttg](biblio.md#lawrence2024ttg) + +["Text-to-Graph via LLM: pre-training, prompting, or tuning?"](https://medium.com/@peter.lawrence_47665/text-to-graph-via-llm-pre-training-prompting-or-tuning-3233d1165360) +**Peter Lawrence** _Medium_ (2024-01-16) + diff --git a/docs/rubric.md b/docs/rubric.md new file mode 100644 index 0000000000000000000000000000000000000000..9157d3a2c4aafb8559316ade6f43d4efea684334 --- /dev/null +++ b/docs/rubric.md @@ -0,0 +1,55 @@ +# Appendix: ML OSS Evaluation Rubric + +The following checklist provides an evaluation rubric for open source code related to machine learning research. +For any given code repository, tally a score based on these questions: + + - **Q1:** Does the repository use a business-friendly license? + - **Q2:** Does the code install correctly with either `pip` or `conda` package managers? + - **Q3:** Are the library dependencies reasonably current, not using pinned versions for popular libraries? + - **Q4:** Has the project provided sample code which runs without exceptions? + - **Q5:** Can the sample code reproduce the published results of the research? + - **Q6:** Does the library provide affordances for data integration, i.e., it's not optimized for a particular benchmark? + - **Q7:** Can the code be called programmatically as a library, i.e., not run primarily through a command line interface (CLI), and not requiring container/microservice orchestration? + - **Q8:** Will the library and its dependencies pass a reasonable level of security audit without structural changes? + - **Q9:** Does the code support concurrency and parallelization? + - **Q10:** Has the repo been maintained within the past six months? + + +## Dependency Evaluations + +Based on this checklist, the dependencies integrated within this project scores as follows: + +rubric | `OpenNRE` | `pulp` | `qwikidata` | `REBEL` | `spaCy` | `Spotlight` | `SpanMarker` | `transformers` +--- | --- | --- | --- | --- | --- | --- | --- +Q1 | x | x | x | x | x | x | x | x +Q2 | x | x | x | x | x | x | x | x +Q3 | x | x | x | x | x | x | x | x +Q4 | x | x | x | x | x | x | x | x +Q5 | x | x | x | x | x | x | x | x +Q6 | x | x | x | x | x | x | x | x +Q7 | x | x | x | x | x | x | x | x +Q8 | x | x | x | x | x | x | x | x +Q9 | x | x | x | x | x | x | x | x +Q10 | x | x | x | x | x | x | x | x + + +[`OpenNRE`](https://github.com/thunlp/OpenNRE/) + +[`pulp`](https://github.com/coin-or/pulp) + +[`qwikidata`](https://github.com/kensho-technologies/qwikidata) + +[`REBEL`](https://github.com/Babelscape/rebel) + +[`spaCy`](https://spacy.io/) + +[`spaCy-DBpedia-Spotlight`](https://github.com/MartinoMensio/spacy-dbpedia-spotlight) + +[`SpanMarker`](https://github.com/tomaarsen/SpanMarkerNER/) + +[`transformers`](https://github.com/huggingface/transformers/) + + +There were many other open source code projects which were evaluated +but scored < 8 and were therefore considered unusable for our work. + diff --git a/docs/start.md b/docs/start.md new file mode 100644 index 0000000000000000000000000000000000000000..86235d1f893a7b9948c6a794267272084a136578 --- /dev/null +++ b/docs/start.md @@ -0,0 +1,23 @@ +# Getting Started + +Video Tutorial by artworkbean from the Noun Project + +## Installation + +Install from [PyPi](https://pypi.python.org/pypi/textgraphs): + +```bash +python3 -m pip install -U textgraphs +``` + +## Sample Usage + +Run the demos locally: + +```bash +python3 demo.py +``` + +```bash +streamlit run app.py +``` diff --git a/docs/strategy.md b/docs/strategy.md new file mode 100644 index 0000000000000000000000000000000000000000..9b04d667db1188d7a702827c9b549c38948621e9 --- /dev/null +++ b/docs/strategy.md @@ -0,0 +1,23 @@ +Consider the recent use of _direct preference optimization_ (DPO) with open source tools such as `Argilla` and `Distilabel` to identify and fix data quality issues in the `Zephyr-7B-beta` dataset. This resulted in the `Notus-7B-v1` model, which was created by a relatively small R&D team -- "GPU-poor" -- and then gained high ranking on the Hugging Face leaderboards. + + - + - + +Andrew Ng: + + +> While it's always nice to have massive numbers of NVIDIA H100 or AMD MI300X GPUs, this work is another illustration — out of many, I want to emphasize — that deep thinking with only modest computational resources can carry you far. + +"Direct Preference Optimization: Your Language Model is Secretly a Reward Model" +Rafael Rafailov, et al. + + +RE projects in particular tend to use Wikidata _labels_ (not IRIs) to train models; these are descriptive but not computable + +Components such as NER and RE could be enhanced by reworking the data quality for training data, benchmarks, evals, etc. + + - `SpanMarker` provides a framework for iteration on NER, to fine-tune for specific KGs + + - `OpenNRE` provides a framework for iteration on RE, to fine-tune for specific KGs + +Data-first iterations on these components can take advantage of DPO, sparse fine-tuning, pruning, quantization, and so on, while the _lemma graph_ plus its topological transforms provide enhanced tokenization and better context for training. diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000000000000000000000000000000000000..7053c5718a5918fa28499225a15c04a47fcb9b8f --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,13 @@ +.md-typeset a { + color: hsl(66deg 100% 31%); +} + +.md-typeset a:focus, .md-typeset a:hover { + color: hsl(306, 45%, 57%); +} + +:root { + --md-primary-fg-color: hsl(65, 46%, 58%); + --md-primary-fg-color--light: #000; + --md-primary-fg-color--dark: #FFF; +} \ No newline at end of file diff --git a/docs/topo.md b/docs/topo.md new file mode 100644 index 0000000000000000000000000000000000000000..487f0ad3000c08feb8c955ddc1b9df19519f86b1 --- /dev/null +++ b/docs/topo.md @@ -0,0 +1,7 @@ +**TODO**: summarize from + +Graph topological transform approaches so far (e.g., `lee2023ingram`) have focused on using relation affinities to train _representation learning_ models. this may be another example of using deep learning as a mêlée weapon. instead, + +results computed from _graph of relations_ analysis naturally feed into _statistical relational learning_ approaches such as _probabilistic soft logic_, to develop rule sets and ground truth for training SRE models. + +TODO: survey/compare topological decomposition of graphs, then using statistics to determine how to reconstruct probabilistically => for recomposition of generate graph elements (not simple nodes, edges) diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 0000000000000000000000000000000000000000..ea110f5851791ad611e71cb2816b7a6b3bcef42a --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,25 @@ +# Tutorial Syllabus + +Video Tutorial by artworkbean from the Noun Project + +Coding samples in the following notebooks help illustrate the use +of **TextGraphs** and related libraries in Python. + + +## Audience + + * You are a Python programmer who needs to learn how to leverage LLM-augmented workflows to construct KGs + * You are an ML engineer who needs to understand how to integrate LLM research results into production-quality apps + +## Prerequisites + + * Some coding experience in Python (you can read a 20-line program) + * Some familiarity with ML, specifically with LLM applications + * Interest in use cases that need to use NLP to construct KGs + + +## Key Takeaways + + * Hands-on experience with popular open source libraries in Python for natural language at the intersection of LLMs and KGs + * Coding examples that can be used as starting points for your own related projects + * Ways to integrate natural language work with other aspects of graph data science diff --git a/examples/ex0_0.ipynb b/examples/ex0_0.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..699ee0ae4ea895317e89b3c2e501136d0b188af8 --- /dev/null +++ b/examples/ex0_0.ipynb @@ -0,0 +1,1689 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "c32bf0b9-1445-4ede-ae49-7dd63ff3b08e", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:41:43.180489Z", + "iopub.status.busy": "2024-01-17T01:41:43.179719Z", + "iopub.status.idle": "2024-01-17T01:41:43.199483Z", + "shell.execute_reply": "2024-01-17T01:41:43.194882Z", + "shell.execute_reply.started": "2024-01-17T01:41:43.180434Z" + } + }, + "outputs": [], + "source": [ + "# for use in tutorial and development; do not include this `sys.path` change in production:\n", + "import sys ; sys.path.insert(0, \"../\")" + ] + }, + { + "cell_type": "markdown", + "id": "c8ff5d81-110c-42ae-8aa7-ed4fffea40c6", + "metadata": {}, + "source": [ + "# demo: TextGraphs + LLMs to construct a 'lemma graph'" + ] + }, + { + "cell_type": "markdown", + "id": "1e847d0a-bc6c-470a-9fef-620ebbdbbbc3", + "metadata": {}, + "source": [ + "_TextGraphs_ library is intended for iterating through a sequence of paragraphs." + ] + }, + { + "cell_type": "markdown", + "id": "61d8d39a-23e4-48e7-b8f4-0dd724ccf586", + "metadata": {}, + "source": [ + "## environment" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "22489527-2ad5-4e3c-be23-f511e6bcf69f", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:41:43.205321Z", + "iopub.status.busy": "2024-01-17T01:41:43.204828Z", + "iopub.status.idle": "2024-01-17T01:41:51.202960Z", + "shell.execute_reply": "2024-01-17T01:41:51.201428Z", + "shell.execute_reply.started": "2024-01-17T01:41:43.205291Z" + }, + "scrolled": true + }, + "outputs": [], + "source": [ + "from IPython.display import display, HTML, Image, SVG\n", + "import pathlib\n", + "import typing\n", + "\n", + "from icecream import ic\n", + "from pyinstrument import Profiler\n", + "import matplotlib.pyplot as plt\n", + "import pandas as pd\n", + "import pyvis\n", + "import spacy\n", + "\n", + "import textgraphs" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "438f5775-487b-493e-a172-59b652b94955", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:41:51.205309Z", + "iopub.status.busy": "2024-01-17T01:41:51.204860Z", + "iopub.status.idle": "2024-01-17T01:41:51.226390Z", + "shell.execute_reply": "2024-01-17T01:41:51.225503Z", + "shell.execute_reply.started": "2024-01-17T01:41:51.205274Z" + } + }, + "outputs": [], + "source": [ + "%load_ext watermark" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "adc052dd-5cca-4d11-b543-3f0999f4f883", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:41:51.228636Z", + "iopub.status.busy": "2024-01-17T01:41:51.228357Z", + "iopub.status.idle": "2024-01-17T01:41:51.282369Z", + "shell.execute_reply": "2024-01-17T01:41:51.281284Z", + "shell.execute_reply.started": "2024-01-17T01:41:51.228610Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Last updated: 2024-01-16T17:41:51.229985-08:00\n", + "\n", + "Python implementation: CPython\n", + "Python version : 3.10.11\n", + "IPython version : 8.20.0\n", + "\n", + "Compiler : Clang 13.0.0 (clang-1300.0.29.30)\n", + "OS : Darwin\n", + "Release : 21.6.0\n", + "Machine : x86_64\n", + "Processor : i386\n", + "CPU cores : 8\n", + "Architecture: 64bit\n", + "\n" + ] + } + ], + "source": [ + "%watermark" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6e4618da-daf9-44c9-adbb-e5781dba5504", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:41:51.291126Z", + "iopub.status.busy": "2024-01-17T01:41:51.287449Z", + "iopub.status.idle": "2024-01-17T01:41:51.322186Z", + "shell.execute_reply": "2024-01-17T01:41:51.320908Z", + "shell.execute_reply.started": "2024-01-17T01:41:51.291072Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sys : 3.10.11 (v3.10.11:7d4cc5aa85, Apr 4 2023, 19:05:19) [Clang 13.0.0 (clang-1300.0.29.30)]\n", + "spacy : 3.7.2\n", + "pandas : 2.1.4\n", + "matplotlib: 3.8.2\n", + "textgraphs: 0.5.0\n", + "pyvis : 0.3.2\n", + "\n" + ] + } + ], + "source": [ + "%watermark --iversions" + ] + }, + { + "cell_type": "markdown", + "id": "1a04e3dc-57d8-43a4-a342-cc38b86fc6a6", + "metadata": {}, + "source": [ + "## parse a document" + ] + }, + { + "cell_type": "markdown", + "id": "7c567afd-2f44-4391-899a-da6aba3d222e", + "metadata": {}, + "source": [ + "provide the source text" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "630430c5-21dc-4897-9a4b-3b01baf3de17", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:41:51.326474Z", + "iopub.status.busy": "2024-01-17T01:41:51.325657Z", + "iopub.status.idle": "2024-01-17T01:41:51.334443Z", + "shell.execute_reply": "2024-01-17T01:41:51.332925Z", + "shell.execute_reply.started": "2024-01-17T01:41:51.326405Z" + } + }, + "outputs": [], + "source": [ + "SRC_TEXT: str = \"\"\" \n", + "Werner Herzog is a remarkable filmmaker and an intellectual originally from Germany, the son of Dietrich Herzog.\n", + "After the war, Werner fled to America to become famous.\n", + "\"\"\"" + ] + }, + { + "cell_type": "markdown", + "id": "01152885-f301-49b1-ab61-f5b19d81c036", + "metadata": {}, + "source": [ + "set up the statistical stack profiling" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "2a289117-301d-4027-ae1b-200201fb5f93", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:41:51.346396Z", + "iopub.status.busy": "2024-01-17T01:41:51.346074Z", + "iopub.status.idle": "2024-01-17T01:41:51.352763Z", + "shell.execute_reply": "2024-01-17T01:41:51.350319Z", + "shell.execute_reply.started": "2024-01-17T01:41:51.346368Z" + } + }, + "outputs": [], + "source": [ + "profiler: Profiler = Profiler()\n", + "profiler.start()" + ] + }, + { + "cell_type": "markdown", + "id": "bf9d4f99-b82b-4d11-a9a4-31d0337f4aa8", + "metadata": {}, + "source": [ + "set up the `TextGraphs` pipeline" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "da6fcb0f-b2ac-4f74-af39-2c129c750cab", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:41:51.357183Z", + "iopub.status.busy": "2024-01-17T01:41:51.354882Z", + "iopub.status.idle": "2024-01-17T01:42:10.886781Z", + "shell.execute_reply": "2024-01-17T01:42:10.884253Z", + "shell.execute_reply.started": "2024-01-17T01:41:51.357081Z" + } + }, + "outputs": [], + "source": [ + "tg: textgraphs.TextGraphs = textgraphs.TextGraphs(\n", + " factory = textgraphs.PipelineFactory(\n", + " spacy_model = textgraphs.SPACY_MODEL,\n", + " ner = None,\n", + " kg = textgraphs.KGWikiMedia(\n", + " spotlight_api = textgraphs.DBPEDIA_SPOTLIGHT_API,\n", + " dbpedia_search_api = textgraphs.DBPEDIA_SEARCH_API,\n", + " dbpedia_sparql_api = textgraphs.DBPEDIA_SPARQL_API,\n", + " \t\twikidata_api = textgraphs.WIKIDATA_API,\n", + " min_alias = textgraphs.DBPEDIA_MIN_ALIAS,\n", + " min_similarity = textgraphs.DBPEDIA_MIN_SIM,\n", + " ),\n", + " infer_rels = [\n", + " \t\ttextgraphs.InferRel_OpenNRE(\n", + " model = textgraphs.OPENNRE_MODEL,\n", + " max_skip = textgraphs.MAX_SKIP,\n", + " min_prob = textgraphs.OPENNRE_MIN_PROB,\n", + " \t\t),\n", + " textgraphs.InferRel_Rebel(\n", + " lang = \"en_XX\",\n", + " mrebel_model = textgraphs.MREBEL_MODEL,\n", + " ),\n", + " ],\n", + " ),\n", + ")\n", + "\n", + "pipe: textgraphs.Pipeline = tg.create_pipeline(\n", + " SRC_TEXT.strip(),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "8b71b841-0cf5-4cc6-af4c-c85344b8f6c5", + "metadata": {}, + "source": [ + "## visualize the parse results" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "5901a49e-3f90-4061-9c3a-e9d1f05b40f3", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:10.892508Z", + "iopub.status.busy": "2024-01-17T01:42:10.891377Z", + "iopub.status.idle": "2024-01-17T01:42:10.925630Z", + "shell.execute_reply": "2024-01-17T01:42:10.921355Z", + "shell.execute_reply.started": "2024-01-17T01:42:10.892351Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + " Werner Herzog\n", + " PERSON\n", + "\n", + " is a remarkable filmmaker and an intellectual originally from \n", + "\n", + " Germany\n", + " GPE\n", + "\n", + ", the son of \n", + "\n", + " Dietrich Herzog\n", + " PERSON\n", + "\n", + ".
After the war, \n", + "\n", + " Werner\n", + " PERSON\n", + "\n", + " fled to \n", + "\n", + " America\n", + " GPE\n", + "\n", + " to become famous.
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "spacy.displacy.render(\n", + " pipe.ner_doc,\n", + " style = \"ent\",\n", + " jupyter = True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "ffc0863d-5ed4-4857-aee1-96f26472f1ef", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:10.929432Z", + "iopub.status.busy": "2024-01-17T01:42:10.928841Z", + "iopub.status.idle": "2024-01-17T01:42:10.974738Z", + "shell.execute_reply": "2024-01-17T01:42:10.973574Z", + "shell.execute_reply.started": "2024-01-17T01:42:10.929374Z" + } + }, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + " Werner Herzog\n", + " PROPN\n", + "\n", + "\n", + "\n", + " is\n", + " AUX\n", + "\n", + "\n", + "\n", + " a\n", + " DET\n", + "\n", + "\n", + "\n", + " remarkable\n", + " ADJ\n", + "\n", + "\n", + "\n", + " filmmaker\n", + " NOUN\n", + "\n", + "\n", + "\n", + " and\n", + " CCONJ\n", + "\n", + "\n", + "\n", + " an\n", + " DET\n", + "\n", + "\n", + "\n", + " intellectual\n", + " NOUN\n", + "\n", + "\n", + "\n", + " originally\n", + " ADV\n", + "\n", + "\n", + "\n", + " from\n", + " ADP\n", + "\n", + "\n", + "\n", + " Germany,\n", + " PROPN\n", + "\n", + "\n", + "\n", + " the\n", + " DET\n", + "\n", + "\n", + "\n", + " son\n", + " NOUN\n", + "\n", + "\n", + "\n", + " of\n", + " ADP\n", + "\n", + "\n", + "\n", + " Dietrich Herzog.\n", + " PUNCT\n", + "\n", + "\n", + "\n", + " \n", + "\n", + " SPACE\n", + "\n", + "\n", + "\n", + " After\n", + " ADP\n", + "\n", + "\n", + "\n", + " the\n", + " DET\n", + "\n", + "\n", + "\n", + " war,\n", + " NOUN\n", + "\n", + "\n", + "\n", + " Werner\n", + " PROPN\n", + "\n", + "\n", + "\n", + " fled\n", + " VERB\n", + "\n", + "\n", + "\n", + " to\n", + " ADP\n", + "\n", + "\n", + "\n", + " America\n", + " PROPN\n", + "\n", + "\n", + "\n", + " to\n", + " PART\n", + "\n", + "\n", + "\n", + " become\n", + " VERB\n", + "\n", + "\n", + "\n", + " famous.\n", + " ADJ\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " nsubj\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " det\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " amod\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " attr\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " cc\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " det\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " conj\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " advmod\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " prep\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " pobj\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " det\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " appos\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " prep\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " punct\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " dep\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " prep\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " det\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " pobj\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " nsubj\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " prep\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " pobj\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " aux\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " advcl\n", + " \n", + " \n", + "\n", + "\n", + "\n", + " \n", + " \n", + " acomp\n", + " \n", + " \n", + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "parse_svg: str = spacy.displacy.render(\n", + " pipe.ner_doc,\n", + " style = \"dep\",\n", + " jupyter = False,\n", + ")\n", + "\n", + "display(SVG(parse_svg))" + ] + }, + { + "cell_type": "markdown", + "id": "5e9de8e0-5a79-45f9-8c9d-6c68c560040e", + "metadata": {}, + "source": [ + "## collect graph elements from the parse" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "4d5abe40-d483-44f5-a747-92e0ac9c8b0d", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:10.978005Z", + "iopub.status.busy": "2024-01-17T01:42:10.977288Z", + "iopub.status.idle": "2024-01-17T01:42:10.985871Z", + "shell.execute_reply": "2024-01-17T01:42:10.984706Z", + "shell.execute_reply.started": "2024-01-17T01:42:10.977922Z" + } + }, + "outputs": [], + "source": [ + "tg.collect_graph_elements(\n", + " pipe,\n", + " debug = False,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "7c440db4-fc01-44ff-8d8d-03517cc1f1e4", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:10.989542Z", + "iopub.status.busy": "2024-01-17T01:42:10.988271Z", + "iopub.status.idle": "2024-01-17T01:42:11.551822Z", + "shell.execute_reply": "2024-01-17T01:42:11.551011Z", + "shell.execute_reply.started": "2024-01-17T01:42:10.989493Z" + }, + "scrolled": true + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "ic| len(tg.nodes.values()): 36\n", + "ic| len(tg.edges.values()): 42\n" + ] + } + ], + "source": [ + "ic(len(tg.nodes.values()));\n", + "ic(len(tg.edges.values()));" + ] + }, + { + "cell_type": "markdown", + "id": "76caa0e6-351a-48e8-9e1f-94a31d612ee4", + "metadata": {}, + "source": [ + "## perform entity linking" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "6d23e215-9d8c-4e03-8040-fa9398fad62b", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:11.553477Z", + "iopub.status.busy": "2024-01-17T01:42:11.553267Z", + "iopub.status.idle": "2024-01-17T01:42:32.304619Z", + "shell.execute_reply": "2024-01-17T01:42:32.302739Z", + "shell.execute_reply.started": "2024-01-17T01:42:11.553444Z" + } + }, + "outputs": [], + "source": [ + "tg.perform_entity_linking(\n", + " pipe,\n", + " debug = False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "f7e31cf4-0f49-4fef-affa-04c9833a6236", + "metadata": {}, + "source": [ + "## infer relations" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "32bb75af-e806-4334-a876-127f2704ffbf", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:32.311135Z", + "iopub.status.busy": "2024-01-17T01:42:32.310408Z", + "iopub.status.idle": "2024-01-17T01:42:46.741855Z", + "shell.execute_reply": "2024-01-17T01:42:46.740354Z", + "shell.execute_reply.started": "2024-01-17T01:42:32.311083Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "[Edge(src_node=0, dst_node=10, kind=, rel='https://schema.org/nationality', prob=1.0, count=1),\n", + " Edge(src_node=15, dst_node=0, kind=, rel='https://schema.org/children', prob=1.0, count=1),\n", + " Edge(src_node=27, dst_node=22, kind=, rel='https://schema.org/event', prob=1.0, count=1)]" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "inferred_edges: list = await tg.infer_relations_async(\n", + " pipe,\n", + " debug = False,\n", + ")\n", + "\n", + "inferred_edges" + ] + }, + { + "cell_type": "markdown", + "id": "76fa3fcb-6432-4ed5-80d1-569be4253e6e", + "metadata": {}, + "source": [ + "## construct a lemma graph" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "79efb0d1-dfc4-4f45-8c4e-b42a080832e7", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:46.744612Z", + "iopub.status.busy": "2024-01-17T01:42:46.744082Z", + "iopub.status.idle": "2024-01-17T01:42:46.752790Z", + "shell.execute_reply": "2024-01-17T01:42:46.751990Z", + "shell.execute_reply.started": "2024-01-17T01:42:46.744560Z" + } + }, + "outputs": [], + "source": [ + "tg.construct_lemma_graph(\n", + " debug = False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "84a4b0c6-ebd5-4794-ac2d-ee191ab7ed0b", + "metadata": {}, + "source": [ + "## extract ranked entities" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "70134eb6-c1b4-474e-81cd-12b6b7f38afd", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:46.756709Z", + "iopub.status.busy": "2024-01-17T01:42:46.754800Z", + "iopub.status.idle": "2024-01-17T01:42:47.059654Z", + "shell.execute_reply": "2024-01-17T01:42:47.058466Z", + "shell.execute_reply.started": "2024-01-17T01:42:46.756630Z" + }, + "scrolled": true + }, + "outputs": [], + "source": [ + "tg.calc_phrase_ranks(\n", + " pr_alpha = textgraphs.PAGERANK_ALPHA,\n", + " debug = False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "1ba5b734-665a-4bc0-9eca-11b2ba074fed", + "metadata": {}, + "source": [ + "show the resulting entities extracted from the document" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "a77a0ede-2225-47c1-8ea8-4ae2220aa086", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:47.062142Z", + "iopub.status.busy": "2024-01-17T01:42:47.061624Z", + "iopub.status.idle": "2024-01-17T01:42:47.098472Z", + "shell.execute_reply": "2024-01-17T01:42:47.097234Z", + "shell.execute_reply.started": "2024-01-17T01:42:47.062101Z" + }, + "scrolled": true + }, + "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", + "
node_idtextposlabelcountweight
00Werner HerzogPROPNdbr:Werner_Herzog10.080547
110GermanyPROPNdbr:Germany10.080437
215Dietrich HerzogPROPNdbo:Person10.079048
327AmericaPROPNdbr:United_States10.079048
424WernerPROPNdbo:Person10.077633
54filmmakerNOUNowl:Thing10.076309
622warNOUNowl:Thing10.076309
732a remarkable filmmakernoun_chunkNone10.076077
87intellectualNOUNowl:Thing10.074725
913sonNOUNowl:Thing10.074725
1033an intellectualnoun_chunkNone10.074606
1134the sonnoun_chunkNone10.074606
1235the warnoun_chunkNone10.074606
\n", + "
" + ], + "text/plain": [ + " node_id text pos label count \\\n", + "0 0 Werner Herzog PROPN dbr:Werner_Herzog 1 \n", + "1 10 Germany PROPN dbr:Germany 1 \n", + "2 15 Dietrich Herzog PROPN dbo:Person 1 \n", + "3 27 America PROPN dbr:United_States 1 \n", + "4 24 Werner PROPN dbo:Person 1 \n", + "5 4 filmmaker NOUN owl:Thing 1 \n", + "6 22 war NOUN owl:Thing 1 \n", + "7 32 a remarkable filmmaker noun_chunk None 1 \n", + "8 7 intellectual NOUN owl:Thing 1 \n", + "9 13 son NOUN owl:Thing 1 \n", + "10 33 an intellectual noun_chunk None 1 \n", + "11 34 the son noun_chunk None 1 \n", + "12 35 the war noun_chunk None 1 \n", + "\n", + " weight \n", + "0 0.080547 \n", + "1 0.080437 \n", + "2 0.079048 \n", + "3 0.079048 \n", + "4 0.077633 \n", + "5 0.076309 \n", + "6 0.076309 \n", + "7 0.076077 \n", + "8 0.074725 \n", + "9 0.074725 \n", + "10 0.074606 \n", + "11 0.074606 \n", + "12 0.074606 " + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df: pd.DataFrame = tg.get_phrases_as_df()\n", + "df" + ] + }, + { + "cell_type": "markdown", + "id": "3143955c-446a-4e6c-834c-583ab173f446", + "metadata": {}, + "source": [ + "## visualize the lemma graph" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "05b409af-14df-4158-9709-ffe2d79e864b", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-24T17:26:10.024360Z", + "iopub.status.busy": "2024-01-24T17:26:10.020502Z", + "iopub.status.idle": "2024-01-24T17:26:10.321275Z", + "shell.execute_reply": "2024-01-24T17:26:10.319871Z", + "shell.execute_reply.started": "2024-01-24T17:26:10.024325Z" + }, + "scrolled": true + }, + "outputs": [], + "source": [ + "render: textgraphs.RenderPyVis = tg.create_render()\n", + "\n", + "pv_graph: pyvis.network.Network = render.render_lemma_graph(\n", + " debug = False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "7b5d3e88-6669-4df1-a20a-587cc6a7db12", + "metadata": {}, + "source": [ + "initialize the layout parameters" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "b212f5ed-03d6-439f-92ae-f2cbedb18609", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-24T17:26:11.343717Z", + "iopub.status.busy": "2024-01-24T17:26:11.343435Z", + "iopub.status.idle": "2024-01-24T17:26:11.385195Z", + "shell.execute_reply": "2024-01-24T17:26:11.379207Z", + "shell.execute_reply.started": "2024-01-24T17:26:11.343691Z" + } + }, + "outputs": [], + "source": [ + "pv_graph.force_atlas_2based(\n", + " gravity = -38,\n", + " central_gravity = 0.01,\n", + " spring_length = 231,\n", + " spring_strength = 0.7,\n", + " damping = 0.8,\n", + " overlap = 0,\n", + ")\n", + "\n", + "pv_graph.show_buttons(filter_ = [ \"physics\" ])\n", + "pv_graph.toggle_physics(True)" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "2f952a7c-3130-49c9-b659-fb941e9e0bfe", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-24T17:26:12.529172Z", + "iopub.status.busy": "2024-01-24T17:26:12.528709Z", + "iopub.status.idle": "2024-01-24T17:26:12.951605Z", + "shell.execute_reply": "2024-01-24T17:26:12.915999Z", + "shell.execute_reply.started": "2024-01-24T17:26:12.529144Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "tmp.fig01.html\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pv_graph.prep_notebook()\n", + "pv_graph.show(\"tmp.fig01.html\")" + ] + }, + { + "cell_type": "markdown", + "id": "dc6654c8-0a4c-4e62-8cfc-f49e33f81064", + "metadata": {}, + "source": [ + "## generate a word cloud" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "ba9543cd-b1e9-4f0a-930c-7a0a6ccb7f0a", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:47.192425Z", + "iopub.status.busy": "2024-01-17T01:42:47.191808Z", + "iopub.status.idle": "2024-01-17T01:42:47.414389Z", + "shell.execute_reply": "2024-01-17T01:42:47.413720Z", + "shell.execute_reply.started": "2024-01-17T01:42:47.192376Z" + } + }, + "outputs": [ + { + "data": { + "image/jpeg": "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", + "image/png": "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", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "wordcloud = render.generate_wordcloud()\n", + "display(wordcloud.to_image())" + ] + }, + { + "cell_type": "markdown", + "id": "9e55b207-16d2-488a-b89b-b6ea8aed0ad9", + "metadata": {}, + "source": [ + "## cluster communities in the lemma graph" + ] + }, + { + "cell_type": "markdown", + "id": "9bdcbe84-ae83-4fa6-91ad-3069b212dc72", + "metadata": {}, + "source": [ + "In the tutorial\n", + "
\"How to Convert Any Text Into a Graph of Concepts\", \n", + "Rahul Nayak uses the\n", + "girvan-newman\n", + "algorithm to split the graph into communities, then clusters on those communities.\n", + "His approach works well for unsupervised clustering of key phrases which have been extracted from many documents.\n", + "In contrast, Nayak was working with entities extracted from \"chunks\" of text, not with a text graph." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "cd2d2f21-966e-40d6-8335-20dbfd8316ed", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:47.416003Z", + "iopub.status.busy": "2024-01-17T01:42:47.415758Z", + "iopub.status.idle": "2024-01-17T01:42:48.383920Z", + "shell.execute_reply": "2024-01-17T01:42:48.383286Z", + "shell.execute_reply.started": "2024-01-17T01:42:47.415969Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "render.draw_communities();" + ] + }, + { + "cell_type": "markdown", + "id": "bb59d135-5b14-4841-ba76-89712017e4d6", + "metadata": {}, + "source": [ + "## graph of relations transform" + ] + }, + { + "cell_type": "markdown", + "id": "d751fa5e-e6ca-4de6-a3f3-c9f8acb43e5e", + "metadata": {}, + "source": [ + "Show a transformed graph, based on _graph of relations_ (see: `lee2023ingram`)" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "5ec1352a-f281-4965-b68d-3e86c0269f09", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-24T17:27:02.399419Z", + "iopub.status.busy": "2024-01-24T17:27:02.398846Z", + "iopub.status.idle": "2024-01-24T17:27:02.528662Z", + "shell.execute_reply": "2024-01-24T17:27:02.527016Z", + "shell.execute_reply.started": "2024-01-24T17:27:02.399365Z" + } + }, + "outputs": [], + "source": [ + "graph: textgraphs.GraphOfRelations = textgraphs.GraphOfRelations(\n", + " tg\n", + ")\n", + "\n", + "graph.seeds()\n", + "graph.construct_gor()" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "a1dc17f1-eaeb-469a-8593-76950d70cc95", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:48.395746Z", + "iopub.status.busy": "2024-01-17T01:42:48.395315Z", + "iopub.status.idle": "2024-01-17T01:42:48.444015Z", + "shell.execute_reply": "2024-01-17T01:42:48.443074Z", + "shell.execute_reply.started": "2024-01-17T01:42:48.395667Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "tmp.fig02.html\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "scores: typing.Dict[ tuple, float ] = graph.get_affinity_scores()\n", + "pv_graph: pyvis.network.Network = graph.render_gor_pyvis(scores)\n", + "\n", + "pv_graph.force_atlas_2based(\n", + " gravity = -38,\n", + " central_gravity = 0.01,\n", + " spring_length = 231,\n", + " spring_strength = 0.7,\n", + " damping = 0.8,\n", + " overlap = 0,\n", + ")\n", + "\n", + "pv_graph.show_buttons(filter_ = [ \"physics\" ])\n", + "pv_graph.toggle_physics(True)\n", + "\n", + "pv_graph.prep_notebook()\n", + "pv_graph.show(\"tmp.fig02.html\")" + ] + }, + { + "cell_type": "markdown", + "id": "c191fde0-1093-4cdc-a3ea-86cc2bf394b8", + "metadata": {}, + "source": [ + "*What does this transform provide?*\n", + "\n", + "By using a _graph of relations_ dual representation of our graph data, first and foremost we obtain a more compact representation of the relations in the graph, and means of making inferences (e.g., _link prediction_) where there is substantially more invariance in the training data.\n", + "\n", + "Also recognize that for a parse graph of a paragraph in the English language, the most interesting nodes will probably be either subjects (`nsubj`) or direct objects (`pobj`). Here in the _graph of relations_ we see illustrated how the important details from _entity linking_ tend to cluster near either `nsubj` or `pobj` entities, connected through punctuation. This is not as readily observed in the earlier visualization of the _lemma graph_." + ] + }, + { + "cell_type": "markdown", + "id": "68ea1b7e-bed2-453b-b210-129ddb082e2f", + "metadata": {}, + "source": [ + "## extract as RDF triples" + ] + }, + { + "cell_type": "markdown", + "id": "ae76750c-feac-414d-8362-5ab92294c858", + "metadata": {}, + "source": [ + "Extract the nodes and edges which have IRIs, to create an \"abstraction layer\" as a semantic graph at a higher level of detail above the _lemma graph_:" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "d9036aec-7c38-4fd7-b2f5-4615bf95c643", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:48.446174Z", + "iopub.status.busy": "2024-01-17T01:42:48.445378Z", + "iopub.status.idle": "2024-01-17T01:42:48.478519Z", + "shell.execute_reply": "2024-01-17T01:42:48.476893Z", + "shell.execute_reply.started": "2024-01-17T01:42:48.446112Z" + }, + "scrolled": true + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "@base .\n", + "@prefix dbo: .\n", + "@prefix dbr: .\n", + "@prefix schema: .\n", + "@prefix skos: .\n", + "@prefix wd_ent: .\n", + "\n", + "dbr:Germany skos:definition \"Germany (German: Deutschland, German pronunciation: [ˈdɔʏtʃlant]), constitutionally the Federal\"@en ;\n", + " skos:prefLabel \"Germany\"@en .\n", + "\n", + "dbr:United_States skos:definition \"The United States of America (USA), commonly known as the United States (U.S. or US) or America\"@en ;\n", + " skos:prefLabel \"United States\"@en .\n", + "\n", + "dbr:Werner_Herzog skos:definition \"Werner Herzog (German: [ˈvɛɐ̯nɐ ˈhɛɐ̯tsoːk]; born 5 September 1942) is a German film director\"@en ;\n", + " skos:prefLabel \"Werner Herzog\"@en .\n", + "\n", + "wd_ent:Q183 skos:definition \"country in Central Europe\"@en ;\n", + " skos:prefLabel \"Germany\"@en .\n", + "\n", + "wd_ent:Q44131 skos:definition \"German film director, producer, screenwriter, actor and opera director\"@en ;\n", + " skos:prefLabel \"Werner Herzog\"@en .\n", + "\n", + " a dbo:Country ;\n", + " skos:prefLabel \"America\"@en ;\n", + " schema:event .\n", + "\n", + " a dbo:Person ;\n", + " skos:prefLabel \"Dietrich Herzog\"@en ;\n", + " schema:children .\n", + "\n", + " skos:prefLabel \"filmmaker\"@en .\n", + "\n", + " skos:prefLabel \"intellectual\"@en .\n", + "\n", + " skos:prefLabel \"son\"@en .\n", + "\n", + " a dbo:Person ;\n", + " skos:prefLabel \"Werner\"@en .\n", + "\n", + " a dbo:Country ;\n", + " skos:prefLabel \"Germany\"@en .\n", + "\n", + " skos:prefLabel \"war\"@en .\n", + "\n", + " a dbo:Person ;\n", + " skos:prefLabel \"Werner Herzog\"@en ;\n", + " schema:nationality .\n", + "\n", + "dbo:Country skos:definition \"Countries, cities, states\"@en ;\n", + " skos:prefLabel \"country\"@en .\n", + "\n", + "dbo:Person skos:definition \"People, including fictional\"@en ;\n", + " skos:prefLabel \"person\"@en .\n", + "\n", + "\n" + ] + } + ], + "source": [ + "triples: str = tg.export_rdf()\n", + "print(triples)" + ] + }, + { + "cell_type": "markdown", + "id": "ff49fe28-e75f-4590-8b87-0d8962928cba", + "metadata": {}, + "source": [ + "## statistical stack profile instrumentation" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "af4ecb06-370f-4077-9899-29a1673e4768", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:48.482588Z", + "iopub.status.busy": "2024-01-17T01:42:48.481127Z", + "iopub.status.idle": "2024-01-17T01:42:48.493047Z", + "shell.execute_reply": "2024-01-17T01:42:48.492253Z", + "shell.execute_reply.started": "2024-01-17T01:42:48.482444Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "profiler.stop()" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "d5ac2ce6-15b1-41ad-8215-8a5f76036cf1", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:42:48.495272Z", + "iopub.status.busy": "2024-01-17T01:42:48.494829Z", + "iopub.status.idle": "2024-01-17T01:42:50.376362Z", + "shell.execute_reply": "2024-01-17T01:42:50.375698Z", + "shell.execute_reply.started": "2024-01-17T01:42:48.495244Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " _ ._ __/__ _ _ _ _ _/_ Recorded: 17:41:51 Samples: 11163\n", + " /_//_/// /_\\ / //_// / //_'/ // Duration: 57.137 CPU time: 72.235\n", + "/ _/ v4.6.1\n", + "\n", + "Program: /Users/paco/src/textgraphs/venv/lib/python3.10/site-packages/ipykernel_launcher.py -f /Users/paco/Library/Jupyter/runtime/kernel-8ffadb7d-3b45-4e0e-a94f-f098e5ad9fbe.json\n", + "\n", + "57.136 _UnixSelectorEventLoop._run_once asyncio/base_events.py:1832\n", + "└─ 57.135 Handle._run asyncio/events.py:78\n", + " [12 frames hidden] asyncio, ipykernel, IPython\n", + " 41.912 ZMQInteractiveShell.run_ast_nodes IPython/core/interactiveshell.py:3394\n", + " ├─ 20.701 ../ipykernel_5151/1245857438.py:1\n", + " │ └─ 20.701 TextGraphs.perform_entity_linking textgraphs/doc.py:534\n", + " │ └─ 20.701 KGWikiMedia.perform_entity_linking textgraphs/kg.py:306\n", + " │ ├─ 10.790 KGWikiMedia._link_kg_search_entities textgraphs/kg.py:932\n", + " │ │ └─ 10.787 KGWikiMedia.dbpedia_search_entity textgraphs/kg.py:641\n", + " │ │ └─ 10.711 get requests/api.py:62\n", + " │ │ [37 frames hidden] requests, urllib3, http, socket, ssl,...\n", + " │ ├─ 9.143 KGWikiMedia._link_spotlight_entities textgraphs/kg.py:851\n", + " │ │ └─ 9.140 KGWikiMedia.dbpedia_search_entity textgraphs/kg.py:641\n", + " │ │ └─ 9.095 get requests/api.py:62\n", + " │ │ [37 frames hidden] requests, urllib3, http, socket, ssl,...\n", + " │ └─ 0.768 KGWikiMedia._secondary_entity_linking textgraphs/kg.py:1060\n", + " │ └─ 0.768 KGWikiMedia.wikidata_search textgraphs/kg.py:575\n", + " │ └─ 0.765 KGWikiMedia._wikidata_endpoint textgraphs/kg.py:444\n", + " │ └─ 0.765 get requests/api.py:62\n", + " │ [7 frames hidden] requests, urllib3\n", + " └─ 19.514 ../ipykernel_5151/1708547378.py:1\n", + " ├─ 14.502 InferRel_Rebel.__init__ textgraphs/rel.py:121\n", + " │ └─ 14.338 pipeline transformers/pipelines/__init__.py:531\n", + " │ [39 frames hidden] transformers, torch, , json\n", + " ├─ 3.437 PipelineFactory.__init__ textgraphs/pipe.py:434\n", + " │ └─ 3.420 load spacy/__init__.py:27\n", + " │ [20 frames hidden] spacy, en_core_web_sm, catalogue, imp...\n", + " ├─ 0.900 InferRel_OpenNRE.__init__ textgraphs/rel.py:33\n", + " │ └─ 0.888 get_model opennre/pretrain.py:126\n", + " └─ 0.672 TextGraphs.create_pipeline textgraphs/doc.py:103\n", + " └─ 0.672 PipelineFactory.create_pipeline textgraphs/pipe.py:508\n", + " └─ 0.672 Pipeline.__init__ textgraphs/pipe.py:216\n", + " └─ 0.672 English.__call__ spacy/language.py:1016\n", + " [11 frames hidden] spacy, spacy_dbpedia_spotlight, reque...\n", + " 14.363 InferRel_Rebel.gen_triples_async textgraphs/pipe.py:188\n", + " ├─ 13.670 InferRel_Rebel.gen_triples textgraphs/rel.py:259\n", + " │ ├─ 12.439 InferRel_Rebel.tokenize_sent textgraphs/rel.py:145\n", + " │ │ └─ 12.436 TranslationPipeline.__call__ transformers/pipelines/text2text_generation.py:341\n", + " │ │ [42 frames hidden] transformers, torch, \n", + " │ └─ 1.231 KGWikiMedia.resolve_rel_iri textgraphs/kg.py:370\n", + " │ └─ 0.753 get_entity_dict_from_api qwikidata/linked_data_interface.py:21\n", + " │ [8 frames hidden] qwikidata, requests, urllib3\n", + " └─ 0.693 InferRel_OpenNRE.gen_triples textgraphs/rel.py:58\n", + "\n", + "\n" + ] + } + ], + "source": [ + "profiler.print()" + ] + }, + { + "cell_type": "markdown", + "id": "c47bcfd2-2bd6-49a5-8f1a-102d90edde39", + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, + "source": [ + "## outro" + ] + }, + { + "cell_type": "markdown", + "id": "68bea4f9-aec2-4b28-8f08-a4034851d066", + "metadata": {}, + "source": [ + "_\\[ more parts are in progress, getting added to this demo \\]_" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.10.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/ex1_0.ipynb b/examples/ex1_0.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..07103680f7696c84037771221c1184f18e847e99 --- /dev/null +++ b/examples/ex1_0.ipynb @@ -0,0 +1,1387 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "c32bf0b9-1445-4ede-ae49-7dd63ff3b08e", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:37.268964Z", + "iopub.status.busy": "2024-01-17T01:35:37.268658Z", + "iopub.status.idle": "2024-01-17T01:35:37.284720Z", + "shell.execute_reply": "2024-01-17T01:35:37.282292Z", + "shell.execute_reply.started": "2024-01-17T01:35:37.268927Z" + } + }, + "outputs": [], + "source": [ + "# for use in tutorial and development; do not include this `sys.path` change in production:\n", + "import sys ; sys.path.insert(0, \"../\")" + ] + }, + { + "cell_type": "markdown", + "id": "c8ff5d81-110c-42ae-8aa7-ed4fffea40c6", + "metadata": {}, + "source": [ + "# reproduce results from the \"InGram\" paper" + ] + }, + { + "cell_type": "markdown", + "id": "1e847d0a-bc6c-470a-9fef-620ebbdbbbc3", + "metadata": {}, + "source": [ + "This is an attempt to reproduce the _graph of relations_ example given in `lee2023ingram`" + ] + }, + { + "cell_type": "markdown", + "id": "61d8d39a-23e4-48e7-b8f4-0dd724ccf586", + "metadata": {}, + "source": [ + "## environment" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "22489527-2ad5-4e3c-be23-f511e6bcf69f", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:37.296455Z", + "iopub.status.busy": "2024-01-17T01:35:37.295661Z", + "iopub.status.idle": "2024-01-17T01:35:45.520968Z", + "shell.execute_reply": "2024-01-17T01:35:45.519870Z", + "shell.execute_reply.started": "2024-01-17T01:35:37.296419Z" + }, + "scrolled": true + }, + "outputs": [], + "source": [ + "import os\n", + "import pathlib\n", + "import typing\n", + "\n", + "from icecream import ic\n", + "from pyinstrument import Profiler\n", + "import matplotlib.pyplot as plt\n", + "import pandas as pd\n", + "import pyvis\n", + "\n", + "import textgraphs" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "438f5775-487b-493e-a172-59b652b94955", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:45.525301Z", + "iopub.status.busy": "2024-01-17T01:35:45.524842Z", + "iopub.status.idle": "2024-01-17T01:35:45.547432Z", + "shell.execute_reply": "2024-01-17T01:35:45.546101Z", + "shell.execute_reply.started": "2024-01-17T01:35:45.525270Z" + } + }, + "outputs": [], + "source": [ + "%load_ext watermark" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "adc052dd-5cca-4d11-b543-3f0999f4f883", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:45.548916Z", + "iopub.status.busy": "2024-01-17T01:35:45.548691Z", + "iopub.status.idle": "2024-01-17T01:35:45.592124Z", + "shell.execute_reply": "2024-01-17T01:35:45.590790Z", + "shell.execute_reply.started": "2024-01-17T01:35:45.548889Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Last updated: 2024-01-16T17:35:45.550539-08:00\n", + "\n", + "Python implementation: CPython\n", + "Python version : 3.10.11\n", + "IPython version : 8.20.0\n", + "\n", + "Compiler : Clang 13.0.0 (clang-1300.0.29.30)\n", + "OS : Darwin\n", + "Release : 21.6.0\n", + "Machine : x86_64\n", + "Processor : i386\n", + "CPU cores : 8\n", + "Architecture: 64bit\n", + "\n" + ] + } + ], + "source": [ + "%watermark" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6e4618da-daf9-44c9-adbb-e5781dba5504", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:45.597302Z", + "iopub.status.busy": "2024-01-17T01:35:45.596553Z", + "iopub.status.idle": "2024-01-17T01:35:45.623704Z", + "shell.execute_reply": "2024-01-17T01:35:45.621991Z", + "shell.execute_reply.started": "2024-01-17T01:35:45.597251Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "matplotlib: 3.8.2\n", + "pandas : 2.1.4\n", + "pyvis : 0.3.2\n", + "textgraphs: 0.5.0\n", + "sys : 3.10.11 (v3.10.11:7d4cc5aa85, Apr 4 2023, 19:05:19) [Clang 13.0.0 (clang-1300.0.29.30)]\n", + "\n" + ] + } + ], + "source": [ + "%watermark --iversions" + ] + }, + { + "cell_type": "markdown", + "id": "1a04e3dc-57d8-43a4-a342-cc38b86fc6a6", + "metadata": {}, + "source": [ + "## load example graph" + ] + }, + { + "cell_type": "markdown", + "id": "7c567afd-2f44-4391-899a-da6aba3d222e", + "metadata": {}, + "source": [ + "load from a JSON file which replicates the data for the \"Figure 3\" example" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "630430c5-21dc-4897-9a4b-3b01baf3de17", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:45.625764Z", + "iopub.status.busy": "2024-01-17T01:35:45.625341Z", + "iopub.status.idle": "2024-01-17T01:35:45.633487Z", + "shell.execute_reply": "2024-01-17T01:35:45.632477Z", + "shell.execute_reply.started": "2024-01-17T01:35:45.625720Z" + } + }, + "outputs": [], + "source": [ + "graph: textgraphs.GraphOfRelations = textgraphs.GraphOfRelations(\n", + " textgraphs.SimpleGraph()\n", + ")\n", + "\n", + "ingram_path: pathlib.Path = pathlib.Path(os.getcwd()) / \"ingram.json\"\n", + "\n", + "graph.load_ingram(\n", + " ingram_path,\n", + " debug = False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "01152885-f301-49b1-ab61-f5b19d81c036", + "metadata": {}, + "source": [ + "set up the statistical stack profiling" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "2a289117-301d-4027-ae1b-200201fb5f93", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:45.639466Z", + "iopub.status.busy": "2024-01-17T01:35:45.639216Z", + "iopub.status.idle": "2024-01-17T01:35:45.646105Z", + "shell.execute_reply": "2024-01-17T01:35:45.644476Z", + "shell.execute_reply.started": "2024-01-17T01:35:45.639439Z" + } + }, + "outputs": [], + "source": [ + "profiler: Profiler = Profiler()\n", + "profiler.start()" + ] + }, + { + "cell_type": "markdown", + "id": "bf9d4f99-b82b-4d11-a9a4-31d0337f4aa8", + "metadata": {}, + "source": [ + "## decouple graph edges into \"seeds\"" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "da6fcb0f-b2ac-4f74-af39-2c129c750cab", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:45.648335Z", + "iopub.status.busy": "2024-01-17T01:35:45.647905Z", + "iopub.status.idle": "2024-01-17T01:35:46.520730Z", + "shell.execute_reply": "2024-01-17T01:35:46.518237Z", + "shell.execute_reply.started": "2024-01-17T01:35:45.648291Z" + }, + "scrolled": true + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "--- triples in source graph ---\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "ic| edge.src_node: 0, rel_id: 1, edge.dst_node: 1\n", + "ic| edge.src_node: 0, rel_id: 0, edge.dst_node: 2\n", + "ic| edge.src_node: 0, rel_id: 0, edge.dst_node: 3\n", + "ic| edge.src_node: 4, rel_id: 2, edge.dst_node: 2\n", + "ic| edge.src_node: 4, rel_id: 2, edge.dst_node: 3\n", + "ic| edge.src_node: 4, rel_id: 1, edge.dst_node: 5\n", + "ic| edge.src_node: 6, rel_id: 1, edge.dst_node: 5\n", + "ic| edge.src_node: 6, rel_id: 2, edge.dst_node: 7\n", + "ic| edge.src_node: 6, rel_id: 4, edge.dst_node: 8\n", + "ic| edge.src_node: 9, " + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Steven_Spielberg Profession Director\n", + " Steven_Spielberg Directed Catch_Me_If_Can\n", + " Steven_Spielberg Directed Saving_Private_Ryan\n", + " Tom_Hanks ActedIn Catch_Me_If_Can\n", + " Tom_Hanks ActedIn Saving_Private_Ryan\n", + " Tom_Hanks Profession Actor\n", + " Mark_Hamil Profession Actor\n", + " Mark_Hamil ActedIn Star_Wars\n", + " Mark_Hamil BornIn California\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "rel_id: 5, edge.dst_node: 10\n", + "ic| edge.src_node: 9, rel_id: 4, edge.dst_node: 10\n", + "ic| edge.src_node: 9, rel_id: 3, edge.dst_node: 8\n", + "ic| edge.src_node: 11, rel_id: 4, edge.dst_node: 12\n", + "ic| edge.src_node: 11, rel_id: 3, edge.dst_node: 12\n", + "ic| edge.src_node: 11, rel_id: 3, edge.dst_node: 8\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Brad_Pitt Nationality USA\n", + " Brad_Pitt BornIn USA\n", + " Brad_Pitt LivedIn California\n", + " Clint_Eastwood BornIn San_Francisco\n", + " Clint_Eastwood LivedIn San_Francisco\n", + " Clint_Eastwood LivedIn California\n" + ] + } + ], + "source": [ + "graph.seeds(\n", + " debug = True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "a9c0fd41-45e9-4019-94bf-8e2cf5c33454", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:46.524005Z", + "iopub.status.busy": "2024-01-17T01:35:46.523531Z", + "iopub.status.idle": "2024-01-17T01:35:46.531929Z", + "shell.execute_reply": "2024-01-17T01:35:46.530922Z", + "shell.execute_reply.started": "2024-01-17T01:35:46.523965Z" + }, + "scrolled": true + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "--- nodes in source graph ---\n", + "n: 0, Steven_Spielberg\n", + " head: []\n", + " tail: [(0, 'Profession', 1), (0, 'Directed', 2), (0, 'Directed', 3)]\n", + "n: 1, Director\n", + " head: [(0, 'Profession', 1)]\n", + " tail: []\n", + "n: 2, Catch_Me_If_Can\n", + " head: [(0, 'Directed', 2), (4, 'ActedIn', 2)]\n", + " tail: []\n", + "n: 3, Saving_Private_Ryan\n", + " head: [(0, 'Directed', 3), (4, 'ActedIn', 3)]\n", + " tail: []\n", + "n: 4, Tom_Hanks\n", + " head: []\n", + " tail: [(4, 'ActedIn', 2), (4, 'ActedIn', 3), (4, 'Profession', 5)]\n", + "n: 5, Actor\n", + " head: [(4, 'Profession', 5), (6, 'Profession', 5)]\n", + " tail: []\n", + "n: 6, Mark_Hamil\n", + " head: []\n", + " tail: [(6, 'Profession', 5), (6, 'ActedIn', 7), (6, 'BornIn', 8)]\n", + "n: 7, Star_Wars\n", + " head: [(6, 'ActedIn', 7)]\n", + " tail: []\n", + "n: 8, California\n", + " head: [(6, 'BornIn', 8), (9, 'LivedIn', 8), (11, 'LivedIn', 8)]\n", + " tail: []\n", + "n: 9, Brad_Pitt\n", + " head: []\n", + " tail: [(9, 'Nationality', 10), (9, 'BornIn', 10), (9, 'LivedIn', 8)]\n", + "n: 10, USA\n", + " head: [(9, 'Nationality', 10), (9, 'BornIn', 10)]\n", + " tail: []\n", + "n: 11, Clint_Eastwood\n", + " head: []\n", + " tail: [(11, 'BornIn', 12), (11, 'LivedIn', 12), (11, 'LivedIn', 8)]\n", + "n: 12, San_Francisco\n", + " head: [(11, 'BornIn', 12), (11, 'LivedIn', 12)]\n", + " tail: []\n", + "\n", + "--- edges in source graph ---\n", + "e: 0, Directed\n", + "e: 1, Profession\n", + "e: 2, ActedIn\n", + "e: 3, LivedIn\n", + "e: 4, BornIn\n", + "e: 5, Nationality\n" + ] + } + ], + "source": [ + "graph.trace_source_graph()" + ] + }, + { + "cell_type": "markdown", + "id": "7e7cb5f3-132c-4999-81eb-4f6167a31c9e", + "metadata": {}, + "source": [ + "## construct a _graph of relations_" + ] + }, + { + "cell_type": "markdown", + "id": "105702ed-7f9c-42ca-a57b-f1b15a206acf", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-02T22:31:57.839227Z", + "iopub.status.busy": "2024-01-02T22:31:57.838113Z", + "iopub.status.idle": "2024-01-02T22:31:57.853374Z", + "shell.execute_reply": "2024-01-02T22:31:57.851669Z", + "shell.execute_reply.started": "2024-01-02T22:31:57.839155Z" + } + }, + "source": [ + "Transform the graph data into _graph of relations_" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "eae8da18-f1be-4673-94e7-7b633bab9bd1", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:46.534228Z", + "iopub.status.busy": "2024-01-17T01:35:46.533720Z", + "iopub.status.idle": "2024-01-17T01:35:48.718340Z", + "shell.execute_reply": "2024-01-17T01:35:48.715493Z", + "shell.execute_reply.started": "2024-01-17T01:35:46.534166Z" + }, + "scrolled": true + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "ic| node_id: 0, len(seeds" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "--- transformed triples ---\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "): 3\n", + "ic| trans_arc: TransArc(pair_key=(0, 1),\n", + " a_rel=1,\n", + " b_rel=0,\n", + " node_id=0,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| trans_arc: TransArc(pair_key=(0, 1),\n", + " a_rel=1,\n", + " b_rel=0,\n", + " node_id=0,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| trans_arc: TransArc(pair_key=(0, 0),\n", + " a_rel=0,\n", + " b_rel=0,\n", + " node_id=0,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| node_id: 1, len(seeds" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "): 1\n", + "ic| node_id: 2, len(seeds): 2\n", + "ic| trans_arc: TransArc(pair_key=(0, 2),\n", + " a_rel=0,\n", + " b_rel=2,\n", + " node_id=2,\n", + " a_dir=,\n", + " b_dir=<" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " (0, 2) Directed.head Catch_Me_If_Can ActedIn.head\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "RelDir.HEAD: 0>)\n", + "ic| node_id: 3, len(seeds): 2\n", + "ic| trans_arc: TransArc(pair_key=(0, 2),\n", + " a_rel=0,\n", + " b_rel=2,\n", + " node_id=3,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| node_id" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " (0, 2) Directed.head Saving_Private_Ryan ActedIn.head\n", + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + ": 4, len(seeds): 3\n", + "ic| trans_arc: TransArc(pair_key=(2, 2),\n", + " a_rel=2,\n", + " b_rel=2,\n", + " node_id=4,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| trans_arc: TransArc(pair_key=(1, 2),\n", + " a_rel=2,\n", + " b_rel=1,\n", + " node_id=4,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| trans_arc: TransArc(pair_key=(1, 2)" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " (2, 2) ActedIn.tail Tom_Hanks ActedIn.tail\n", + "\n", + " (1, 2) ActedIn.tail Tom_Hanks Profession.tail\n", + "\n", + " (1, 2) ActedIn.tail Tom_Hanks Profession.tail\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + ",\n", + " a_rel=2,\n", + " b_rel=1,\n", + " node_id=4,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic|" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + " node_id: 5, len(seeds): 2\n", + "ic| trans_arc: TransArc(pair_key=(1, 1),\n", + " a_rel=1,\n", + " b_rel=1,\n", + " " + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " (1, 1) Profession.head Actor Profession.head\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "node_id=5,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| node_id: 6, len(seeds): 3\n", + "ic| trans_arc: TransArc(pair_key=(1, 2),\n", + " a_rel=1,\n", + " b_rel=2,\n", + " node_id=6,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| trans_arc: TransArc(pair_key=(1, 4),\n", + " a_rel=1,\n", + " b_rel=4,\n", + " node_id=6,\n", + " a_dir" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " (1, 4) Profession.tail Mark_Hamil BornIn.tail\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "=,\n", + " b_dir=)\n", + "ic| trans_arc: TransArc(pair_key=(2, 4),\n", + " a_rel=2,\n", + " b_rel=4,\n", + " node_id=6,\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " (2, 4) ActedIn.tail Mark_Hamil BornIn.tail\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + " a_dir=,\n", + " b_dir=)\n", + "ic| node_id: 7, len(seeds): 1\n", + "ic| node_id: 8, len(seeds): 3\n", + "ic| trans_arc: TransArc(pair_key=(3, 4),\n", + " a_rel=4,\n", + " b_rel=3,\n", + " node_id=8,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| trans_arc: TransArc(pair_key=(3, 4),\n", + " a_rel=4,\n", + " b_rel=3,\n", + " node_id=8,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| trans_arc: TransArc(pair_key=(3, 3),\n", + " a_rel=3,\n", + " b_rel=3,\n", + " node_id=8,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| node_id: 9, len(seeds): 3\n", + "ic" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " (3, 4) BornIn.head California LivedIn.head\n", + "\n", + " (3, 3) LivedIn.head California LivedIn.head\n", + "\n", + " (4, 5) Nationality.tail Brad_Pitt BornIn.tail\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "| trans_arc: TransArc(pair_key=(4, 5),\n", + " a_rel=5,\n", + " b_rel=4,\n", + " node_id=9,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| trans_arc: TransArc(pair_key=(3, 5),\n", + " a_rel=5,\n", + " b_rel=3,\n", + " node_id=9,\n", + " a_dir=,\n", + " b_dir=<" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " (3, 5) Nationality.tail Brad_Pitt LivedIn.tail\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "RelDir.TAIL: 1>)\n", + "ic| trans_arc: TransArc(pair_key=(3, 4),\n", + " a_rel=4,\n", + " b_rel=3,\n", + " node_id=9,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| node_id: 10, len(seeds): 2\n", + "ic| trans_arc: TransArc(pair_key=(4, 5),\n", + " a_rel=5,\n", + " b_rel=4,\n", + " node_id=10,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| node_id: 11, len(seeds): 3\n", + "ic| trans_arc: TransArc(pair_key=(3, " + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " (3, 4) BornIn.tail Brad_Pitt LivedIn.tail\n", + "\n", + " (4, 5) Nationality.head USA BornIn.head\n", + "\n", + " (3, 4) BornIn.tail Clint_Eastwood LivedIn.tail\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "4),\n", + " a_rel=4,\n", + " b_rel=3,\n", + " node_id=11,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " (3, 4) BornIn.tail Clint_Eastwood LivedIn.tail\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "| trans_arc: TransArc(pair_key=(3, 4),\n", + " a_rel=4,\n", + " b_rel=3,\n", + " node_id=11,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| trans_arc: TransArc(pair_key=(3, 3),\n", + " a_rel=3,\n", + " b_rel=3,\n", + " node_id=11,\n", + " a_dir=,\n", + " b_dir=)\n", + "ic| node_id: 12, len(seeds" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " (3, 3) LivedIn.tail Clint_Eastwood LivedIn.tail\n", + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "): 2\n", + "ic| trans_arc: TransArc(pair_key=(3, 4),\n", + " a_rel=4,\n", + " b_rel=3,\n", + " node_id=12,\n", + " a_dir=,\n", + " b_dir=)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " (3, 4) BornIn.head San_Francisco LivedIn.head\n", + "\n" + ] + } + ], + "source": [ + "graph.construct_gor(\n", + "\tdebug = True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "d5a06b72-c19b-440c-83c7-332f28aa9586", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:48.731674Z", + "iopub.status.busy": "2024-01-17T01:35:48.731142Z", + "iopub.status.idle": "2024-01-17T01:35:48.745182Z", + "shell.execute_reply": "2024-01-17T01:35:48.739573Z", + "shell.execute_reply.started": "2024-01-17T01:35:48.731638Z" + }, + "scrolled": true + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "--- collect shared entity tallies ---\n", + "0 Directed\n", + " h: 4 dict_items([(2, 4.0)])\n", + " t: 6 dict_items([(0, 3.0), (1, 3.0)])\n", + "1 Profession\n", + " h: 3 dict_items([(1, 3.0)])\n", + " t: 10 dict_items([(0, 3.0), (2, 5.0), (4, 2.0)])\n", + "2 ActedIn\n", + " h: 4 dict_items([(0, 4.0)])\n", + " t: 10 dict_items([(1, 5.0), (2, 3.0), (4, 2.0)])\n", + "3 LivedIn\n", + " h: 8 dict_items([(3, 3.0), (4, 5.0)])\n", + " t: 10 dict_items([(3, 3.0), (4, 5.0), (5, 2.0)])\n", + "4 BornIn\n", + " h: 7 dict_items([(3, 5.0), (5, 2.0)])\n", + " t: 11 dict_items([(1, 2.0), (2, 2.0), (3, 5.0), (5, 2.0)])\n", + "5 Nationality\n", + " h: 2 dict_items([(4, 2.0)])\n", + " t: 4 dict_items([(3, 2.0), (4, 2.0)])\n" + ] + } + ], + "source": [ + "scores: typing.Dict[ tuple, float ] = graph.get_affinity_scores(\n", + " debug = True,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "a3d2310b-11c1-476d-82ab-1e34bc496cb1", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:48.749266Z", + "iopub.status.busy": "2024-01-17T01:35:48.748905Z", + "iopub.status.idle": "2024-01-17T01:35:48.964799Z", + "shell.execute_reply": "2024-01-17T01:35:48.957975Z", + "shell.execute_reply.started": "2024-01-17T01:35:48.749231Z" + }, + "scrolled": true + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "ic| scores: {(0, 0): 0.3,\n", + " (0, 1): 0.2653846153846154,\n", + " (0, 2): 0.34285714285714286,\n", + " (1, 1): 0.23076923076923078,\n", + " (1, 2): 0.3708791208791209,\n", + " (1, 4): 0.13247863247863248,\n", + " (2, 2): 0.21428571428571427,\n", + " (2, 4): 0.12698412698412698,\n", + " (3, 3): 0.3333333333333333,\n", + " (3, 4): 0.5555555555555556,\n", + " (3, 5): 0.2222222222222222,\n", + " (4, 5): 0.4444444444444444}\n" + ] + } + ], + "source": [ + "ic(scores);" + ] + }, + { + "cell_type": "markdown", + "id": "8b71b841-0cf5-4cc6-af4c-c85344b8f6c5", + "metadata": {}, + "source": [ + "## visualize the transform results" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "5901a49e-3f90-4061-9c3a-e9d1f05b40f3", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:48.973661Z", + "iopub.status.busy": "2024-01-17T01:35:48.973146Z", + "iopub.status.idle": "2024-01-17T01:35:49.339291Z", + "shell.execute_reply": "2024-01-17T01:35:49.337857Z", + "shell.execute_reply.started": "2024-01-17T01:35:48.973607Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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DdpOp5OoqCjCw8Npbm5WVwH1/C15Zt08+eSTnH322eoSvEKcKK1Wq57g21IUBZvN1qE1obS0FLvdrt4uICCg09aEE9nL4e677+a9997DbDazZMkSbrrpph55jk6nk88++4zly5dz8OBBli9f3mEtCl/g9TCwZ88eHnnkERoaGrjzzjvZvXt3u+sP/4HGx8dz3nnnce+997J06VKio6OprKxk/fr1TJ8+nbPPPpsHH3yQxx9/nIULF6pjBqZMmUJ0dDRarZb8/Hy1ZWHOnDm88MILrF69mpkzZwKwc+dOHA4HEyZM4PLLL+eRRx5h5syZpKWlcf/996tv6gNhtOzx6G6Y6E6gOd7r2wah7hz/eBQUFFBQUHBc9zmRsBIUFEROTk6HY9lsNlpbW0lOTm53eVJSEuvWrTvq83G5XOj1et5//32qqqo4++yz1d9vz9+gTqdj/fr17Nq1i5dffvmoYwU8y+UOpNYf0Xc0Gg0BAQEEBAR0mALo6XLwjA/bvHmzOgts0KBB3HTTTYwcObJdQAgPD++wQFNbnoWgbrvtNu65554efS56vZ4pU6bw008/sX79eiZOnNijx+8pXgkDixYt4oEHHkCr1ZKcnMy5557Lhg0biIuL6xAGoGMgWLFiBU8++SRnnnkmZrOZuLg4Zs2axfTp0wkJCWHVqlUsXryY0aNHAzB58mSmTJlCYGAg9957L3PmzMFut/P0009z8cUX88EHH/Doo4+yYMEC3G432dnZPPzwwwBce+215OfnM2XKFHQ6HYsXL+aDDz7o/RfJB/nbm/mxgownTOTn51NXV8eYMWP6JPAc6STscrlwu90dPv177nekZkrPOu3fffcdCxYs4Mknn1T/dg733HPPMW7cOMaOHXvU127Dhg20tLR0ep23WnB6skurq/WB700h6+/adjkEBgbyxz/+kYULF2KxWLj33nt59NFH+f7772lpaaGpqYnKykpMJhOTJk064jGfffZZQkNDe3SlwcPde++9PPHEE2zbtk3d7MqXaJQufPxpbGwkPDychoaGds2PfcVisfTqNA3PghpCnIgff/yRiIgIsrOzvV0KwcHBvPzyy8ybN0+9bOHChezcuZNPP/0Uk8mkNvsDalfAf//7Xy699FLuuOMO7rrrrk67zOrr60lNTeXPf/4z11577VFPcmazGafT2aXA0xctTEe7TW/z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Cm644QYqKyt577332nXZeDgcDq666io2b97M559/ftS553q9vt0YEJfLhd1uP2JryPEshuMJQifSvTIQgsezzz7bJ2tuNDU1sW3bNs4888xefyzRnoQB0ad0Oh16vf64pyN1RXf6Hd966y0WLFjAb37zG66++uoermzgu/XWW7n00ks544wzuPDCC3nrrbf49ttv+frrrwkODubNN98kMDCQWbNmodfryc/P56677kKn0/Htt9922prT3NzM1VdfzcGDB/n666+POrBUo9EQHh7epS4mD8/Yi+PpTjnSv06n86jXH2/w6InBo10ZWHqs4JGRkcFTTz3Fhg0bmDdvHm+99RaTJk1Srz/55JO55557mD17NtXV1SxevJjvvvsOjUbDzJkzeeihh9SA/vHHH7N06VJqamq48MILO30PCAsLY8yYMcyfPx+DwUBTUxOfffYZSUlJvPzyy0ybNq3Lr6M4PhIGRJ8LCgpqt7BQTx73RF155ZVceeWVPViNf5kxYwaPPPIIixcv5ne/+x1xcXG88MIL6tTR5cuXExkZycyZMwG45pprWLt2LXPnzmXZsmUEBAQQHBzMpEmTGDduHIqicM4557B161Y+/vhjrFYrpaWlmEwmIiMje2RchueE2BP938fSNnh0Z4yHJ3jY7fYjhpbj4QkHnhUCO9PU1ERQUBAXXXQR7777rhoGNm/eTHl5Oeeddx6KojB37lwmTpzIxo0bsVqtzJ8/n6effprFixezf/9+brzxRl5//XV+85vf8NZbb3HvvfeSm5vb7rFWr16tzgR69913+fjjj3n77bd54oknmD9/fpf2RBEnRsKA6HM6nY6goKAeHZUcFBQko5G9bOHChVx55ZXU19djMBjIyMhQP3k+99xzaDQadTDh9OnTOeWUU8jPz+fnn3+mtbVVXUNi/PjxVFVVUVlZSVhYGOedd566xr3b7ebLL7/kN7/5jdee54noq+ChKMoJDS41Go1HrS0jI4PLLruM2bNnq+Ht3Xff5YILLiAwMJBNmzZx8OBB/vWvf6HVagkKCuKOO+7grrvuYvHixaxatYrTTjuNc845BzgUBl966aUOj/PII4+oY0LOO+88tSXgmmuu4YEHHqC2trbT5ZBF90kYEF5hMplwu909Ml85ICBA5in7AJ1OR1xcXKcbzJx66qntvr/77ruPeqz4+Hj27dvX4XKXy+WTfeq+wjOb5niDR2BgYKezPgBCQ0MJDAxk3LhxxMfH8/nnnzNjxgw+/PBD/vrXvwJQVFREQ0MDmZmZ6v08oQSgoqKC1NTUdsc9/HtPHStXrgQgISFBvdwzU6WpqUnCQC+RMCC8JjAwEK1W260WgqCgIAkCfkRaf/qewWAgKysLOLQ2yHvvvUdQUBCBgYFql0FycjKxsbHs3r2702MkJCSwfv36dpeVlJQwfvz4dpfZbDZZpthLBtaaqKLfMZlMhIWFHXfzqV6vJywsTIKAED3E6XRitVrVr7Yn5dmzZ+N2u7n00kv55ptvePHFF5kzZ47aSjN27FiSk5N57LHHaGpqQlEUiouL+eqrrwC48MILWbt2LV9++SVOp5M33nij3YJkHhs2bGi31oToOxIGhNfpdDp1JUOTyXTEueyeZUvDwsIIDQ2VT4lC9KB77rmHwMBA9WvYsGHqdRdffDEtLS2kpKRw0kknsXbtWi699FL1ep1Ox9///nfKyso4+eSTSU9P59JLL1U3/hoyZAgvvvgi9913H1lZWWzcuLHT6YMffvhhp+tIiN4niw4Jn+RZ6MZDp9NJX7EQXuTrC4aJzsmiQ6Jf68pCN0KIvuPLC4aJ7pNuAiGEEMfkWTCsN8hGRd4nYUAIIUSX9FYzvnQPeJ+EASGEEF3iWTCsJ8mCYb5BwoAQQoguM5lMR9zK+njJgmG+Q8KAEEKI4xIYGNjtFgLPwkXCN8hwbSGEEMfNZDKh1+uxWCzHtQupXq+XrgEfJGFACCHECfEsGOZyubDZbDgcDnU/gra0Wi0GgwGTySQhwEdJGBBCCNEtbQcWyoJh/ZOEASGEED1GFgzrn2QAoRBCCOHnJAwIIYQQfk7CgBBCCOHnJAwIIYQQfk7CgBBCCOHnJAwIIYQQfk7CgBBCCOHnJAwIIYQQfk7CgBBCCOHnJAwIIYQQfk7CgBBCCOHnJAwIIYQQfk7CgBBCCOHnJAwIIYQQfk7CgBBCCOHnurTptKIoADQ2NvZqMUIIIYToOZ7ztuc8fiRdCgNNTU0ApKamdrMsIYQQQvS1pqYmwsPDj3i9RjlWXADcbjdlZWWEhoai0Wh6tEAhhBBC9A5FUWhqaiIpKQmt9sgjA7oUBoQQQggxcMkAQiGEEMLPSRgQQggh/JyEASGEEMLPSRgQQggh/JyEASGEEMLPSRgQQggh/JyEASGEEMLP/f+Pu7DOLdgI9AAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "graph.render_gor_plt(scores)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "8fa85274-6d16-48eb-b875-01108a9575b8", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:49.341965Z", + "iopub.status.busy": "2024-01-17T01:35:49.341537Z", + "iopub.status.idle": "2024-01-17T01:35:49.383683Z", + "shell.execute_reply": "2024-01-17T01:35:49.382725Z", + "shell.execute_reply.started": "2024-01-17T01:35:49.341916Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "tmp.fig03.html\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pv_graph: pyvis.network.Network = graph.render_gor_pyvis(scores)\n", + "\n", + "pv_graph.force_atlas_2based(\n", + " gravity = -38,\n", + " central_gravity = 0.01,\n", + " spring_length = 231,\n", + " spring_strength = 0.7,\n", + " damping = 0.8,\n", + " overlap = 0,\n", + ")\n", + "\n", + "pv_graph.show_buttons(filter_ = [ \"physics\" ])\n", + "pv_graph.toggle_physics(True)\n", + "\n", + "pv_graph.prep_notebook()\n", + "pv_graph.show(\"tmp.fig03.html\")" + ] + }, + { + "cell_type": "markdown", + "id": "07cf6fca-af95-4cf0-9e3b-247521bafbff", + "metadata": {}, + "source": [ + "## analysis" + ] + }, + { + "cell_type": "markdown", + "id": "97af44dc-4e56-4986-9f54-cbfaff67e3d4", + "metadata": {}, + "source": [ + "As the results below above illustrate, the computed _affinity scores_ differ from what is published in `lee2023ingram`. After trying several different variations of interpretation for the paper's descriptions, the current approach provides the closest approximation that we have obtained." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "f64462b4-654a-4e2e-bea2-a36bdc5ec967", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:49.387402Z", + "iopub.status.busy": "2024-01-17T01:35:49.386218Z", + "iopub.status.idle": "2024-01-17T01:35:49.434520Z", + "shell.execute_reply": "2024-01-17T01:35:49.432123Z", + "shell.execute_reply.started": "2024-01-17T01:35:49.387333Z" + } + }, + "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", + "
pairrel_arel_baffinityexpected
0(0, 0)DirectedDirected0.30NaN
1(0, 1)DirectedProfession0.270.22
2(0, 2)DirectedActedIn0.340.50
3(1, 1)ProfessionProfession0.23NaN
4(1, 2)ProfessionActedIn0.370.33
5(1, 4)ProfessionBornIn0.130.11
6(2, 2)ActedInActedIn0.21NaN
7(2, 4)ActedInBornIn0.130.11
8(3, 3)LivedInLivedIn0.33NaN
9(3, 4)LivedInBornIn0.560.81
10(3, 5)LivedInNationality0.220.11
11(4, 5)BornInNationality0.440.36
\n", + "
" + ], + "text/plain": [ + " pair rel_a rel_b affinity expected\n", + "0 (0, 0) Directed Directed 0.30 NaN\n", + "1 (0, 1) Directed Profession 0.27 0.22\n", + "2 (0, 2) Directed ActedIn 0.34 0.50\n", + "3 (1, 1) Profession Profession 0.23 NaN\n", + "4 (1, 2) Profession ActedIn 0.37 0.33\n", + "5 (1, 4) Profession BornIn 0.13 0.11\n", + "6 (2, 2) ActedIn ActedIn 0.21 NaN\n", + "7 (2, 4) ActedIn BornIn 0.13 0.11\n", + "8 (3, 3) LivedIn LivedIn 0.33 NaN\n", + "9 (3, 4) LivedIn BornIn 0.56 0.81\n", + "10 (3, 5) LivedIn Nationality 0.22 0.11\n", + "11 (4, 5) BornIn Nationality 0.44 0.36" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df: pd.DataFrame = graph.trace_metrics(scores)\n", + "df" + ] + }, + { + "cell_type": "markdown", + "id": "ff49fe28-e75f-4590-8b87-0d8962928cba", + "metadata": {}, + "source": [ + "## statistical stack profile instrumentation" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "af4ecb06-370f-4077-9899-29a1673e4768", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:49.437344Z", + "iopub.status.busy": "2024-01-17T01:35:49.436840Z", + "iopub.status.idle": "2024-01-17T01:35:49.444892Z", + "shell.execute_reply": "2024-01-17T01:35:49.444135Z", + "shell.execute_reply.started": "2024-01-17T01:35:49.437293Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "profiler.stop()" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "d5ac2ce6-15b1-41ad-8215-8a5f76036cf1", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:49.446514Z", + "iopub.status.busy": "2024-01-17T01:35:49.446199Z", + "iopub.status.idle": "2024-01-17T01:35:49.728817Z", + "shell.execute_reply": "2024-01-17T01:35:49.728098Z", + "shell.execute_reply.started": "2024-01-17T01:35:49.446483Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " _ ._ __/__ _ _ _ _ _/_ Recorded: 17:35:45 Samples: 2526\n", + " /_//_/// /_\\ / //_// / //_'/ // Duration: 3.799 CPU time: 4.060\n", + "/ _/ v4.6.1\n", + "\n", + "Program: /Users/paco/src/textgraphs/venv/lib/python3.10/site-packages/ipykernel_launcher.py -f /Users/paco/Library/Jupyter/runtime/kernel-27f0c564-73f8-45ab-9f64-8b064ae1de10.json\n", + "\n", + "3.799 IPythonKernel.dispatch_queue ipykernel/kernelbase.py:525\n", + "└─ 3.791 IPythonKernel.process_one ipykernel/kernelbase.py:511\n", + " [10 frames hidden] ipykernel, IPython\n", + " 3.680 ZMQInteractiveShell.run_ast_nodes IPython/core/interactiveshell.py:3394\n", + " ├─ 2.176 ../ipykernel_4421/3358887201.py:1\n", + " │ └─ 2.176 GraphOfRelations.construct_gor textgraphs/gor.py:311\n", + " │ ├─ 1.607 IceCreamDebugger.__call__ icecream/icecream.py:204\n", + " │ │ [17 frames hidden] icecream, colorama, ipykernel, thread...\n", + " │ │ 1.078 lock.acquire \n", + " │ └─ 0.566 GraphOfRelations._transformed_triples textgraphs/gor.py:275\n", + " │ └─ 0.563 IceCreamDebugger.__call__ icecream/icecream.py:204\n", + " │ [13 frames hidden] icecream, colorama, ipykernel, zmq, t...\n", + " ├─ 0.866 ../ipykernel_4421/4061275008.py:1\n", + " │ └─ 0.866 GraphOfRelations.seeds textgraphs/gor.py:197\n", + " │ └─ 0.865 IceCreamDebugger.__call__ icecream/icecream.py:204\n", + " │ [42 frames hidden] icecream, inspect, posixpath, ../ipykernel_4421/559531165.py:1\n", + " │ ├─ 0.234 show matplotlib/pyplot.py:482\n", + " │ │ [32 frames hidden] matplotlib, matplotlib_inline, IPytho...\n", + " │ └─ 0.128 GraphOfRelations.render_gor_plt textgraphs/gor.py:522\n", + " │ └─ 0.104 draw_networkx networkx/drawing/nx_pylab.py:127\n", + " │ [6 frames hidden] networkx, matplotlib\n", + " ├─ 0.197 ../ipykernel_4421/1169542473.py:1\n", + " │ └─ 0.197 IceCreamDebugger.__call__ icecream/icecream.py:204\n", + " │ [14 frames hidden] icecream, colorama, ipykernel, thread...\n", + " └─ 0.041 ../ipykernel_4421/2247466716.py:1\n", + "\n", + "\n" + ] + } + ], + "source": [ + "profiler.print()" + ] + }, + { + "cell_type": "markdown", + "id": "c47bcfd2-2bd6-49a5-8f1a-102d90edde39", + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, + "source": [ + "## outro" + ] + }, + { + "cell_type": "markdown", + "id": "68bea4f9-aec2-4b28-8f08-a4034851d066", + "metadata": {}, + "source": [ + "_\\[ more parts are in progress, getting added to this demo \\]_" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.10.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/ex2_0.ipynb b/examples/ex2_0.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..31b5fe7f22c60314f4911b47cafe2c289fd72aec --- /dev/null +++ b/examples/ex2_0.ipynb @@ -0,0 +1,627 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "c32bf0b9-1445-4ede-ae49-7dd63ff3b08e", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:52.002602Z", + "iopub.status.busy": "2024-01-17T01:35:52.001643Z", + "iopub.status.idle": "2024-01-17T01:35:52.021332Z", + "shell.execute_reply": "2024-01-17T01:35:52.018806Z", + "shell.execute_reply.started": "2024-01-17T01:35:52.002544Z" + } + }, + "outputs": [], + "source": [ + "# for use in tutorial and development; do not include this `sys.path` change in production:\n", + "import sys ; sys.path.insert(0, \"../\")" + ] + }, + { + "cell_type": "markdown", + "id": "c8ff5d81-110c-42ae-8aa7-ed4fffea40c6", + "metadata": {}, + "source": [ + "# bootstrap the _lemma graph_ with RDF triples" + ] + }, + { + "cell_type": "markdown", + "id": "1e847d0a-bc6c-470a-9fef-620ebbdbbbc3", + "metadata": {}, + "source": [ + "Show how to bootstrap definitions in a _lemma graph_ by loading RDF, e.g., for synonyms." + ] + }, + { + "cell_type": "markdown", + "id": "61d8d39a-23e4-48e7-b8f4-0dd724ccf586", + "metadata": {}, + "source": [ + "## environment" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "22489527-2ad5-4e3c-be23-f511e6bcf69f", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:52.030355Z", + "iopub.status.busy": "2024-01-17T01:35:52.029702Z", + "iopub.status.idle": "2024-01-17T01:35:59.577245Z", + "shell.execute_reply": "2024-01-17T01:35:59.576046Z", + "shell.execute_reply.started": "2024-01-17T01:35:52.030319Z" + }, + "scrolled": true + }, + "outputs": [], + "source": [ + "from icecream import ic\n", + "from pyinstrument import Profiler\n", + "import pyvis\n", + "\n", + "import textgraphs" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "438f5775-487b-493e-a172-59b652b94955", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:59.579567Z", + "iopub.status.busy": "2024-01-17T01:35:59.579060Z", + "iopub.status.idle": "2024-01-17T01:35:59.603599Z", + "shell.execute_reply": "2024-01-17T01:35:59.602072Z", + "shell.execute_reply.started": "2024-01-17T01:35:59.579536Z" + } + }, + "outputs": [], + "source": [ + "%load_ext watermark" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "adc052dd-5cca-4d11-b543-3f0999f4f883", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:59.605959Z", + "iopub.status.busy": "2024-01-17T01:35:59.605459Z", + "iopub.status.idle": "2024-01-17T01:35:59.655730Z", + "shell.execute_reply": "2024-01-17T01:35:59.654417Z", + "shell.execute_reply.started": "2024-01-17T01:35:59.605924Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Last updated: 2024-01-16T17:35:59.608787-08:00\n", + "\n", + "Python implementation: CPython\n", + "Python version : 3.10.11\n", + "IPython version : 8.20.0\n", + "\n", + "Compiler : Clang 13.0.0 (clang-1300.0.29.30)\n", + "OS : Darwin\n", + "Release : 21.6.0\n", + "Machine : x86_64\n", + "Processor : i386\n", + "CPU cores : 8\n", + "Architecture: 64bit\n", + "\n" + ] + } + ], + "source": [ + "%watermark" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6e4618da-daf9-44c9-adbb-e5781dba5504", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:59.658604Z", + "iopub.status.busy": "2024-01-17T01:35:59.658083Z", + "iopub.status.idle": "2024-01-17T01:35:59.692941Z", + "shell.execute_reply": "2024-01-17T01:35:59.684789Z", + "shell.execute_reply.started": "2024-01-17T01:35:59.658572Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "pyvis : 0.3.2\n", + "textgraphs: 0.5.0\n", + "sys : 3.10.11 (v3.10.11:7d4cc5aa85, Apr 4 2023, 19:05:19) [Clang 13.0.0 (clang-1300.0.29.30)]\n", + "\n" + ] + } + ], + "source": [ + "%watermark --iversions" + ] + }, + { + "cell_type": "markdown", + "id": "23cefb5b-6ee7-4c33-8f82-a526cb9125d8", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-15T00:46:26.663615Z", + "iopub.status.busy": "2024-01-15T00:46:26.662220Z", + "iopub.status.idle": "2024-01-15T00:46:26.673766Z", + "shell.execute_reply": "2024-01-15T00:46:26.672702Z", + "shell.execute_reply.started": "2024-01-15T00:46:26.663477Z" + } + }, + "source": [ + "## load the bootstrap definitions" + ] + }, + { + "cell_type": "markdown", + "id": "89da700d-1e7f-4b24-901f-a36db8525add", + "metadata": {}, + "source": [ + "Define the bootstrap RDF triples in N3/Turtle format: we define an entity `Werner` as a synonym for `Werner Herzog` by using the [`skos:broader`](https://www.w3.org/TR/skos-reference/#semantic-relations) relation. Keep in mind that this entity may also refer to other Werners..." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "e2412f6c-2c60-40d7-95f5-7bd281d522e7", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:59.695180Z", + "iopub.status.busy": "2024-01-17T01:35:59.694887Z", + "iopub.status.idle": "2024-01-17T01:35:59.711557Z", + "shell.execute_reply": "2024-01-17T01:35:59.704654Z", + "shell.execute_reply.started": "2024-01-17T01:35:59.695127Z" + } + }, + "outputs": [], + "source": [ + "TTL_STR: str = \"\"\"\n", + "@base .\n", + "@prefix dbo: .\n", + "@prefix skos: .\n", + "\n", + " a dbo:Person ;\n", + " skos:prefLabel \"Werner\"@en .\n", + "\n", + " a dbo:Person ;\n", + " skos:prefLabel \"Werner Herzog\"@en.\n", + "\n", + "dbo:Person skos:definition \"People, including fictional\"@en ;\n", + " skos:prefLabel \"person\"@en .\n", + "\n", + " skos:broader .\n", + "\"\"\"" + ] + }, + { + "cell_type": "markdown", + "id": "7c567afd-2f44-4391-899a-da6aba3d222e", + "metadata": {}, + "source": [ + "Provide the source text" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "630430c5-21dc-4897-9a4b-3b01baf3de17", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:59.718153Z", + "iopub.status.busy": "2024-01-17T01:35:59.717788Z", + "iopub.status.idle": "2024-01-17T01:35:59.734747Z", + "shell.execute_reply": "2024-01-17T01:35:59.732341Z", + "shell.execute_reply.started": "2024-01-17T01:35:59.718117Z" + } + }, + "outputs": [], + "source": [ + "SRC_TEXT: str = \"\"\" \n", + "Werner Herzog is a remarkable filmmaker and an intellectual originally from Germany, the son of Dietrich Herzog.\n", + "After the war, Werner fled to America to become famous.\n", + "\"\"\"" + ] + }, + { + "cell_type": "markdown", + "id": "01152885-f301-49b1-ab61-f5b19d81c036", + "metadata": {}, + "source": [ + "set up the statistical stack profiling" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "2a289117-301d-4027-ae1b-200201fb5f93", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:59.738759Z", + "iopub.status.busy": "2024-01-17T01:35:59.737750Z", + "iopub.status.idle": "2024-01-17T01:35:59.745742Z", + "shell.execute_reply": "2024-01-17T01:35:59.744107Z", + "shell.execute_reply.started": "2024-01-17T01:35:59.738713Z" + } + }, + "outputs": [], + "source": [ + "profiler: Profiler = Profiler()\n", + "profiler.start()" + ] + }, + { + "cell_type": "markdown", + "id": "bf9d4f99-b82b-4d11-a9a4-31d0337f4aa8", + "metadata": {}, + "source": [ + "set up the `TextGraphs` pipeline" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "da6fcb0f-b2ac-4f74-af39-2c129c750cab", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:35:59.749862Z", + "iopub.status.busy": "2024-01-17T01:35:59.749122Z", + "iopub.status.idle": "2024-01-17T01:36:03.042323Z", + "shell.execute_reply": "2024-01-17T01:36:03.040676Z", + "shell.execute_reply.started": "2024-01-17T01:35:59.749790Z" + } + }, + "outputs": [], + "source": [ + "tg: textgraphs.TextGraphs = textgraphs.TextGraphs(\n", + " factory = textgraphs.PipelineFactory(\n", + " kg = textgraphs.KGWikiMedia(\n", + " spotlight_api = textgraphs.DBPEDIA_SPOTLIGHT_API,\n", + " dbpedia_search_api = textgraphs.DBPEDIA_SEARCH_API,\n", + " dbpedia_sparql_api = textgraphs.DBPEDIA_SPARQL_API,\n", + " \t\twikidata_api = textgraphs.WIKIDATA_API,\n", + " min_alias = textgraphs.DBPEDIA_MIN_ALIAS,\n", + " min_similarity = textgraphs.DBPEDIA_MIN_SIM,\n", + " ),\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "e6f98bbc-6954-4e39-b5d6-f726816bd5c7", + "metadata": {}, + "source": [ + "load the bootstrap definitions" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "321a9a90-ae80-47d7-b392-020b06bd3066", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:36:03.044027Z", + "iopub.status.busy": "2024-01-17T01:36:03.043746Z", + "iopub.status.idle": "2024-01-17T01:36:03.071058Z", + "shell.execute_reply": "2024-01-17T01:36:03.070258Z", + "shell.execute_reply.started": "2024-01-17T01:36:03.043990Z" + } + }, + "outputs": [], + "source": [ + "tg.load_bootstrap_ttl(\n", + " TTL_STR,\n", + " debug = False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "1db1fe56-52fe-4a01-9776-82908444dd6c", + "metadata": {}, + "source": [ + "parse the input text" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "f7f6665e-19da-4a25-a405-adbb5dfb3e88", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:36:03.072882Z", + "iopub.status.busy": "2024-01-17T01:36:03.072607Z", + "iopub.status.idle": "2024-01-17T01:36:03.751536Z", + "shell.execute_reply": "2024-01-17T01:36:03.750042Z", + "shell.execute_reply.started": "2024-01-17T01:36:03.072843Z" + } + }, + "outputs": [], + "source": [ + "pipe: textgraphs.Pipeline = tg.create_pipeline(\n", + " SRC_TEXT.strip(),\n", + ")\n", + "\n", + "tg.collect_graph_elements(\n", + " pipe,\n", + " debug = False,\n", + ")\n", + "\n", + "tg.construct_lemma_graph(\n", + " debug = False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "3143955c-446a-4e6c-834c-583ab173f446", + "metadata": {}, + "source": [ + "## visualize the lemma graph" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "05b409af-14df-4158-9709-ffe2d79e864b", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:36:03.762865Z", + "iopub.status.busy": "2024-01-17T01:36:03.762378Z", + "iopub.status.idle": "2024-01-17T01:36:03.773217Z", + "shell.execute_reply": "2024-01-17T01:36:03.769536Z", + "shell.execute_reply.started": "2024-01-17T01:36:03.762817Z" + }, + "scrolled": true + }, + "outputs": [], + "source": [ + "render: textgraphs.RenderPyVis = tg.create_render()\n", + "\n", + "pv_graph: pyvis.network.Network = render.render_lemma_graph(\n", + " debug = False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "7b5d3e88-6669-4df1-a20a-587cc6a7db12", + "metadata": {}, + "source": [ + "initialize the layout parameters" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "b212f5ed-03d6-439f-92ae-f2cbedb18609", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:36:03.776399Z", + "iopub.status.busy": "2024-01-17T01:36:03.775428Z", + "iopub.status.idle": "2024-01-17T01:36:03.784525Z", + "shell.execute_reply": "2024-01-17T01:36:03.783464Z", + "shell.execute_reply.started": "2024-01-17T01:36:03.776310Z" + } + }, + "outputs": [], + "source": [ + "pv_graph.force_atlas_2based(\n", + " gravity = -38,\n", + " central_gravity = 0.01,\n", + " spring_length = 231,\n", + " spring_strength = 0.7,\n", + " damping = 0.8,\n", + " overlap = 0,\n", + ")\n", + "\n", + "pv_graph.show_buttons(filter_ = [ \"physics\" ])\n", + "pv_graph.toggle_physics(True)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "2f952a7c-3130-49c9-b659-fb941e9e0bfe", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:36:03.788862Z", + "iopub.status.busy": "2024-01-17T01:36:03.787641Z", + "iopub.status.idle": "2024-01-17T01:36:03.848366Z", + "shell.execute_reply": "2024-01-17T01:36:03.847499Z", + "shell.execute_reply.started": "2024-01-17T01:36:03.788773Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "tmp.fig04.html\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pv_graph.prep_notebook()\n", + "pv_graph.show(\"tmp.fig04.html\")" + ] + }, + { + "cell_type": "markdown", + "id": "e57d42a8-4414-4f27-9817-b9339e65346f", + "metadata": {}, + "source": [ + "Notice how the `Werner` and `Werner Herzog` nodes are now linked? This synonym from the bootstrap definitions above provided means to link more portions of the _lemma graph_ than the demo in `ex0_0` with the same input text." + ] + }, + { + "cell_type": "markdown", + "id": "ff49fe28-e75f-4590-8b87-0d8962928cba", + "metadata": {}, + "source": [ + "## statistical stack profile instrumentation" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "af4ecb06-370f-4077-9899-29a1673e4768", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:36:03.849937Z", + "iopub.status.busy": "2024-01-17T01:36:03.849635Z", + "iopub.status.idle": "2024-01-17T01:36:03.856645Z", + "shell.execute_reply": "2024-01-17T01:36:03.855799Z", + "shell.execute_reply.started": "2024-01-17T01:36:03.849877Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "profiler.stop()" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "d5ac2ce6-15b1-41ad-8215-8a5f76036cf1", + "metadata": { + "execution": { + "iopub.execute_input": "2024-01-17T01:36:03.857987Z", + "iopub.status.busy": "2024-01-17T01:36:03.857704Z", + "iopub.status.idle": "2024-01-17T01:36:04.615855Z", + "shell.execute_reply": "2024-01-17T01:36:04.615084Z", + "shell.execute_reply.started": "2024-01-17T01:36:03.857962Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + " _ ._ __/__ _ _ _ _ _/_ Recorded: 17:35:59 Samples: 2846\n", + " /_//_/// /_\\ / //_// / //_'/ // Duration: 4.111 CPU time: 3.294\n", + "/ _/ v4.6.1\n", + "\n", + "Program: /Users/paco/src/textgraphs/venv/lib/python3.10/site-packages/ipykernel_launcher.py -f /Users/paco/Library/Jupyter/runtime/kernel-4365d4ba-2d4d-4d4b-83e2-eb5ef8abfe26.json\n", + "\n", + "4.111 IPythonKernel.dispatch_shell ipykernel/kernelbase.py:378\n", + "└─ 4.075 IPythonKernel.execute_request ipykernel/kernelbase.py:721\n", + " [9 frames hidden] ipykernel, IPython\n", + " 3.995 ZMQInteractiveShell.run_ast_nodes IPython/core/interactiveshell.py:3394\n", + " ├─ 3.250 ../ipykernel_4433/1372904243.py:1\n", + " │ └─ 3.248 PipelineFactory.__init__ textgraphs/pipe.py:434\n", + " │ └─ 3.232 load spacy/__init__.py:27\n", + " │ [98 frames hidden] spacy, en_core_web_sm, catalogue, imp...\n", + " │ 0.496 tokenizer_factory spacy/language.py:110\n", + " │ └─ 0.108 _validate_special_case spacy/tokenizer.pyx:573\n", + " │ 0.439 spacy/language.py:2170\n", + " │ └─ 0.085 _validate_special_case spacy/tokenizer.pyx:573\n", + " ├─ 0.672 ../ipykernel_4433/3257668275.py:1\n", + " │ └─ 0.669 TextGraphs.create_pipeline textgraphs/doc.py:103\n", + " │ └─ 0.669 PipelineFactory.create_pipeline textgraphs/pipe.py:508\n", + " │ └─ 0.669 Pipeline.__init__ textgraphs/pipe.py:216\n", + " │ └─ 0.669 English.__call__ spacy/language.py:1016\n", + " │ [31 frames hidden] spacy, spacy_dbpedia_spotlight, reque...\n", + " └─ 0.055 ../ipykernel_4433/72966960.py:1\n", + " └─ 0.046 Network.prep_notebook pyvis/network.py:552\n", + " [5 frames hidden] pyvis, jinja2\n", + "\n", + "\n" + ] + } + ], + "source": [ + "profiler.print()" + ] + }, + { + "cell_type": "markdown", + "id": "c47bcfd2-2bd6-49a5-8f1a-102d90edde39", + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, + "source": [ + "## outro" + ] + }, + { + "cell_type": "markdown", + "id": "68bea4f9-aec2-4b28-8f08-a4034851d066", + "metadata": {}, + "source": [ + "_\\[ more parts are in progress, getting added to this demo \\]_" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.10.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/fish.py b/examples/fish.py new file mode 100644 index 0000000000000000000000000000000000000000..00fddd29ef58d646e76afd2e118dabe2ad255b47 --- /dev/null +++ b/examples/fish.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +`spacyfishing` entity linking to Wikidata + +""" + +from icecream import ic # pylint: disable=E0401 +import spacy # pylint: disable=E0401 + + +SRC_TEXT: str = """ +Werner Herzog is a remarkable filmmaker and an intellectual originally from Germany, the son of Dietrich Herzog, although they never spoke after the war. +""" + +nlp = spacy.load( + "en_core_web_sm", + exclude = [ "ner" ], +) + +nlp.add_pipe( + "span_marker", + config = { + "model": "tomaarsen/span-marker-roberta-large-ontonotes5", + }, +) + +nlp.add_pipe( + "entityfishing", + config = { + "api_ef_base": "https://cloud.science-miner.com/nerd/service", + "extra_info": True, + "filter_statements": [ ], + }, +) + +nlp.add_pipe( + "merge_entities", +) + + +doc = nlp(SRC_TEXT.strip()) + +for ent in doc.ents: + ic( + ent.text, + ent.label_, + ent._.nerd_score, + ent._.url_wikidata, + ent._.description, + ent._.other_ids, + ) diff --git a/examples/gen_kg.py b/examples/gen_kg.py new file mode 100644 index 0000000000000000000000000000000000000000..4537dcbae939dff9c8ce0586c9e3be9c88c45d48 --- /dev/null +++ b/examples/gen_kg.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +`replicate` demo from + +""" + +import typing + +import replicate # pylint: disable=E0401 + + +if __name__ == "__main__": + # load `Notus` model: + model: replicate.model.Model = replicate.models.get( + "titocosta/notus-7b-v1", + ) + + version: replicate.version.Version = model.versions.get( + "dbcd2277b32873525e618545e13e64c3ba121b681cbd2b5f0ee7f95325e7a395", + ) + + prompt: str = """ +Sentence: {} +Extract RDF predicate from the sentence in this format: +SUBJECT: +PREDICATE: +OBJECT: + """ + + text: str = """ +Werner Herzog is a German film director, screenwriter, author, actor, and opera director, regarded as a pioneer of New German Cinema. + """ + + output: typing.Iterator[ str ] = replicate.run( + version, + input = { + "prompt": prompt.format(text.strip()).strip(), + }, + ) + + for item in output: + print(item) diff --git a/examples/ingram.json b/examples/ingram.json new file mode 100644 index 0000000000000000000000000000000000000000..9324037df32cda9fbfc85ce3eda9a633a8dbf9a5 --- /dev/null +++ b/examples/ingram.json @@ -0,0 +1,49 @@ +{ + "rels": [ + "Directed", + "Profession", + "ActedIn", + "LivedIn", + "BornIn", + "Nationality" + ], + + "ents": { + "Steven_Spielberg": [ + [ "Profession", "Director" ], + [ "Directed", "Catch_Me_If_Can" ], + [ "Directed", "Saving_Private_Ryan" ] + ], + "Tom_Hanks": [ + [ "ActedIn", "Catch_Me_If_Can" ], + [ "ActedIn", "Saving_Private_Ryan" ], + [ "Profession", "Actor" ] + ], + "Mark_Hamil": [ + [ "Profession", "Actor" ], + [ "ActedIn", "Star_Wars" ], + [ "BornIn", "California" ] + ], + "Brad_Pitt": [ + [ "Nationality", "USA" ], + [ "BornIn", "USA" ], + [ "LivedIn", "California" ] + ], + "Clint_Eastwood": [ + [ "BornIn", "San_Francisco" ], + [ "LivedIn", "San_Francisco" ], + [ "LivedIn", "California" ] + ] + }, + + "scores": [ + [0, 1, 0.22], + [0, 2, 0.50], + [1, 2, 0.33], + [1, 4, 0.11], + [2, 4, 0.11], + [3, 4, 0.81], + [3, 5, 0.11], + [4, 5, 0.36] + ] +} diff --git a/examples/notus.py b/examples/notus.py new file mode 100644 index 0000000000000000000000000000000000000000..d359738952a8b47a28e185cd06668e298b335b16 --- /dev/null +++ b/examples/notus.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Example use of `transformers` from HF model card for `Notus` +""" + +from transformers import pipeline # pylint: disable=E0401 +import torch # pylint: disable=E0401 + + +pipe = pipeline( + "text-generation", + model = "argilla/notus-7b-v1", + torch_dtype = torch.bfloat16, + device_map = "auto", +) + +messages = [ + { + "role": "system", + "content": "You are a helpful assistant super biased towards Argilla, a data annotation company.", # pylint: disable=C0301 + }, + { + "role": "user", + "content": "What's the best data annotation company out there in your opinion?", # pylint: disable=C0301 + }, +] + +prompt = pipe.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) +outputs = pipe(prompt, max_new_tokens=256, do_sample=True, temperature=0.7, top_k=50, top_p=0.95) + +generated_text = outputs[0]["generated_text"] +print(generated_text) diff --git a/examples/sense.py b/examples/sense.py new file mode 100644 index 0000000000000000000000000000000000000000..70d7650ce3adf38fb7654b4c7a7ba94aedfa0c79 --- /dev/null +++ b/examples/sense.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +`sense2vec` demo from + +""" + +from icecream import ic # pylint: disable=E0401 +import spacy # pylint: disable=E0401 + +if __name__ == "__main__": + nlp = spacy.load("en_core_web_sm") + s2v = nlp.add_pipe("sense2vec") + s2v.from_disk("./s2v_old") + + text: str = """ +A sentence about natural language, AI, and NLP. + """ + + doc = nlp(text.strip()) + + for ent in doc.ents: + ic(ent) + + try: + for lemma_tuple, prob in ent._.s2v_most_similar(3): + ic(lemma_tuple, prob) + + freq = ent._.s2v_freq + ic(freq) + except ValueError as ex: + ic(ex) diff --git a/examples/wiki.py b/examples/wiki.py new file mode 100644 index 0000000000000000000000000000000000000000..acdd9824d7dca2f3b12dd659e98040995d11926b --- /dev/null +++ b/examples/wiki.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +`spaCy-entity-linker` demo from + +""" + +from icecream import ic # pylint: disable=E0401 +import spacy # pylint: disable=E0401 +import spacy_entity_linker as sel # pylint: disable=E0401 + + +def link_wikidata ( + doc: spacy.tokens.doc.Doc, + ) -> None: + """ +Run an entity linking classifier for wikidata + """ + classifier = sel.EntityClassifier.EntityClassifier() + + for ent in doc.ents: + print() + ic(ent.text, ent.label_) + + # build a term (a simple span) then identify all + # the candidate entities for it + term: sel.TermCandidate = sel.TermCandidate.TermCandidate(ent) + + candidates: sel.EntityCandidates.EntityCandidates = term.get_entity_candidates() + ic(candidates) + + if len(candidates) > 0: + # select the best candidate + entity: sel.EntityElement.EntityElement = classifier(candidates) + + ic(entity.__dict__) + ic(entity.get_sub_entities(limit=10)) + ic(entity.get_super_entities(limit=10)) + + +if __name__ == "__main__": + SRC_TEXT: str = """ +Werner Herzog is a remarkable filmmaker and an intellectual originally from Germany, the son of Dietrich Herzog. +After the war, Werner fled to America to become famous. +""" + + # initialize language model + nlp: spacy.Language = spacy.load("en_core_web_sm") + sample_doc: spacy.tokens.doc.Doc = nlp(SRC_TEXT.strip()) + + link_wikidata(sample_doc) diff --git a/gor.py b/gor.py new file mode 100644 index 0000000000000000000000000000000000000000..eefefa94cbd51584454d0a1cf74fde834f01e905 --- /dev/null +++ b/gor.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Experiment with deserializing a node-link graph, +then transform it into a _graph of relations_ +""" + +import pathlib +import typing + +from icecream import ic # pylint: disable=E0401 +import matplotlib.pyplot as plt # pylint: disable=E0401 +import pandas as pd # pylint: disable=E0401 + +import textgraphs + + +if __name__ == "__main__": + graph: textgraphs.GraphOfRelations = textgraphs.GraphOfRelations( + textgraphs.SimpleGraph() + ) + + graph.load_ingram( + pathlib.Path("examples/ingram.json"), + debug = False, # True + ) + + graph.seeds( + debug = True, # False + ) + + graph.trace_source_graph() + + graph.construct_gor( + debug = True, # False + ) + + _scores: typing.Dict[ tuple, float ] = graph.get_affinity_scores( + debug = True, # False + ) + + df: pd.DataFrame = graph.trace_metrics(_scores) + ic(df) + + graph.render_gor_plt(_scores) + plt.show() diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000000000000000000000000000000000000..030cb3759b311493722cae5f82a8ff900e221b6a --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,82 @@ +site_name: TextGraphs +site_description: Explore uses of large language models (LLMs) in semi-automated knowledge graph (KG) construction from unstructured text sources, with human-in-the-loop (HITL) affordances to incorporate guidance from domain experts. +site_url: https://github.com/DerwenAI/textgraphs +site_author: TextGraphs contributors, with Derwen, Inc. + +repo_url: https://github.com/DerwenAI/textgraphs +repo_name: DerwenAI/textgraphs + +copyright: Source code and documentation are licensed under an MIT License; Copyright © 2023-2024 Derwen, Inc. + +nav: + - Home: + - Overview: index.md + - Getting Started: start.md + + - Project Report (DRAFT): + - Introduction: + - Abstract: abstract.md + - Project Objectives: objectives.md + - Natural Language Processing: nlp.md + - Graph Representation: graph.md + - Related Work: related.md + - Definitions: + - Lemma Graph: lemma.md + - Probabilistic Graph Features: prob.md + - Graph Levels of Detail: glod.md + - Topological Transforms: topo.md + - Methods: + - Technical Approach: methods.md + - Implementation Details: details.md + - Leveraging Domain Expertise: hitl.md + - Data-First Strategy: strategy.md + - Conclusions: conclude.md + - Research Guides: + - Acknowledgements: ack.md + - Bibliography: biblio.md + - Glossary: glossary.md + - Appendix: + - ML OSS Evaluation Rubric: rubric.md + + - Tutorial: + - Syllabus: tutorial.md + - Example Usage: ex0_0.md + - Replicating "InGram": ex1_0.md + - Using Bootstrap Definitions: ex2_0.md + + - Technical Reference: + - Build Instructions: build.md + - Package Reference: ref.md + +theme: + name: material + icon: + repo: fontawesome/brands/github + favicon: assets/favicon.png + logo: assets/logo.png + features: + - navigation.instant + +plugins: + - mknotebooks + - git-revision-date + +extra_css: + - stylesheets/extra.css + +extra_javascript: + - javascripts/config.js + - https://polyfill.io/v3/polyfill.min.js?features=es6 + - https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js + +use_directory_urls: true + +markdown_extensions: + - admonition + - codehilite + - footnotes + - pymdownx.arithmatex: + generic: true + - toc: + toc_depth: 3 + permalink: true diff --git a/pkg_doc.cfg b/pkg_doc.cfg new file mode 100644 index 0000000000000000000000000000000000000000..494b82195432cd0603fe34926f282e1aa832fb3e --- /dev/null +++ b/pkg_doc.cfg @@ -0,0 +1,34 @@ +{ + "src_url": "https://github.com/DerwenAI/textgraphs/blob/main", + + "module": "textgraphs", + + "classes": [ + "TextGraphs", + "SimpleGraph", + "Node", + "Edge", + "EnumBase", + "NodeEnum", + "RelEnum", + "PipelineFactory", + "Pipeline", + "Component", + "NERSpanMarker", + "NounChunk", + "KnowledgeGraph", + "KGSearchHit", + "KGWikiMedia", + "LinkedEntity", + "InferRel", + "InferRel_OpenNRE", + "InferRel_Rebel", + "RenderPyVis", + "NodeStyle", + "GraphOfRelations", + "TransArc", + "RelDir", + "SheafSeed", + "Affinity" + ] +} diff --git a/pkg_doc.py b/pkg_doc.py new file mode 100755 index 0000000000000000000000000000000000000000..e7eb3efc601ebe5d080978ab2e65d1e13d1a1284 --- /dev/null +++ b/pkg_doc.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Generate the `apidocs` markdown needed for the package reference. +""" + +import importlib +import json +import sys + +import pyfixdoc + + +###################################################################### +## main entry point + +if __name__ == "__main__": + ref_md_file: str = sys.argv[1] + + # NB: `inspect` is picky about paths and current working directory + # this only works if run from the top-level directory of the repo + sys.path.insert(0, "../") + + with open("pkg_doc.cfg", "r", encoding="utf-8") as fp: + config: dict = json.load(fp) + + importlib.import_module(config["module"]) + + pkg_doc: pyfixdoc.PackageDoc = pyfixdoc.PackageDoc( + config["module"], + config["src_url"], + config["classes"], + ) + + # NB: uncomment to analyze/troubleshoot the results of `inspect` + #pkg_doc.show_all_elements(); sys.exit(0) + + # build the apidocs markdown + pkg_doc.build() + + # output the apidocs markdown + pkg_doc.write_markdown(ref_md_file) diff --git a/pyfixdoc.py b/pyfixdoc.py new file mode 100755 index 0000000000000000000000000000000000000000..20e47542da101dae6be8eac8af858bfdf485164e --- /dev/null +++ b/pyfixdoc.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pylint: disable=C0103,C0114,C0116,C0209,C0301,E0401,R0914,W0611,W0613,W0621,W0702,W1308,W1514 + +""" +Implementation of apidoc-ish documentation which generates actual +Markdown that can be used with MkDocs, and fits with Diátaxis design +principles for effective documentation. Because the others really +don't. + +In particular, this library... + + * is aware of type annotations (PEP 484, etc.) + * fixes Py version bugs related to `typing` and `inspect` + * handles forward references (prior to Python 3.8) + * links to source lines in a GitHub repo + * provides non-bassackwards parameter descriptions (eyes on *you*, GOOG) + * does not require use of a plugin + * uses `icecream` for debugging + * exists b/c Sphinx really sucks + +You're welcome. +""" + +import inspect +import os +import re +import sys +import traceback +import typing + +from icecream import ic # type: ignore # pylint: disable=E0401 + + +class PackageDoc: + """ +Because there doesn't appear to be any other Markdown-friendly +docstring support in Python. + +See also: + + * [PEP 256](https://www.python.org/dev/peps/pep-0256/) + * [`inspect`](https://docs.python.org/3/library/inspect.html) + """ + + PAT_PARAM = re.compile(r"( \S+.*\:\n(?:\S.*\n)+)", re.MULTILINE) + PAT_NAME = re.compile(r"^\s+(.*)\:\n(.*)") + PAT_FWD_REF = re.compile(r"ForwardRef\('(.*)'\)") + + + def __init__ ( + self, + module_name: str, + git_url: str, + class_list: typing.List[str], + ) -> None: + """ +Constructor, to configure a `PackageDoc` object. + + module_name: +name of the Python module + + git_url: +URL for the Git source repository + + class_list: +list of the classes to include in the apidocs + """ + self.module_name = module_name + self.git_url = git_url + self.class_list = class_list + + self.module_obj = sys.modules[self.module_name] + self.md: typing.List[str] = [ + "# Reference: `{}` package".format(self.module_name), + "API by Adnen Kadri from the Noun Project", + ] + + + def show_all_elements ( + self + ) -> None: + """ +Show all possible elements from `inspect` for the given module, for +debugging purposes. + """ + for name, obj in inspect.getmembers(self.module_obj): + for n, o in inspect.getmembers(obj): + ic(name, n, o) + ic(type(o)) + + + def write_markdown ( + self, + path: str, + ) -> None: + """ +Output the apidocs markdown to the given path. + + path: +path for the output file + """ + ic("writing", path) + + with open(path, "w") as f: + for line in self.md: + f.write(line) + f.write("\n") + + + def build ( + self + ) -> None: + """ +Build the apidocs documentation as markdown. + """ + todo_list:typing.Dict[ str, typing.Any] = self.get_todo_list() + + # markdown for top-level module description + self.md.extend(self.get_docstring(self.module_obj)) + + # find and format the class definitions + try: + for class_name in self.class_list: + self.format_class(todo_list, class_name) + except Exception as ex: # pylint: disable=W0718 + print(class_name) + ic(ex) + traceback.print_exc() + sys.exit(-1) + + # format the function definitions and types + self.format_functions() + self.format_types() + + + def get_todo_list ( + self + ) -> typing.Dict[ str, typing.Any]: + """ +Walk the module tree to find class definitions to document. + + returns: +a dictionary of class objects which need apidocs generated + """ + todo_list: typing.Dict[ str, typing.Any] = { + class_name: class_obj + for class_name, class_obj in inspect.getmembers(self.module_obj, inspect.isclass) + if class_name in self.class_list + } + + return todo_list + + + def get_docstring ( # pylint: disable=W0102 + self, + obj, + parse=False, + arg_dict: dict = {}, + ) -> typing.List[str]: + """ +Get the docstring for the given object. + + obj: +class definition for which its docstring will be inspected and parsed + + parse: +flag to parse docstring or use the raw text; defaults to `False` + + arg_dict: +optional dictionary of forward references, if parsed + + returns: +list of lines of markdown + """ + local_md: typing.List[str] = [] + raw_docstring = obj.__doc__ + + if raw_docstring: + docstring = inspect.cleandoc(raw_docstring) + + if parse: + local_md.append(self.parse_method_docstring(docstring, arg_dict)) + else: + local_md.append(docstring) + + local_md.append("\n") + + return local_md + + + def parse_method_docstring ( + self, + docstring: str, + arg_dict: dict, + ) -> str: + """ +Parse the given method docstring. + + docstring: +input docstring to be parsed + + arg_dict: +optional dictionary of forward references + + returns: +parsed/fixed docstring, as markdown + """ + local_md: typing.List[str] = [] + + for chunk in self.PAT_PARAM.split(docstring): + m_param = self.PAT_PARAM.match(chunk) + + if m_param: + param = m_param.group() + m_name = self.PAT_NAME.match(param) + + if m_name: + name = m_name.group(1).strip() + anno = self.fix_fwd_refs(arg_dict[name]) + descrip = m_name.group(2).strip() + + if name == "returns": + local_md.append("\n * *{}* : `{}` \n{}".format(name, anno, descrip)) + elif name == "yields": + local_md.append("\n * *{}* : \n{}".format(name, descrip)) + else: + local_md.append("\n * `{}` : `{}` \n{}".format(name, anno, descrip)) + else: + chunk = chunk.strip() + + if len(chunk) > 0: + local_md.append(chunk) + + return "\n".join(local_md) + + + def fix_fwd_refs ( + self, + anno: str, + ) -> typing.Optional[str]: + """ +Substitute the quoted forward references for a given module class. + + anno: +raw annotated type for the forward reference + + returns: +fixed forward reference, as markdown; or `None` if no annotation is supplied + """ + results: list = [] + + if not anno: + return None + + for term in anno.split(", "): + for chunk in self.PAT_FWD_REF.split(term): + if len(chunk) > 0: + results.append(chunk) + + return ", ".join(results) + + + def document_method ( + self, + path_list: list, + name: str, + obj: typing.Any, + func_kind: str, + ) -> typing.Tuple[int, typing.List[str]]: + """ +Generate apidocs markdown for the given class method. + + path_list: +elements of a class path, as a list + + name: +class method name + + obj: +class method object + + func_kind: +function kind + + returns: +line number, plus apidocs for the method as a list of markdown lines + """ + local_md: typing.List[str] = ["---"] + + # format a header + anchor + frag = ".".join(path_list + [ name ]) + anchor = "#### [`{}` {}](#{})".format(name, func_kind, frag) + local_md.append(anchor) + + # link to source code in Git repo + code = obj.__code__ + line_num = code.co_firstlineno + file = code.co_filename.replace(os.getcwd(), "") + + src_url = "[*\[source\]*]({}{}#L{})\n".format(self.git_url, file, line_num) # pylint: disable=W1401 + local_md.append(src_url) + + # format the callable signature + sig = inspect.signature(obj) + arg_list = self.get_arg_list(sig) + arg_list_str = "{}".format(", ".join([ a[0] for a in arg_list ])) + + local_md.append("```python") + local_md.append("{}({})".format(name, arg_list_str)) + local_md.append("```") + + # include the docstring, with return annotation + arg_dict: dict = { + name.split("=")[0]: anno + for name, anno in arg_list + } + + arg_dict["yields"] = None + + ret = sig.return_annotation + + if ret: + arg_dict["returns"] = self.extract_type_annotation(ret) + + local_md.extend(self.get_docstring(obj, parse=True, arg_dict=arg_dict)) + local_md.append("") + + return line_num, local_md + + + def get_arg_list ( + self, + sig: inspect.Signature, + ) -> list: + """ +Get the argument list for a given method. + + sig: +inspect signature for the method + + returns: +argument list of `(arg_name, type_annotation)` pairs + """ + arg_list: list = [] + + for param in sig.parameters.values(): + #ic(param.name, param.empty, param.default, param.annotation, param.kind) + + if param.name == "self": + pass + else: + if param.kind == inspect.Parameter.VAR_POSITIONAL: + name = "*{}".format(param.name) + elif param.kind == inspect.Parameter.VAR_KEYWORD: + name = "**{}".format(param.name) + elif param.default == inspect.Parameter.empty: + name = param.name + else: + if isinstance(param.default, str): + default_repr = repr(param.default).replace("'", '"') + else: + default_repr = param.default + + name = "{}={}".format(param.name, default_repr) + + anno = self.extract_type_annotation(param.annotation) + arg_list.append((name, anno)) + + return arg_list + + + @classmethod + def extract_type_annotation ( + cls, + sig: inspect.Signature, + ): + """ +Extract the type annotation for a given method, correcting `typing` +formatting problems as needed. + + sig: +inspect signature for the method + + returns: +corrected type annotation + """ + type_name = str(sig) + type_class = sig.__class__.__module__ + + try: + if type_class != "typing": + if type_name.startswith(" typing.List[str]: + """ +Generate apidocs markdown for the given type definition. + + path_list: +elements of a class path, as a list + + name: +type name + + obj: +type object + + returns: +apidocs for the type, as a list of lines of markdown + """ + local_md: typing.List[str] = [] + + # format a header + anchor + frag = ".".join(path_list + [ name ]) + anchor = "#### [`{}` {}](#{})".format(name, "type", frag) + local_md.append(anchor) + + # show type definition + local_md.append("```python") + local_md.append("{} = {}".format(name, obj)) + local_md.append("```") + local_md.append("") + + return local_md + + + @classmethod + def find_line_num ( + cls, + src: typing.Tuple[typing.List[str], int], + member_name: str, + ) -> int: + """ +Corrects for the error in parsing source line numbers of class methods that have decorators: + + + src: +list of source lines for the class being inspected + + member_name: +name of the class member to locate + + returns: +corrected line number of the method definition + """ + correct_line_num = -1 + + for line_num, line in enumerate(src[0]): + tokens = line.strip().split(" ") + + if tokens[0] == "def" and tokens[1] == member_name: + correct_line_num = line_num + + return correct_line_num + + + def format_class ( + self, + todo_list: typing.Dict[ str, typing.Any], + class_name: str, + ) -> None: + """ +Format apidocs as markdown for the given class. + + todo_list: +list of classes to be documented + + class_name: +name of the class to document + """ + self.md.append("## [`{}` class](#{})".format(class_name, class_name)) # pylint: disable=W1308 + + class_obj = todo_list[class_name] + docstring = class_obj.__doc__ + src = inspect.getsourcelines(class_obj) + + if docstring: + # add the raw docstring for a class + self.md.append(docstring) + + obj_md_pos: typing.Dict[int, typing.List[str]] = {} + + for member_name, member_obj in inspect.getmembers(class_obj): + path_list = [self.module_name, class_name] + + if member_name.startswith("__") or not member_name.startswith("_"): + if member_name not in class_obj.__dict__: + # inherited method + continue + + if inspect.isfunction(member_obj): + func_kind = "method" + elif inspect.ismethod(member_obj): + func_kind = "classmethod" + else: + continue + + _, obj_md = self.document_method(path_list, member_name, member_obj, func_kind) + line_num = self.find_line_num(src, member_name) + obj_md_pos[line_num] = obj_md + + for _, obj_md in sorted(obj_md_pos.items()): + self.md.extend(obj_md) + + + def format_functions ( + self + ) -> None: + """ +Walk the module tree, and for each function definition format its +apidocs as markdown. + """ + self.md.append("---") + self.md.append("## [module functions](#{})".format(self.module_name)) + + for func_name, func_obj in inspect.getmembers(self.module_obj, inspect.isfunction): + if not func_name.startswith("_"): + _, obj_md = self.document_method([self.module_name], func_name, func_obj, "function") + self.md.extend(obj_md) + + + def format_types ( + self + ) -> None: + """ +Walk the module tree, and for each type definition format its apidocs +as markdown. + """ + self.md.append("---") + self.md.append("## [module types](#{})".format(self.module_name)) + + for name, obj in inspect.getmembers(self.module_obj): + if obj.__class__.__module__ == "typing": + if not str(obj).startswith("~"): + obj_md = self.document_type([self.module_name], name, obj) + self.md.extend(obj_md) + + +###################################################################### +## test entry point + +if __name__ == "__main__": + pkg_doc = PackageDoc( + "foo", + "http://example.com/", + [], + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..fe950c836328b632e2f582e7894bb35ab7d81892 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,114 @@ +[build-system] + +build-backend = "setuptools.build_meta" + +requires = [ + "setuptools >= 69.0", + "setuptools_scm[toml] >= 6.2", + "wheel >= 0.42", +] + + +[tool.setuptools] + +packages = [ "textgraphs" ] + + +[tool.setuptools_scm] + +# required section; empty contents is fine + + +[project.urls] + +home = "https://huggingface.co/spaces/DerwenAI/textgraphs" +docs = "https://derwen.ai/docs/txg/" +code = "https://github.com/DerwenAI/textgraphs" +PyPi = "https://pypi.org/project/textgraphs/" +DOI = "https://zenodo.org/doi/10.5281/zenodo.10431783" + + +[project] + +name = "textgraphs" +dynamic = ["version"] + +authors = [ + { name = "derwen.ai", email = "info@derwen.ai" }, +] + +description = "TextGraphs + LLMs + graph ML for entity extraction, linking, ranking, and constructing a lemma graph" +readme = "README.md" +license = { file = "LICENSE" } + +requires-python = ">=3.10" + +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Scientific/Engineering :: Human Machine Interfaces", + "Topic :: Scientific/Engineering :: Information Analysis", + "Topic :: Scientific/Engineering :: Visualization", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Text Processing :: Indexing", + "Topic :: Text Processing :: Linguistic", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3.10", +] + +dependencies = [ + "beautifulsoup4 >= 4.12", + "GitPython >= 3.1", + "icecream >= 2.1", + "markdown2 >= 2.4", + "matplotlib >= 3.8", + "networkx >= 3.2", + "open-nre >= 0.1.1", + "pulp >= 2.7", + "pyinstrument >= 4.6", + "pyvis >= 0.3", + "qwikidata >= 0.4", + "rdflib >= 7.0", + "spacy >= 3.7", + "spacy-dbpedia-spotlight >= 0.2.6", + "span_marker >= 1.5", + "transformers >= 4.35", + "wordcloud >= 1.9", +] + + +[project.optional-dependencies] + +dev = [ + "build >= 1.0", + "Flask >= 3.0", + "mkdocs-git-revision-date-plugin >= 0.3", + "mkdocs-material >= 9.5", + "mknotebooks >= 0.8", + "pre-commit >= 3.5", + "selenium >= 4.16", + "twine >= 4.0", +] + +test = [ + "pytest >= 7.4", + "deepdiff >= 6.7", +] + +demo = [ + "ipywidgets >= 8.1", + "jupyterlab_execute_time >= 3.1", + "jupyterlab >= 4.0", + "kuzu >= 0.1", + "sense2vec >= 2.0", + "spacy-entity-linker >= 1.0", + "streamlit < 1.29", + "watermark >= 2.4", +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000000000000000000000000000000000000..6c7d1a6be500780dcf3e4631ec323e99affb5606 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,18 @@ +build >= 1.0 +deepdiff >= 6.7 +Flask >= 3.0 +ipywidgets >= 8.1 +mkdocs-git-revision-date-plugin >= 0.3 +mkdocs-material >= 9.5 +mknotebooks >= 0.8 +jupyterlab >= 4.0 +jupyterlab_execute_time >= 3.1 +kuzu >= 0.1 +pre-commit >= 3.5 +pytest >= 7.4 +selenium >= 4.16 +sense2vec >= 2.0 +spacy-entity-linker >= 1.0 +streamlit < 1.29 +twine >= 4.0 +watermark >= 2.4 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..ee61afbbf44cc693ce96f44e604999b7d602051e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +# this is required and used for specifically HF Spaces deployment, not PyPi + +spacy >= 3.7 +https://huggingface.co/spacy/en_core_web_sm/resolve/main/en_core_web_sm-any-py3-none-any.whl + +beautifulsoup4 >= 4.12 +GitPython >= 3.1 +icecream >= 2.1 +markdown2 >= 2.4 +matplotlib >= 3.8 +networkx >= 3.2 +open-nre >= 0.1.1 +pulp >= 2.7 +pyinstrument >= 4.6 +pyvis >= 0.3 +qwikidata >= 0.4 +rdflib >= 7.0 +spacy-dbpedia-spotlight >= 0.2.6 +span_marker >= 1.5 +transformers >= 4.35 +wordcloud >= 1.9 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..a97e11841a2cf311b0877bafad783697ec9a1798 --- /dev/null +++ b/setup.py @@ -0,0 +1,7 @@ +""" +PyPi legacy support +https://setuptools.pypa.io/en/latest/userguide/quickstart.html#setup-py +""" + +from setuptools import setup +setup() diff --git a/tests/test_extract.py b/tests/test_extract.py new file mode 100644 index 0000000000000000000000000000000000000000..8a2b9f874a1c279b8a1653b57ae2210d9ca8b69c --- /dev/null +++ b/tests/test_extract.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +unit tests: + + * extract the top-k entities from a raw text + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +from os.path import abspath, dirname +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(dirname(dirname(abspath(__file__)))))) +import textgraphs # pylint: disable=C0413 + + +def test_extract_herzog ( + ) -> None: + """ +Run an extract with the Werner Herzog blurb. + """ + text: str = """ +Werner Herzog is a remarkable filmmaker and intellectual originally from Germany, the son of Dietrich Herzog. + """ + + tg: textgraphs.TextGraphs = textgraphs.TextGraphs( # pylint: disable=C0103 + factory = textgraphs.PipelineFactory(), + ) + + pipe: textgraphs.Pipeline = tg.create_pipeline( + text.strip(), + ) + + tg.collect_graph_elements( + pipe, + debug = False, + ) + + tg.perform_entity_linking( + pipe, + debug = False, + ) + + tg.construct_lemma_graph( + debug = False, + ) + + tg.calc_phrase_ranks( + debug = False, + ) + + results: list = [ + ( row["text"], row["pos"], ) + for _, row in tg.get_phrases_as_df().iterrows() + ] + + # top-k, k=4 + results = results[:4] + + expects: list = [ + ("Germany", "PROPN"), + ("Werner Herzog", "PROPN"), + ("Dietrich Herzog", "PROPN"), + ] + + for pair in expects: + assert pair in results + + +if __name__ == "__main__": + test_extract_herzog() diff --git a/tests/test_load.py b/tests/test_load.py new file mode 100644 index 0000000000000000000000000000000000000000..2604aba5e16bb76f85a4b85f2eaab966ee387402 --- /dev/null +++ b/tests/test_load.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +unit tests: + + * serialization and deserialization + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +from os.path import abspath, dirname +import json +import pathlib +import sys + +import deepdiff # pylint: disable=E0401 + +sys.path.insert(0, str(pathlib.Path(dirname(dirname(abspath(__file__)))))) +import textgraphs # pylint: disable=C0413 + + +def test_load_minimal ( + *, + debug: bool = False, + ) -> None: + """ +Construct a _lemma graph_ from a minimal example, then compare +serialized and deserialized data to ensure no fields get corrupted +in the conversions. + """ + text: str = """ +See Spot run. + """ + + tg: textgraphs.TextGraphs = textgraphs.TextGraphs() # pylint: disable=C0103 + pipe: textgraphs.Pipeline = tg.create_pipeline(text.strip()) + + # serialize into node-link format + tg.collect_graph_elements(pipe) + tg.construct_lemma_graph() + tg.calc_phrase_ranks() + + json_str: str = tg.dump_lemma_graph() + exp_graph = json.loads(json_str) + + # deserialize from node-link format + tg = textgraphs.TextGraphs() # pylint: disable=C0103 + tg.load_lemma_graph(json_str) + tg.construct_lemma_graph() + + obs_graph: dict = json.loads(tg.dump_lemma_graph()) + + if debug: + print(obs_graph) + + # compare + diff: deepdiff.diff.DeepDiff = deepdiff.DeepDiff(exp_graph, obs_graph) + + if debug: + print(diff) + + if len(diff) > 0: + print(json.dumps(json.loads(diff.to_json()), indent = 2)) + + assert len(diff) == 0 + + +if __name__ == "__main__": + test_load_minimal(debug = True) diff --git a/textgraphs/__init__.py b/textgraphs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0423dc09bc224b4b19b87029bce261c8ce2a1d43 --- /dev/null +++ b/textgraphs/__init__.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Package definitions for the `TextGraphs` library. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +from .defaults import DBPEDIA_MIN_ALIAS, DBPEDIA_MIN_SIM, \ + DBPEDIA_SEARCH_API, DBPEDIA_SPARQL_API, DBPEDIA_SPOTLIGHT_API, \ + FISHING_API, MAX_SKIP, MREBEL_MODEL, \ + NER_MODEL, OPENNRE_MIN_PROB, OPENNRE_MODEL, \ + PAGERANK_ALPHA, SPACY_MODEL, WIKIDATA_API + +from .doc import TextGraphs + +from .elem import Edge, KGSearchHit, LinkedEntity, Node, NodeEnum, NounChunk, RelEnum + +from .gor import Affinity, GraphOfRelations, RelDir, SheafSeed, TransArc + +from .graph import SimpleGraph + +from .kg import KGWikiMedia + +from .ner import NERSpanMarker + +from .pipe import Component, InferRel, KnowledgeGraph, Pipeline, PipelineFactory + +from .rel import InferRel_OpenNRE, InferRel_Rebel + +from .util import EnumBase, \ + calc_quantile_bins, root_mean_square, stripe_column + +from .version import get_repo_version, \ + __version__, __version_major__, __version_minor__, __version_patch__ + +from .vis import NodeStyle, RenderPyVis + + +__release__ = __version__ + +__title__ = "TextGraphs: raw texts, LLMs, and KGs, oh my!" + +__description__ = "TextGraphs + LLMs + graph ML for entity extraction, linking, ranking, and constructing a lemma graph" # pylint: disable=C0301 + +__copyright__ = "2023-2024, Derwen, Inc." + +__author__ = """\n""".join([ + "derwen.ai " +]) diff --git a/textgraphs/defaults.py b/textgraphs/defaults.py new file mode 100644 index 0000000000000000000000000000000000000000..73c5af83157114df9c758b31bf0b323f005a71f4 --- /dev/null +++ b/textgraphs/defaults.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Default settings for the `TextGraphs` library. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +import spacy_dbpedia_spotlight # pylint: disable=E0401 + + +DBPEDIA_MIN_ALIAS: float = 0.8 +DBPEDIA_MIN_SIM: float = 0.9 + +DBPEDIA_SEARCH_API: str = "https://lookup.dbpedia.org/api/search" +DBPEDIA_SPARQL_API: str = "https://dbpedia.org/sparql" +DBPEDIA_SPOTLIGHT_API: str = f"{spacy_dbpedia_spotlight.EntityLinker.base_url}/en" + +FISHING_API: str = "https://cloud.science-miner.com/nerd/service" + +MAX_SKIP: int = 11 + +MREBEL_MODEL: str = "Babelscape/mrebel-large" + +NER_MODEL: str = "tomaarsen/span-marker-roberta-large-ontonotes5" + +OPENNRE_MIN_PROB: float = 0.9 +OPENNRE_MODEL: str = "wiki80_cnn_softmax" + +PAGERANK_ALPHA: float = 0.85 + +SPACY_MODEL: str = "en_core_web_sm" + +WIKIDATA_API: str = "https://www.wikidata.org/w/api.php" diff --git a/textgraphs/doc.py b/textgraphs/doc.py new file mode 100644 index 0000000000000000000000000000000000000000..077840c1c70563a85363c5d0e3fac0fdb5d654ce --- /dev/null +++ b/textgraphs/doc.py @@ -0,0 +1,1353 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pylint: disable=C0302,R0801 + +""" +Implementation of an LLM-augmented `textgraph` algorithm for +constructing a _lemma graph_ from raw, unstructured text source. +The results provide elements for semi-automated construction or +augmentation of a _knowledge graph_. + +This class maintains the state of a graph. Updates get applied by +running methods on `Pipeline` objects, typically per paragraph. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +from collections import defaultdict +import asyncio +import csv +import logging +import os +import pathlib +import re +import shutil +import sys +import tempfile +import typing +import zipfile + +from icecream import ic # pylint: disable=E0401 +import networkx as nx # pylint: disable=E0401 +import numpy as np # pylint: disable=E0401 +import pandas as pd # pylint: disable=E0401 +import pulp # pylint: disable=E0401 +import rdflib # pylint: disable=E0401 +import spacy # pylint: disable=E0401 +import transformers # pylint: disable=E0401 +import urllib3 # pylint: disable=E0401 + +from .defaults import PAGERANK_ALPHA +from .elem import Edge, Node, NodeEnum, RelEnum +from .graph import SimpleGraph +from .pipe import Pipeline, PipelineFactory +from .util import calc_quantile_bins, root_mean_square, stripe_column +from .vis import RenderPyVis + + +###################################################################### +## repair the libraries which are borked: + +# workaround: determine whether this is loading into a Jupyter +# notebook, to allow for `tqdm` progress bars +if "ipykernel" in sys.modules: + from tqdm.notebook import tqdm # pylint: disable=E0401,W0611 +else: + from tqdm import tqdm # pylint: disable=E0401 + +# override: HF `transformers` and `tokenizers` have noisy logging +transformers.logging.set_verbosity_error() +os.environ["TOKENIZERS_PARALLELISM"] = "0" + +# override: `OpenNRE` uses `word2vec` which has noisy logging +logging.disable(logging.INFO) + +# override: WikidMedia and others allow their SSL certs to expire +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +###################################################################### +## class definitions + +class TextGraphs (SimpleGraph): + """ +Construct a _lemma graph_ from the unstructured text source, +then extract ranked phrases using a `textgraph` algorithm. + """ + IRI_BASE: str = "https://github.com/DerwenAI/textgraphs/ns/" + + + def __init__ ( + self, + *, + factory: typing.Optional[ PipelineFactory ] = None, + iri_base: str = IRI_BASE, + ) -> None: + """ +Constructor. + + factory: +optional `PipelineFactory` used to configure components + """ + super().__init__() + + self.iri_base = iri_base + + # initialize the pipeline factory + if factory is not None: + self.factory = factory + else: + self.factory = PipelineFactory() + + + def create_pipeline ( + self, + text_input: str, + ) -> Pipeline: + """ +Use the pipeline factory to create a pipeline (e.g., `spaCy.Document`) +for each text input, which are typically paragraph-length. + + text_input: +raw text to be parsed by this pipeline + + returns: +a configured pipeline + """ + return self.factory.create_pipeline( + text_input, + ) + + + def create_render ( + self + ) -> RenderPyVis: + """ +Create an object for rendering the graph in `PyVis` HTML+JavaScript. + + returns: +a configured `RenderPyVis` object for generating graph visualizations + """ + return RenderPyVis( + self, + self.factory.kg, + ) + + + def _extract_phrases ( # pylint: disable=R0913 + self, + pipe: Pipeline, + sent_id: int, + sent: spacy.tokens.span.Span, + text_id: int, + para_id: int, + lemma_iter: typing.Iterator[ typing.Tuple[ str, int ]], + *, + debug: bool = False, + ) -> typing.Iterator[ Node ]: + """ +Extract phrases from a parsed document to build nodes in the +_lemma graph_, while considering: + + 1. NER entities+labels + 2. lemmatized nouns and verbs + 3. noun chunks that overlap with entities + +as the ordered priorities. + + pipe: +configured pipeline for this document + + sent_id: +sentence identifier + + sent: +token span for the parsed sentence + + text_id: +text (top-level document) identifier + + para_id: +paragraph identitifer + + lemma_iter: +iterator for parsed lemmas + + debug: +debugging flag + + yields: +extracted entities represented as `Node` objects in the graph + """ + # extract entities using NER + ent_seq: typing.List[ spacy.tokens.span.Span ] = list(sent.ents) + + if debug: + ic(ent_seq) + + for token in sent: + head = ( token.head, token.head.i, ) + + if debug: + ic( + token, + token.i, + token.dep_, + head, + ) + + if len(ent_seq) > 0 and ent_seq[0].start == token.i: + # link a named entity + ent = ent_seq.pop(0) + lemma_key, span_len = next(lemma_iter) # pylint: disable=R1708 + + yield self.make_node( + pipe.tokens, + lemma_key, + token, + NodeEnum.ENT, + text_id, + para_id, + sent_id, + label = ent.label_, + length = span_len, + ) + + elif token.pos_ in [ "NOUN", "PROPN", "VERB" ]: + # link a lemmatized entity + yield self.make_node( + pipe.tokens, + Pipeline.get_lemma_key(token), + token, + NodeEnum.LEM, + text_id, + para_id, + sent_id, + ) + + else: + # fall-through case: use token as a placeholder in the lemma graph + yield self.make_node( + pipe.tokens, + Pipeline.get_lemma_key(token, placeholder = True), + token, + NodeEnum.DEP, + text_id, + para_id, + sent_id, + linked = False, + ) + + + def _make_class_link ( + self, + node: Node, + pipe: Pipeline, + *, + debug: bool = False, + ) -> None: + """ +Private helper method to construct a link to an entity's class. + + node: +recognized entity to be linked + + pipe: +configured pipeline for this document + + debug: +debugging flag + """ + if debug: + print("link:", node.label) + + # special case of `make_node()` + if node.label in self.nodes: + dst: Node = self.nodes[node.label] + dst.count += 1 + + else: + # find class IRI metadata + class_meta: typing.List[typing.Dict[ str, str ]] = [ + meta + for meta in pipe.kg.NER_MAP.values() + if meta["iri"] == node.label + ] + + dst = Node( + len(self.nodes), + node.label, # type: ignore + class_meta[0]["definition"], + str(rdflib.RDF.type), + NodeEnum.IRI, + label = class_meta[0]["label"], + length = len(class_meta[0]["label"].split(" ")), + count = 1, + ) + + self.nodes[node.label] = dst # type: ignore + + node.annotated = True + + # construct a directed edge between them + edge: Edge = self.make_edge( # type: ignore + node, + dst, + RelEnum.IRI, + str(rdflib.RDF.type), + node.weight, + debug = debug, + ) + + if debug: + ic(edge) + + if edge is not None: + pipe.edges.append(edge) + + + def _overlay_noun_chunks ( + self, + pipe: Pipeline, + *, + text_id: int = 0, + para_id: int = 0, + debug: bool = False, + ) -> None: + """ +Identify the unique noun chunks, i.e., those which differ from the +entities and lemmas that have already been linked in the lemma graph. + + pipe: +configured pipeline for this document + + text_id: +text (top-level document) identifier + + para_id: +paragraph identitifer + + debug: +debugging flag + """ + # scan the noun chunks for uniqueness + for chunk in pipe.link_noun_chunks(self.nodes): + if chunk.unseen: + location: typing.List[ int ] = [ + text_id, + para_id, + chunk.sent_id, + chunk.start, + ] + + if chunk.lemma_key in self.nodes: + node = self.nodes.get(chunk.lemma_key) + node.loc.append(location) # type: ignore + node.count += 1 # type: ignore + else: + node = Node( + len(self.nodes), + chunk.lemma_key, + chunk.text, + "noun_chunk", + NodeEnum.CHU, + span = chunk.span, + loc = [ location ], + length = chunk.length, + count = 1, + ) + + self.nodes[chunk.lemma_key] = node + + # add the related edges, which do not necessarily + # correspond 1:1 with the existing nodes + for token_id in range(chunk.start, chunk.start + chunk.length): + if debug: + ic(pipe.tokens[token_id]) + + edge: Edge = self.make_edge( + node, # type: ignore + pipe.tokens[token_id], + RelEnum.CHU, + "noun_chunk", + 1.0, + debug = debug, + ) + + if edge is not None: + pipe.edges.append(edge) + + + def collect_graph_elements ( + self, + pipe: Pipeline, + *, + text_id: int = 0, + para_id: int = 0, + debug: bool = False, + ) -> None: + """ +Collect the elements of a _lemma graph_ from the results of running +the `textgraph` algorithm. These elements include: parse dependencies, +lemmas, entities, and noun chunks. + +Make sure to call beforehand: `TextGraphs.create_pipeline()` + + pipe: +configured pipeline for this document + + text_id: +text (top-level document) identifier + + para_id: +paragraph identitifer + + debug: +debugging flag + """ + # parse each sentence + lemma_iter: typing.Iterator[ typing.Tuple[ str, int ]] = pipe.get_ent_lemma_keys() + + for sent_id, sent in enumerate(pipe.ner_doc.sents): + if debug: + ic(sent_id, sent, sent.start) + + sent_nodes: typing.List[ Node ] = list(self._extract_phrases( + pipe, + sent_id, + sent, + text_id, + para_id, + lemma_iter, + )) + + if debug: + ic(sent_nodes) + + for node in sent_nodes: + # re-map from OntoTypes4 to a formal IRI, if possible + # then link the inferred class + if node.kind == NodeEnum.ENT: + node.label = pipe.kg.remap_ner(node.label) + + if node.label is not None and re.search(r"http[s]*://", node.label) is not None: + self._make_class_link( + node, + pipe, + debug = debug, + ) + + # link parse elements, based on the token's head + head_idx: int = node.span.head.i # type: ignore + + if head_idx >= len(sent_nodes): + head_idx -= sent.start + + if debug: + ic(node, len(sent_nodes), node.span.head.i, node.span.head.text, head_idx) # type: ignore # pylint: disable=C0301 + + edge: Edge = self.make_edge( # type: ignore + node, + sent_nodes[head_idx], + RelEnum.DEP, + node.span.dep_, # type: ignore + 1.0, + debug = debug, + ) + + if edge is not None: + pipe.edges.append(edge) + + # annotate src nodes which are subjects or direct objects + if node.span.dep_ in [ "nsubj", "pobj" ]: # type: ignore + node.sub_obj = True + + # overlay unique noun chunks onto the parsed elements, + self._overlay_noun_chunks( + pipe, + text_id = text_id, + para_id = para_id, + debug = debug, + ) + + + def construct_lemma_graph ( + self, + *, + debug: bool = False, + ) -> None: + """ +Construct the base level of the _lemma graph_ from the collected +elements. This gets represented in `NetworkX` as a directed graph +with parallel edges. + +Make sure to call beforehand: `TextGraphs.collect_graph_elements()` + + debug: +debugging flag + """ + # add the nodes + self.lemma_graph.add_nodes_from([ + node.node_id + for node in self.nodes.values() + ]) + + # populate the minimum required node properties + for node_key, node in self.nodes.items(): + nx_node = self.lemma_graph.nodes[node.node_id] + nx_node["lemma"] = node_key + nx_node["count"] = node.count + nx_node["weight"] = node.weight + nx_node["kind"] = str(node.kind) + + if node.kind in [ NodeEnum.DEP ]: + nx_node["label"] = "" + elif node.kind in [ NodeEnum.IRI ]: + nx_node["label"] = self.factory.kg.normalize_prefix(node.label) # type: ignore + else: + nx_node["label"] = node.text + + if debug: + ic(nx_node) + + # add the edges and their properties + self.lemma_graph.add_edges_from([ + ( + edge.src_node, + edge.dst_node, + { + "kind": str(edge.kind), + "title": edge.rel, + "lemma": edge_key, + "weight": float(edge.count), + "prob": edge.prob, + "count": edge.count, + }, + ) + for edge_key, edge in self.edges.items() + ]) + + + ###################################################################### + ## entity linking + + def perform_entity_linking ( + self, + pipe: Pipeline, + *, + debug: bool = False, + ) -> None: + """ +Perform _entity linking_ based on the `KnowledgeGraph` object. + +Make sure to call beforehand: `TextGraphs.collect_graph_elements()` + + pipe: +configured pipeline for this document + + debug: +debugging flag + """ + pipe.kg.perform_entity_linking( + self, + pipe, + debug = debug, + ) + + # by default, link the baseline semantics + for node in pipe.tokens: + if node.kind == NodeEnum.LEM and node.label is None: + node.label = str(rdflib.OWL.Thing) + + + ###################################################################### + ## relation extraction + + def _infer_rel_construct_edge ( + self, + src: Node, + iri: str, + dst: Node, + *, + debug: bool = False, + ) -> Edge: + """ +Create an edge for the linked IRI, based on the input triple. + + src: +source node in the triple + + iri: +predicate IRI in the triple + + dst: +destination node in the triple + + debug: +debugging flag + + returns: +the constructed `Edge` object + """ + edge = self.make_edge( # type: ignore + src, + dst, + RelEnum.INF, + iri, + 1.0, + debug = debug, + ) + + if debug: + ic(edge) + + return edge # type: ignore + + + async def _consume_infer_rel ( + self, + queue: asyncio.Queue, + inferred_edges: typing.List[ Edge ], + *, + debug: bool = False, + ) -> None: + """ +Consume from queue: inferred relations represented as triples. + + queue: +queue of inference tasks to be performed + + inferred_edges: +a list collecting the `Edge` objects inferred during this processing + + debug: +debugging flag + """ + while True: + src, iri, dst = await queue.get() + + inferred_edges.append( + self._infer_rel_construct_edge( + src, + iri, + dst, + debug = debug, + ) + ) + + queue.task_done() + + + async def infer_relations_async ( + self, + pipe: Pipeline, + *, + debug: bool = False, + ) -> typing.List[ Edge ]: + """ +Gather triples representing inferred relations and build edges, +concurrently by running an async queue. + + +Make sure to call beforehand: `TextGraphs.collect_graph_elements()` + + pipe: +configured pipeline for this document + + debug: +debugging flag + + returns: +a list of the inferred `Edge` objects + """ + inferred_edges: typing.List[ Edge ] = [] + queue: asyncio.Queue = asyncio.Queue() + + producer_tasks: typing.List[ asyncio.Task ] = [ + asyncio.create_task( + producer.gen_triples_async( # type: ignore + pipe, + queue, + debug = debug, + ) + ) + for producer in pipe.infer_rels + ] + + consumer_task: asyncio.Task = asyncio.create_task( + self._consume_infer_rel( + queue, + inferred_edges, + debug = debug, + ) + ) + + # wait for producers to finish, + # await the remaining tasks, + # then cancel the now-idle consumer + await asyncio.gather(*producer_tasks) + + if debug: + ic("Queue: done producing") + + await queue.join() + consumer_task.cancel() + + if debug: + ic("Queue: done consuming") + + # update the graph + pipe.edges.extend(inferred_edges) + + return inferred_edges + + + def infer_relations ( + self, + pipe: Pipeline, + *, + debug: bool = False, + ) -> typing.List[ Edge ]: + """ +Gather triples representing inferred relations and build edges. + +Make sure to call beforehand: `TextGraphs.collect_graph_elements()` + + pipe: +configured pipeline for this document + + debug: +debugging flag + + returns: +a list of the inferred `Edge` objects + """ + inferred_edges: typing.List[ Edge ] = [ + self._infer_rel_construct_edge(src, iri, dst, debug = debug) + for infer_rel in pipe.infer_rels + for src, iri, dst in infer_rel.gen_triples(pipe, debug = debug) + ] + + # update the graph + pipe.edges.extend(inferred_edges) + + return inferred_edges + + + ###################################################################### + ## rank the extracted and linked phrases + + @classmethod + def _solve_restack_coeffs ( + cls, + sum_e: float, + sum_l: float, + min_e: float, + max_l: float, + *, + debug: bool = False, + ) -> typing.Tuple[ float, float ]: + """ +Solve for the rank coefficients using a `pulp` linear programming model. + + sum_e: +sum of the entity ranks + + sum_l: +sum of the lemma ranks + + min_e: +minimum among the entity ranks + + max_l: +maximum among the entity ranks + + debug: +debugging flag + + returns: +the calculated rank coefficients + """ + coef0: pulp.LpVariable = pulp.LpVariable("coef0", 0) # coef for ranked entities + coef1: pulp.LpVariable = pulp.LpVariable("coef1", 0) # coef for ranked lemmas + slack: pulp.LpVariable = pulp.LpVariable("slack", 0) # "stack gap" slack variable + + prob: pulp.LpProblem = pulp.LpProblem("restack_coeffs", pulp.LpMinimize) + prob += coef0 * sum_e + coef1 * sum_l + slack == 1.0 + prob += coef0 * min_e - coef1 * max_l - slack == 0.0 + prob += coef0 - coef1 >= 0 + + # final expression becomes the objective function + prob += slack + + status: int = prob.solve( + pulp.PULP_CBC_CMD(msg = False), + ) + + if debug: + ic(pulp.LpStatus[status]) + ic(pulp.value(coef0)) + ic(pulp.value(coef1)) + ic(pulp.value(slack)) + + return ( pulp.value(coef0), pulp.value(coef1), ) # type: ignore + + + def _restack_ranks ( # pylint: disable=R0914 + self, + ranks: typing.List[ float ], + *, + debug: bool = False, + ) -> typing.List[ float ]: + """ +Stack-rank the nodes so that entities have priority over lemmas. + + ranks: +list of calculated ranks per node + + debug: +debugging flag + + returns: +ordered list of re-stacked nodes + """ + # build a dataframe of node ranks and counts + df1: pd.DataFrame = pd.DataFrame.from_dict([ + { + "weight": ranks[node.node_id], + "count": node.get_stacked_count(), + "hood": node.neighbors, + "subobj": int(node.sub_obj), + } + for node in self.nodes.values() + ]) + + df1.loc[df1["count"] < 1, "weight"] = 0 + + # normalize by column and calculate quantiles + df2: pd.DataFrame = df1.apply(lambda x: x / x.max(), axis = 0) + bins: np.ndarray = calc_quantile_bins(len(df2.index)) + + # stripe each columns + df3: pd.DataFrame = pd.DataFrame([ + stripe_column(values, bins) + for _, values in df2.items() + ]).T + + # renormalize the ranks + df1["rank"] = df3.apply(root_mean_square, axis=1) + df1.loc[df1["count"] < 1, "rank"] = 0 + + rank_col: np.ndarray = df1["rank"].to_numpy() + rank_col /= sum(rank_col) + + # prepare to stack entities atop lemmas + df1["E"] = df1["rank"] + df1["L"] = df1["rank"] + + df1["entity"] = [ + node.kind == NodeEnum.ENT + for node in self.nodes.values() + ] + + df1.loc[~df1["entity"], "E"] = 0 + df1.loc[df1["entity"], "L"] = 0 + + if debug: + ic(df1) + + # partition the lists to be stacked + E: typing.List[ float ] = [ # pylint: disable=C0103 + rank + for rank in df1["E"].to_numpy() + if rank > 0.0 + ] + + L: typing.List[ float ] = [ # pylint: disable=C0103 + rank + for rank in df1["L"].to_numpy() + if rank > 0.0 + ] + + # just use the calculated ranks when either list is empty + if len(E) < 1 or len(L) < 1: + return ranks + + # configure a system of linear equations + coef0, coef1 = self._solve_restack_coeffs( + sum_e = sum(E), + sum_l = sum(L), + min_e = min(E), + max_l = max(L), + debug = debug, + ) + + df1["stacked"] = df1["E"] * coef0 + df1["L"] * coef1 + + if debug: + ic(df1) + + return list(df1["stacked"].to_numpy()) + + + def calc_phrase_ranks ( + self, + *, + pr_alpha: float = PAGERANK_ALPHA, + debug: bool = False, + ) -> None: + """ +Calculate the weights for each node in the _lemma graph_, then +stack-rank the nodes so that entities have priority over lemmas. + +Phrase ranks are normalized to sum to 1.0 and these now represent +the ranked entities extracted from the document. + +Make sure to call beforehand: `TextGraphs.construct_lemma_graph()` + + pr_alpha: +optional `alpha` parameter for the PageRank algorithm + + debug: +debugging flag + """ + for node in self.nodes.values(): + nx_node = self.lemma_graph.nodes[node.node_id] + neighbors: int = 0 + + try: + neighbors = len(list(nx.neighbors(self.lemma_graph, node.node_id))) + except Exception: # pylint: disable=W0718 + pass + finally: + node.neighbors = neighbors + nx_node["hood"] = neighbors + + # restack + ranks: typing.List[ float ] = self._restack_ranks( + list(nx.pagerank( + self.lemma_graph, + alpha = pr_alpha, + ).values()), + debug = debug, + ) + + # update the node weights + for i, node in enumerate(self.nodes.values()): + node.weight = ranks[i] + + + def get_phrases ( + self + ) -> typing.Iterator[ dict ]: + """ +Return the entities extracted from the document. + +Make sure to call beforehand: `TextGraphs.calc_phrase_ranks()` + + yields: +extracted entities + """ + for node in sorted( + [ + node + for node in self.nodes.values() + if node.weight > 0 + ], + key = lambda n: n.weight, + reverse = True, + ): + + label: str = self.factory.kg.normalize_prefix(node.get_linked_label()) # type: ignore # pylint: disable=C0301 + + yield { + "node_id": node.node_id, + "text": node.text, + "pos": node.pos, + "label": label, + "count": node.count, + "weight": node.weight, + } + + + def get_phrases_as_df ( + self + ) -> pd.DataFrame: + """ +Return the ranked extracted entities as a dataframe. + +Make sure to call beforehand: `TextGraphs.calc_phrase_ranks()` + + returns: +a `pandas.DataFrame` of the extracted entities + """ + return pd.DataFrame.from_dict(self.get_phrases()) + + + ###################################################################### + ## knowledge graph abstraction layer + + def export_rdf ( # pylint: disable=R0914 + self, + *, + lang: str = "en", + ) -> str: + """ +Extract the entities and relations which have IRIs as RDF triples. + + lang: +language identifier + + returns: +RDF triples N3 (Turtle) format as a string + """ + node_list: typing.List[ Node ] = list(self.nodes.values()) + node_keys: typing.List[ str ] = list(self.nodes.keys()) + ref_dict: typing.Dict[ int, rdflib.URIRef ] = {} + rdf_graph: rdflib.Graph = rdflib.Graph() + + # extract entities as RDF + for node_id, node in enumerate(self.nodes.values()): + if node.kind in [ NodeEnum.ENT, NodeEnum.LEM ]: + if node.pos not in [ "VERB" ]: + iri: str = f"{self.iri_base}entity/{node.key.replace(' ', '_').replace('.', '_')}" # pylint: disable=C0301 + subj: rdflib.URIRef = rdflib.URIRef(iri) + ref_dict[node_id] = subj + + rdf_graph.add(( + subj, + rdflib.SKOS.prefLabel, + rdflib.Literal(node.text, lang = lang), + )) + + if node.kind == NodeEnum.ENT and node.annotated: + cls_obj: rdflib.URIRef = rdflib.URIRef(node.label) + cls_id: int = node_keys.index(node.label) # type: ignore + ref_dict[cls_id] = cls_obj + + rdf_graph.add(( + subj, + rdflib.RDF.type, + cls_obj, + )) + + elif node.kind == NodeEnum.IRI: + subj = rdflib.URIRef(node.key) + ref_dict[node_id] = subj + + rdf_graph.add(( + subj, + rdflib.SKOS.prefLabel, + rdflib.Literal(node.label, lang = lang), + )) + + rdf_graph.add(( + subj, + rdflib.SKOS.definition, + rdflib.Literal(node.text, lang = lang), + )) + + # extract relations as RDF + for edge in self.edges.values(): + if edge.kind == RelEnum.INF: + if edge.src_node in ref_dict: + subj = ref_dict.get(edge.src_node) + else: + src_node: Node = node_list[edge.src_node] + subj = rdflib.URIRef(src_node.label) + ref_dict[edge.src_node] = subj + + if edge.dst_node in ref_dict: + obj: rdflib.URIRef = ref_dict.get(edge.dst_node) + else: + dst_node: Node = node_list[edge.dst_node] + obj = rdflib.URIRef(dst_node.label) + ref_dict[edge.dst_node] = obj + + rdf_graph.add(( + subj, + rdflib.URIRef(edge.rel), + obj, + )) + + # serialize as RDF triples + for prefix, iri in self.factory.kg.NS_PREFIX.items(): + rdf_graph.bind(prefix, rdflib.Namespace(iri)) + + n3_str: str = rdf_graph.serialize( + format = "n3", + base = self.iri_base, + ) + + return n3_str + + + def denormalize_iri ( + self, + uri_ref: rdflib.term.URIRef, + ) -> str: + """ +Discern between a parsed entity and a linked entity. + + returns: +_lemma_key_ for a parsed entity, the full IRI for a linked entity + """ + uri: str = str(uri_ref) + + if uri.startswith(self.iri_base): + return uri.replace(self.iri_base, "").replace("entity/", "").replace("_", ".") + + return uri + + + def load_bootstrap_ttl ( # pylint: disable=R0912,R0914 + self, + ttl_str: str, + *, + debug: bool = False, + ) -> None: + """ +Parse a TTL string with an RDF semantic graph representation to load +bootstrap definitions for the _lemma graph_ prior to parsing, e.g., +for synonyms. + + ttl_str: +RDF triples in TTL (Turtle/N3) format + + debug: +debugging flag + """ + rdf_graph: rdflib.Graph = rdflib.Graph() + rdf_graph.parse(data = ttl_str) + + rdf_nodes: typing.Dict[ str, dict ] = defaultdict(dict) + rdf_edges: typing.Set[ tuple ] = set() + + # parse the node data, tally the edges + for subj, pred, obj in rdf_graph: + uri: str = self.denormalize_iri(subj) + + if pred == rdflib.SKOS.prefLabel: + rdf_nodes[uri]["label"] = str(obj) + elif pred == rdflib.SKOS.definition: + rdf_nodes[uri]["descrip"] = str(obj) + + elif pred == rdflib.RDF.type: + dst: str = str(obj) + rdf_nodes[dst]["ref"] = True + rdf_nodes[uri]["type"] = dst + + else: + src: str = uri + rdf_nodes[src]["ref"] = True + + dst = self.denormalize_iri(obj) + rdf_nodes[dst]["ref"] = True + + rdf_edges.add(( str(pred), src, dst, )) + + # construct the nodes + for uri, node_dat in rdf_nodes.items(): + if "ref" in node_dat: + if debug: + ic(uri, node_dat) + + node_kind: NodeEnum = NodeEnum.ENT + + if re.search(r"http[s]*://", uri) is not None: + node_kind = NodeEnum.IRI + + node: Node = self.make_node( + [], + uri, + None, + node_kind, + 0, + 0, + 0, + label = node_dat["label"], + length = len(node_dat["label"].split(" ")), + ) + + node.count = 0 + node.loc = [] + + if "type" in node_dat: + node.pos = node_dat["type"] + + if "descrip" in node_dat: + node.text = node_dat["descrip"] + + if node_kind == NodeEnum.ENT: + node.text = node_dat["label"] + + if debug: + ic(node) + + # construct the edges + node_list: typing.List[ Node ] = list(self.nodes.values()) + + for rel, src, dst in rdf_edges: + src_node: Node = self.nodes[src] + dst_node: Node = self.nodes[dst] + + if debug: + print(rel, node_list.index(src_node), node_list.index(dst_node)) + + edge_kind: RelEnum = RelEnum.IRI + + if rel == str(rdflib.SKOS.broader): + edge_kind = RelEnum.SYN + + edge: Edge = self.make_edge( # type: ignore + src_node, + dst_node, + edge_kind, + rel, + 1.0, + debug = debug, + ) + + if debug: + ic(edge) + + + def export_kuzu ( # pylint: disable=R0912,R0914 + self, + *, + zip_name: str = "lemma.zip", + debug: bool = False, + ) -> str: + """ +Export a labeled property graph for KùzuDB (openCypher). + + debug: +debugging flag + + returns: +name of the generated ZIP file + """ + subdir: str = "cyp" + zip_dir: tempfile.TemporaryDirectory = tempfile.TemporaryDirectory() # pylint: disable=R1732 + incl_nodes: set = set() + + with zipfile.ZipFile( + zip_name, + mode = "w", + compression = zipfile.ZIP_DEFLATED, + compresslevel = 9, + ) as zip_fp: + # write the nodes table + nodes_path: pathlib.Path = pathlib.Path(zip_dir.name) / "nodes.csv" + + with open(nodes_path, "w", encoding = "utf-8") as fp: # pylint: disable=C0103 + writer = csv.writer(fp) + + for node in self.nodes.values(): + # juggle the serialized IRIs + iri: typing.Optional[ str ] = None + + if node.kind in [ NodeEnum.ENT, NodeEnum.LEM ]: + if node.pos not in [ "VERB" ]: + iri = f"{self.iri_base}entity/{node.key.replace(' ', '_').replace('.', '_')}" # pylint: disable=C0301 + elif node.kind == NodeEnum.IRI: + iri = node.key + + if iri is not None: + incl_nodes.add(node.node_id) + + node_row: list = [ + node.node_id, + iri, + node.weight, + str(node.kind), + node.key, + node.label, + node.text, + node.pos, + node.length, + node.count, + ] + + if debug: + ic(node_row) + + writer.writerow(node_row) + + zip_fp.write( + nodes_path, + arcname = subdir + "/" + nodes_path.name, + ) + + # write the edges table + edges_path: pathlib.Path = pathlib.Path(zip_dir.name) / "edges.csv" + + with open(edges_path, "w", encoding = "utf-8") as fp: # pylint: disable=C0103 + writer = csv.writer(fp) + + for edge in self.edges.values(): + if edge.src_node in incl_nodes and edge.dst_node in incl_nodes: + edge_row: list = [ + edge.src_node, + edge.dst_node, + edge.rel, + edge.prob, + str(edge.kind), + edge.count, + ] + + if debug: + ic(edge_row) + + writer.writerow(edge_row) + + zip_fp.write( + edges_path, + arcname = subdir + "/" + edges_path.name, + ) + + # write the `demo.py` script + demo_str: str = """ +# minimal dependencies +import kuzu +import shutil + +# clear space for tables +DB_DIR: str = "db" +shutil.rmtree(DB_DIR, ignore_errors = True) + +# instantiate KùzuDB connection +db = kuzu.Database(DB_DIR) +conn = kuzu.Connection(db) + +# define table schema +conn.execute( + "CREATE NODE TABLE subobj(id INT64, iri STRING, prob FLOAT, kind STRING, lemma STRING, label STRING, descrip STRING, pos STRING, length INT16, count INT16, PRIMARY KEY (id))" +) +conn.execute( + "CREATE REL TABLE triple(FROM subobj TO subobj, pred STRING, prob FLOAT, kind STRING, count INT16)" +) + +# load data into tables +conn.execute('COPY subobj FROM "nodes.csv"') +conn.execute('COPY triple FROM "edges.csv"') + +# run a simple Cypher query +query: str = "MATCH (s:subobj) RETURN s.id, s.iri;" +results = conn.execute(query) + +while results.has_next(): + print(results.get_next()) + """ + + zip_fp.writestr( + subdir + "/demo.py", + demo_str, + ) + + if debug: + zip_fp.printdir() + + shutil.rmtree(zip_dir.name) + + return zip_name diff --git a/textgraphs/elem.py b/textgraphs/elem.py new file mode 100644 index 0000000000000000000000000000000000000000..fec4acf49a5724c381476d610af461b27fde599d --- /dev/null +++ b/textgraphs/elem.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +These classes represent graph elements. + +Consider this "flavor" of graph representation to be a superset of +`openCypher` _labeled property graphs_ (LPG) with additional support +for probabilistic graphs. + +Imposing a discipline of IRIs for node names and edge relations +helps guarantee that a view of the graph can be exported to RDF +for data quality checks, transitive closure, semantic inference, +and so on. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +from dataclasses import dataclass, field +import typing + +import spacy # pylint: disable=E0401 + +from .util import EnumBase + + +###################################################################### +## class definitions + +@dataclass(order=False, frozen=False) +class KGSearchHit: # pylint: disable=R0902 + """ +A data class representing a hit from a _knowledge graph_ search. + """ + iri: str + label: str + descrip: str + aliases: typing.List[ str ] + prob: float + + +@dataclass(order=False, frozen=False) +class LinkedEntity: # pylint: disable=R0902 + """ +A data class representing one linked entity. + """ + span: typing.Optional[ spacy.tokens.span.Span ] + iri: str + length: int + rel: str + prob: float + token_id: int + kg_ent: typing.Optional[ KGSearchHit ] + count: int = 1 + + +@dataclass(order=False, frozen=False) +class NounChunk: # pylint: disable=R0902 + """ +A data class representing one noun chunk, i.e., a candidate as an extracted phrase. + """ + span: spacy.tokens.span.Span + text: str + length: int + lemma_key: str + unseen: bool + sent_id: int + start: int = 0 + + +class NodeEnum (EnumBase): + """ +Enumeration for the kinds of node categories + """ + DEP = 0 # `spaCy` parse dependency + LEM = 1 # lemmatized token + ENT = 2 # named entity + CHU = 3 # noun chunk + IRI = 4 # IRI for linked entity + + @property + def decoder ( + self + ) -> typing.List[ str ]: + """ +Decoder values + """ + return [ + "dep", + "lem", + "ent", + "chu", + "iri", + ] + + +@dataclass(order=False, frozen=False) +class Node: # pylint: disable=R0902 + """ +A data class representing one node, i.e., an extracted phrase. + """ + node_id: int + key: str + text: str + pos: str + kind: NodeEnum + span: typing.Optional[ typing.Union[ spacy.tokens.span.Span, spacy.tokens.token.Token ]] = None + loc: typing.List[ typing.List[ int ] ] = field(default_factory = lambda: []) + label: typing.Optional[ str ] = None + length: int = 1 + sub_obj: bool = False + count: int = 0 + neighbors: int = 0 + weight: float = 0.0 + entity: typing.List[ LinkedEntity ] = field(default_factory = lambda: []) + annotated: bool = False + + + def get_linked_label ( + self + ) -> typing.Optional[ str ]: + """ +When this node has a linked entity, return that IRI. +Otherwise return its `label` value. + + returns: +a label for the linked entity + """ + if len(self.entity) > 0: + return self.entity[0].iri + + return self.label + + + def get_name ( + self + ) -> str: + """ +Return a brief name for the graphical depiction of this Node. + + returns: +brief label to be used in a graph + """ + if self.kind == NodeEnum.IRI: + return self.label # type: ignore + if self.kind == NodeEnum.LEM: + return self.key + + return self.text + + + def get_stacked_count ( + self + ) -> int: + """ +Return a modified count, to redact verbs and linked entities from +the stack-rank partitions. + + returns: +count, used for re-ranking extracted entities + """ + if self.pos == "VERB" or self.kind == NodeEnum.IRI: + return 0 + + return self.count + + + def get_pos ( + self + ) -> typing.Tuple[ int, int ]: + """ +Generate a position span for `OpenNRE`. + + returns: +a position span needed for `OpenNRE` relation extraction + """ + position: typing.Tuple[ int, int ] = ( self.span.idx, self.span.idx + len(self.text) - 1, ) # type: ignore # pylint: disable=C0301 + return position + + +class RelEnum (EnumBase): + """ +Enumeration for the kinds of edge relations + """ + DEP = 0 # `spaCy` parse dependency + CHU = 1 # `spaCy` noun chunk + INF = 2 # `REBEL` or `OpenNRE` inferred relation + SYN = 3 # `sense2vec` inferred synonym + IRI = 4 # `DBPedia` or `Wikidata` linked entity + + @property + def decoder ( + self + ) -> typing.List[ str ]: + """ +Decoder values + """ + return [ + "dep", + "chu", + "inf", + "syn", + "iri", + ] + + +@dataclass(order=False, frozen=False) +class Edge: + """ +A data class representing an edge between two nodes. + """ + src_node: int + dst_node: int + kind: RelEnum + rel: str + prob: float + count: int = 1 diff --git a/textgraphs/gor.py b/textgraphs/gor.py new file mode 100644 index 0000000000000000000000000000000000000000..42bd121ffe477f74d552e62f852676ebfa42a312 --- /dev/null +++ b/textgraphs/gor.py @@ -0,0 +1,588 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +This class handles toplogical transforms of graph data into a +_graph of relations_ dual representation. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +from collections import Counter, defaultdict +from dataclasses import dataclass, field +import itertools +import pathlib +import json +import sys +import typing + +from icecream import ic # pylint: disable=E0401 +import networkx as nx # pylint: disable=E0401 +import pandas as pd # pylint: disable=E0401 +import pyvis # pylint: disable=E0401 + +from .elem import Edge, Node, NodeEnum, RelEnum +from .graph import SimpleGraph +from .util import EnumBase + + +###################################################################### +## class definitions + +class RelDir (EnumBase): + """ +Enumeration for the directions of a relation. + """ + HEAD = 0 # relation flows into node + TAIL = 1 # relation flows out of node + + @property + def decoder ( + self + ) -> typing.List[ str ]: + """ +Decoder values + """ + return [ + "head", + "tail", + ] + + +@dataclass(order=False, frozen=False) +class SheafSeed: + """ +A data class representing a node from the source graph plus its +partial edge, based on a _Sheaf Theory_ decomposition of a graph. + """ + node_id: int + rel_id: int + rel_dir: RelDir + edge: Edge + + +@dataclass(order=False, frozen=False) +class TransArc: + """ +A data class representing one transformed rel-node-rel triple in +a _graph of relations_. + """ + pair_key: tuple + a_rel: int + b_rel: int + node_id: int + a_dir: RelDir + b_dir: RelDir + + +@dataclass(order=False, frozen=False) +class Affinity: + """ +A data class representing the affinity scores from one entity +in the transformed _graph of relations_. + +NB: there are much more efficient ways to calculate these +_affinity scores_ using sparse tensor algebra; this approach +illustrates the process -- for research and debugging. + """ + pairs: typing.Dict[ int, Counter ] = field(default_factory = lambda: defaultdict(Counter)) + scores: typing.Dict[ int, float ] = field(default_factory = lambda: {}) + tally: int = 0 + + +class GraphOfRelations: # pylint: disable=R0902 + """ +Attempt to reproduce results published in +"INGRAM: Inductive Knowledge Graph Embedding via Relation Graphs" + + """ + + def __init__ ( + self, + source: SimpleGraph + ) -> None: + """ +Constructor. + + source: +source graph to be transformed + """ + self.source: SimpleGraph = source + self.rel_list: typing.List[ str ] = [] + + self.node_list: typing.List[ Node ] = [] + self.edge_list: typing.List[ Edge ] = [] + + self.seed_links: typing.Dict[ int, list ] = defaultdict(list) + + self.head_affin: typing.Dict[ int, Affinity ] = defaultdict(Affinity) + self.tail_affin: typing.Dict[ int, Affinity ] = defaultdict(Affinity) + + # to be loaded from the dataset + self.pub_score: typing.Dict[ tuple, float ] = {} + + + def load_ingram ( # pylint: disable=R0914 + self, + json_file: pathlib.Path, + *, + debug: bool = False, + ) -> None: + """ +Load data for a source graph, as illustrated in _lee2023ingram_ + + json_file: +path for the JSON dataset to load + + debug: +debugging flag + """ + with open(json_file, "r", encoding = "utf-8") as fp: # pylint: disable=C0103,W0621 + dat: dict = json.load(fp) + + # JSON file provides an ordered list of relations + # to simplify tracing/debugging + self.rel_list = dat["rels"] + + # build the src node of the triple + for src_name, links in dat["ents"].items(): + src_node: Node = self.source.make_node( + [], + src_name, + None, + NodeEnum.ENT, + 0, + 0, + 0, + ) + + for rel_name, dst_name in links: + # error-check input + if rel_name not in self.rel_list: + print("Unknown relation:", rel_name) + sys.exit(-1) + + # build the dst node of the triple + dst_node: Node = self.source.make_node( + [], + dst_name, + None, + NodeEnum.ENT, + 0, + 0, + 0, + ) + + # create an edge between src/dst + edge: Edge = self.source.make_edge( # type: ignore # pylint: disable=W0612,W0621 + src_node, + dst_node, + RelEnum.SYN, + rel_name, + 1.0, + ) + + # load the expected score values + for rel_a, rel_b, score in dat["scores"]: + pair_key: tuple = (rel_a, rel_b) + self.pub_score[pair_key] = score + + if debug: + print(self.source.nodes) + print(self.source.edges) + print(self.rel_list) + print(self.pub_score) + + + def seeds ( + self, + *, + debug: bool = False, + ) -> None: + """ +Prep data for the topological transform illustrated in _lee2023ingram_ + + debug: +debugging flag + """ + self.node_list = list(self.source.nodes.values()) + self.edge_list = list(self.source.edges.values()) + + if debug: + print("\n--- triples in source graph ---") + + for edge in self.source.edges.values(): + if edge.rel not in self.rel_list: + self.rel_list.append(edge.rel) + + rel_id: int = self.rel_list.index(edge.rel) + + if debug: + ic(edge.src_node, rel_id, edge.dst_node) + print("", self.node_list[edge.src_node].text, edge.rel, self.node_list[edge.dst_node].text) # pylint: disable=C0301 + + # enumerate the partially decoupled links ("seeds") + # for the topological transform: + self.seed_links[edge.dst_node].append(SheafSeed( + edge.dst_node, + rel_id, + RelDir.HEAD, + edge, + )) + + self.seed_links[edge.src_node].append(SheafSeed( + edge.src_node, + rel_id, + RelDir.TAIL, + edge, + )) + + + def trace_source_graph ( + self + ) -> None: + """ +Output a "seed" representation of the source graph. + """ + print("\n--- nodes in source graph ---") + + for node in self.source.nodes.values(): + # CONFIRMED: correct according to examples in the paper + print(f"n: {node.node_id:2}, {node.text}") + + head_edges = [ + ( seed.edge.src_node, seed.edge.rel, seed.edge.dst_node, ) + for seed in self.seed_links[node.node_id] + if seed.rel_dir == RelDir.HEAD + ] + + print("", "head:", head_edges) + + tail_edges = [ + ( seed.edge.src_node, seed.edge.rel, seed.edge.dst_node, ) + for seed in self.seed_links[node.node_id] + if seed.rel_dir == RelDir.TAIL + ] + + print("", "tail:", tail_edges) + + print("\n--- edges in source graph ---") + + for rel_id, rel in enumerate(self.rel_list): + print(f"e: {rel_id:2}, {rel}") + + + def _transformed_triples ( + self, + *, + debug: bool = False, + ) -> typing.Iterator[ TransArc ]: + """ +Generate the transformed triples for a _graph of relations_. + + debug: +debugging flag + + yields: +transformed triples + """ + for node_id, seeds in sorted(self.seed_links.items()): + if debug: + ic(node_id, len(seeds)) + + for seed_a, seed_b in itertools.combinations(seeds, 2): + pair_key: tuple = tuple(sorted([ seed_a.rel_id, seed_b.rel_id ])) + + if debug: + print(f" {pair_key} {seed_a.edge.rel}.{seed_a.rel_dir} {self.node_list[node_id].text} {seed_b.edge.rel}.{seed_b.rel_dir}") # pylint: disable=C0301 + + trans_arc: TransArc = TransArc( + pair_key, + seed_a.rel_id, + seed_b.rel_id, + node_id, + seed_a.rel_dir, + seed_b.rel_dir, + ) + + yield trans_arc + + + def construct_gor ( + self, + *, + debug: bool = False, + ) -> None: + """ +Perform the topological transform described by _lee2023ingram_, +constructing a _graph of relations_ (GOR) and calculating +_affinity scores_ between entities in the GOR based on their +definitions: + +> we measure the affinity between two relations by considering how many +entities are shared between them and how frequently they share the same +entity + + debug: +debugging flag + """ + if debug: + print("\n--- transformed triples ---") + + for trans_arc in self._transformed_triples(debug = debug): + if debug: + ic(trans_arc) + print() + + if trans_arc.a_dir == RelDir.HEAD: + self.head_affin[trans_arc.a_rel].pairs[trans_arc.b_rel][trans_arc.node_id] += 1 + else: + self.tail_affin[trans_arc.a_rel].pairs[trans_arc.b_rel][trans_arc.node_id] += 1 + + if trans_arc.b_dir == RelDir.HEAD: + self.head_affin[trans_arc.b_rel].pairs[trans_arc.a_rel][trans_arc.node_id] += 1 + else: + self.tail_affin[trans_arc.b_rel].pairs[trans_arc.a_rel][trans_arc.node_id] += 1 + + + @classmethod + def tally_frequencies ( + cls, + counter: Counter, + ) -> int: + """ +Tally the frequency of shared entities. + + counter: +`counter` data collection for the rel_b/entity pairs + + returns: +tallied values for one relation + """ + sum_freq: int = counter.total() # type: ignore + + for occur in counter.values(): # pylint: disable=W0612 + sum_freq += 1 + + return sum_freq + + + def _collect_tallies ( + self, + *, + debug: bool = False, + ) -> None: + """ +Collect tallies, in preparation for calculating the affinity scores. + + debug: +debugging flag + """ + if debug: + print("\n--- collect shared entity tallies ---") + + for rel_a, rel in enumerate(self.rel_list): + for rel_b, counter in sorted(self.head_affin[rel_a].pairs.items()): + tally: int = self.tally_frequencies(counter) + self.head_affin[rel_a].scores[rel_b] = float(tally) + self.head_affin[rel_a].tally += tally + + for rel_b, counter in sorted(self.tail_affin[rel_a].pairs.items()): + tally = self.tally_frequencies(counter) + self.tail_affin[rel_a].scores[rel_b] = float(tally) + self.tail_affin[rel_a].tally += tally + + if debug: + print(rel_a, rel) + print(" h:", self.head_affin[rel_a].tally, self.head_affin[rel_a].scores.items()) + print(" t:", self.tail_affin[rel_a].tally, self.tail_affin[rel_a].scores.items()) + + + def get_affinity_scores ( + self, + *, + debug: bool = False, + ) -> typing.Dict[ tuple, float ]: + """ +Reproduce metrics based on the example published in _lee2023ingram_ + + debug: +debugging flag + + returns: +the calculated affinity scores + """ + self._collect_tallies(debug = debug) + + scores: typing.Dict[ tuple, float ] = {} + n_rels: int = len(self.rel_list) + + pairs: typing.Set[ tuple ] = { + tuple(sorted([ rel_a, rel_b ])) + for rel_a in range(n_rels) + for rel_b in range(n_rels) + } + + for rel_a, rel_b in sorted(list(pairs)): + pair_affin: float = 0.0 + + if rel_b in self.head_affin and rel_a in self.tail_affin: + rel_a_sum = self.head_affin[rel_a].tally + self.tail_affin[rel_a].tally + a_contrib = self.tally_frequencies(self.head_affin[rel_b].pairs[rel_a]) + + rel_b_sum = self.head_affin[rel_b].tally + self.tail_affin[rel_b].tally + b_contrib = self.tally_frequencies(self.tail_affin[rel_a].pairs[rel_b]) + + pair_affin += (a_contrib / float(rel_a_sum)) + (b_contrib / float(rel_b_sum)) + + if rel_b in self.tail_affin and rel_a in self.head_affin: + rel_a_sum = self.head_affin[rel_a].tally + self.tail_affin[rel_a].tally + a_contrib = self.tally_frequencies(self.tail_affin[rel_b].pairs[rel_a]) + + rel_b_sum = self.head_affin[rel_b].tally + self.tail_affin[rel_b].tally + b_contrib = self.tally_frequencies(self.head_affin[rel_a].pairs[rel_b]) + + pair_affin += (a_contrib / float(rel_a_sum)) + (b_contrib / float(rel_b_sum)) + + if pair_affin > 0.0: + pair_key: tuple = tuple(sorted([ rel_a, rel_b ])) + scores[pair_key] = pair_affin / 2.0 + + return scores + + + def trace_metrics ( + self, + scores: typing.Dict[ tuple, float ], + ) -> pd.DataFrame: + """ +Compare the calculated affinity scores with results from a published +example. + + scores: +the calculated affinity scores between pairs of relations (i.e., observed values) + + returns: +a `pandas.DataFrame` where the rows compare expected vs. observed affinity scores + """ + df_compare: pd.DataFrame = pd.DataFrame.from_dict([ + { + "pair": pair_key, + "rel_a": self.rel_list[pair_key[0]], + "rel_b": self.rel_list[pair_key[1]], + "affinity": round(aff, 2), + "expected": self.pub_score.get(pair_key) + } + for pair_key, aff in sorted(scores.items()) + ]) + + return df_compare + + + def _build_nx_graph ( + self, + scores: typing.Dict[ tuple, float ], + ) -> nx.Graph: + """ +Construct a network representation of the _graph of relations_ +in `NetworkX` + + scores: +the calculated affinity scores between pairs of relations (i.e., observed values) + + returns: +a `networkx.Graph` representation of the transformed graph + """ + vis_graph: nx.Graph = nx.Graph() + + vis_graph.add_nodes_from([ + ( + rel_id, + { + "label": rel, + }, + ) + for rel_id, rel in enumerate(self.rel_list) + ]) + + vis_graph.add_edges_from([ + ( + rel_a, + rel_b, + { + "weight": affinity, + }, + ) + for (rel_a, rel_b), affinity in scores.items() + ]) + + return vis_graph + + + def render_gor_plt ( + self, + scores: typing.Dict[ tuple, float ], + ) -> None: + """ +Visualize the _graph of relations_ using `matplotlib` + + scores: +the calculated affinity scores between pairs of relations (i.e., observed values) + """ + vis_graph: nx.Graph = self._build_nx_graph(scores) + + node_labels: typing.Dict[ int, str ] = dict(enumerate(self.rel_list)) + + edge_labels: typing.Dict[ int, str ] = { + edge_id: str(round(vis_graph.edges[edge_id]["weight"], 2)) + for edge_id in vis_graph.edges + } + + pos: dict = nx.spring_layout( + vis_graph, + k = 2.0, + ) + + nx.draw_networkx( + vis_graph, + pos, + labels = node_labels, + with_labels = True, + node_color = "#eee", + edge_color = "#bbb", + font_size = 9, + ) + + nx.draw_networkx_edge_labels( + vis_graph, + pos, + edge_labels = edge_labels, + ) + + + def render_gor_pyvis ( + self, + scores: typing.Dict[ tuple, float ], + ) -> pyvis.network.Network: + """ +Visualize the _graph of relations_ interactively using `PyVis` + + scores: +the calculated affinity scores between pairs of relations (i.e., observed values) + + returns: +a `pyvis.networkNetwork` representation of the transformed graph + """ + pv_graph: pyvis.network.Network = pyvis.network.Network() + pv_graph.from_nx(self._build_nx_graph(scores)) + + for pv_edge in pv_graph.get_edges(): + pair_key: tuple = ( pv_edge["from"], pv_edge["to"], ) + aff: typing.Optional[ float ] = scores.get(pair_key) + + if aff is not None: + pv_edge["title"] = round(aff, 2) + pv_edge["label"] = round(aff, 2) + pv_edge["width"] = int(aff * 10.0) + + return pv_graph diff --git a/textgraphs/graph.py b/textgraphs/graph.py new file mode 100644 index 0000000000000000000000000000000000000000..f90d9f3ccfedb174f6184f5bc60d8302483bb909 --- /dev/null +++ b/textgraphs/graph.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pylint: disable=R0801 + +""" +This class implements a generic, in-memory graph data structure used +to represent the _lemma graph_. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +from collections import OrderedDict +import json +import typing + +from icecream import ic # pylint: disable=E0401 +import networkx as nx # pylint: disable=E0401 +import spacy # pylint: disable=E0401 + +from .elem import Edge, LinkedEntity, Node, NodeEnum, RelEnum + + +###################################################################### +## class definitions + +class SimpleGraph: + """ +An in-memory graph used to build a `MultiDiGraph` in NetworkX. + """ + + def __init__ ( + self + ) -> None: + """ +Constructor. + """ + self.nodes: typing.Dict[ str, Node ] = OrderedDict() + self.edges: typing.Dict[ str, Edge ] = {} + self.lemma_graph: nx.MultiDiGraph = nx.MultiDiGraph() + + + def reset ( + self + ) -> None: + """ +Re-initialize the data structures, resetting all but the configuration. + """ + self.nodes = OrderedDict() + self.edges = {} + self.lemma_graph = nx.MultiDiGraph() + + + def make_node ( # pylint: disable=R0913,R0914 + self, + tokens: typing.List[ Node ], + key: str, + span: spacy.tokens.token.Token, + kind: NodeEnum, + text_id: int, + para_id: int, + sent_id: int, + *, + label: typing.Optional[ str ] = None, + length: int = 1, + linked: bool = True, + ) -> Node: + """ +Lookup and return a `Node` object. +By default, link matching keys into the same node. +Otherwise instantiate a new node if it does not exist already. + + tokens: +list of parsed tokens + + key: +lemma key (invariant) + + span: +token span for the parsed entity + + kind: +the kind of this `Node` object + + text_id: +text (top-level document) identifier + + para_id: +paragraph identitifer + + sent_id: +sentence identifier + + label: +node label (for a new object) + + length: +length of token span + + linked: +flag for whether this links to an entity + + returns: +the constructed `Node` object + """ + token_id: int = 0 + token_text: str = key + token_pos: str = "PROPN" + + if span is not None: + token_id = span.i + token_text = span.text + token_pos = span.pos_ + + location: typing.List[ int ] = [ # type: ignore + text_id, + para_id, + sent_id, + token_id, + ] + + if not linked: + # construct a placeholder node (stopwords) + # NB: omit locations + self.nodes[key] = Node( + len(self.nodes), + key, + span.text, + span.pos_, + kind, + span = span, + length = length, + ) + + elif key in self.nodes: + # link to previously constructed entity node + self.nodes[key].count += 1 + self.nodes[key].loc.append(location) + + # reset the span, if this node was loaded from a + # previous pipeline or from bootstrap definitions + if self.nodes[key].span is None: + self.nodes[key].span = span + + # construct a new node for entity or lemma + else: + self.nodes[key] = Node( + len(self.nodes), + key, + token_text, + token_pos, + kind, + span = span, + loc = [ location ], + label = label, + length = length, + count = 1, + ) + + node: Node = self.nodes.get(key) # type: ignore + + if kind not in [ NodeEnum.CHU, NodeEnum.IRI ]: + tokens.append(node) + + return node # type: ignore + + + def make_edge ( # pylint: disable=R0913 + self, + src_node: Node, + dst_node: Node, + kind: RelEnum, + rel: str, + prob: float, + *, + key: typing.Optional[ str ] = None, + debug: bool = False, + ) -> typing.Optional[ Edge ]: + """ +Lookup an edge, creating a new one if it does not exist already, +and increment the count if it does. + + src_node: +source node in the triple + + dst_node: +destination node in the triple + + kind: +the kind of this `Edge` object + + rel: +relation label + + prob: +probability of this `Edge` within the graph + + key: +lemma key (invariant); generate a key if this is not provided + + debug: +debugging flag + + returns: +the constructed `Edge` object; this may be `None` if the input parameters indicate skipping the edge + """ + if key is None: + key = ".".join([ + str(src_node.node_id), + str(dst_node.node_id), + rel.replace(" ", "_"), + str(kind.value), + ]) + + if debug: + ic(key) + + if key in self.edges: + self.edges[key].count += 1 + + elif src_node.node_id != dst_node.node_id: + # preclude cycles in the graph + self.edges[key] = Edge( + src_node.node_id, + dst_node.node_id, + kind, + rel, + prob, + ) + + if debug: + ic(self.edges.get(key)) + + return self.edges.get(key) + + + def dump_lemma_graph ( + self + ) -> str: + """ +Dump the _lemma graph_ as a JSON string in _node-link_ format, +suitable for serialization and subsequent use in JavaScript, +Neo4j, Graphistry, etc. + +Make sure to call beforehand: `TextGraphs.calc_phrase_ranks()` + + returns: +a JSON representation of the exported _lemma graph_ in +[_node-link_](https://networkx.org/documentation/stable/reference/readwrite/json_graph.html) +format + """ + # populate the optional node properties + for node in self.nodes.values(): + nx_node = self.lemma_graph.nodes[node.node_id] + nx_node["name"] = node.text + nx_node["kind"] = str(node.kind) + nx_node["subobj"] = node.sub_obj + nx_node["pos"] = node.pos + nx_node["loc"] = str(node.loc) + nx_node["length"] = node.length + nx_node["hood"] = node.neighbors + nx_node["anno"] = node.annotated + + # juggle the serialized IRIs + if node.kind in [ NodeEnum.IRI ]: + nx_node["iri"] = node.key + elif node.label is not None and node.label.startswith("http"): + nx_node["iri"] = node.label + else: + nx_node["iri"] = None + + # emulate a node-link format serialization, using the + # default `NetworkX.node_link_data()` property names + edge_list: typing.List[ dict ] = [] + + for src, dst, props in self.lemma_graph.edges.data(): + props["source"] = src + props["target"] = dst + edge_list.append(props) + + node_link: dict = { + "directed": True, + "multigraph": True, + "nodes": [ + props + for node_id, props in self.lemma_graph.nodes.data() + ], + "links": edge_list, + "graph": {} + } + + return json.dumps( + node_link, + sort_keys = True, + indent = 2, + separators = ( ",", ":" ), + ) + + + def load_lemma_graph ( # pylint: disable=R0914 + self, + json_str: str, + *, + debug: bool = False, + ) -> None: + """ +Load from a JSON string in +a JSON representation of the exported _lemma graph_ in +[_node-link_](https://networkx.org/documentation/stable/reference/readwrite/json_graph.html) +format + + debug: +debugging flag + """ + dat: dict = json.loads(json_str) + tokens: typing.List[ Node ] = [] + to_link: typing.Dict[ str, str ] = {} + + # deserialize the nodes + for nx_node in dat.get("nodes"): # type: ignore + if debug: + ic(nx_node) + + kind: NodeEnum = NodeEnum.decode(nx_node["kind"]) # type: ignore + label: typing.Optional[ str ] = nx_node["label"] + + if kind in [ NodeEnum.ENT ] and nx_node["iri"] is not None: + label = nx_node["iri"] + + node: Node = self.make_node( + tokens, + nx_node["lemma"], + None, + kind, + 0, + 0, + 0, + label = label, + length = nx_node["length"], + ) + + node.text = nx_node["name"] + node.pos = nx_node["pos"] + node.loc = eval(nx_node["loc"]) # pylint: disable=W0123 + node.count = int(nx_node["count"]) + node.neighbors = int(nx_node["hood"]) + node.annotated = nx_node["anno"] + + # note which `Node` objects need to have entities linked + if kind == NodeEnum.ENT and nx_node["iri"] is not None: + to_link[node.key] = nx_node["iri"] + + if debug: + ic(node) + + # re-link the entities + for src_key, cls_key in to_link.items(): + src_node: Node = self.nodes.get(src_key) # type: ignore + cls_node: Node = self.nodes.get(cls_key) # type: ignore + + src_node.entity.append( + LinkedEntity( + cls_node.span, + cls_node.label, # type: ignore + cls_node.length, + cls_node.pos, + cls_node.weight, + 0, + None, + ) + ) + + # deserialize the edges + node_list: typing.List[ Node ] = list(self.nodes.values()) + + for nx_edge in dat.get("links"): # type: ignore + if debug: + ic(nx_edge) + + edge: Edge = self.make_edge( # type: ignore + node_list[nx_edge["source"]], + node_list[nx_edge["target"]], + RelEnum.decode(nx_edge["kind"]), # type: ignore + nx_edge["title"], + float(nx_edge["prob"]), + key = nx_edge["lemma"], + ) + + edge.count = int(nx_edge["count"]) + + if debug: + ic(edge) diff --git a/textgraphs/kg.py b/textgraphs/kg.py new file mode 100644 index 0000000000000000000000000000000000000000..a4e46fc4912bb30b23ed6d6d76aed9211df6643c --- /dev/null +++ b/textgraphs/kg.py @@ -0,0 +1,1215 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pylint: disable=C0302 + +""" +This class provides a wrapper for access to a _knowledge graph_, which +then runs _entity linking_ and other functions in the pipeline. + +This could provide an interface to a graph database, such as Neo4j, +StarDog, KùzuDB, etc., or to an API. + +In this default case, we wrap services available via the WikiMedia APIs: + + * DBPedia: Spotlight, SPARQL, Search + * Wikidata: Search + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +from collections import OrderedDict +from difflib import SequenceMatcher +import http +import json +import time +import traceback +import typing +import urllib.parse + +from bs4 import BeautifulSoup # pylint: disable=E0401 +from icecream import ic # pylint: disable=E0401 +from qwikidata.linked_data_interface import get_entity_dict_from_api # pylint: disable=E0401 +import markdown2 # pylint: disable=E0401 +import rdflib # pylint: disable=E0401 +import requests # type: ignore # pylint: disable=E0401 +import spacy # pylint: disable=E0401 + +from .defaults import DBPEDIA_MIN_ALIAS, DBPEDIA_MIN_SIM, \ + DBPEDIA_SEARCH_API, DBPEDIA_SPARQL_API, DBPEDIA_SPOTLIGHT_API, \ + WIKIDATA_API +from .elem import Edge, KGSearchHit, LinkedEntity, Node, NodeEnum, RelEnum +from .graph import SimpleGraph +from .pipe import KnowledgeGraph, Pipeline, PipelineFactory + + +###################################################################### +## class definitions + +class KGWikiMedia (KnowledgeGraph): # pylint: disable=R0902,R0903 + """ +Manage access to WikiMedia-related APIs. + """ + NER_MAP: typing.Dict[ str, dict ] = OrderedDict({ + "CARDINAL": { + "iri": "http://dbpedia.org/resource/Cardinal_number", + "definition": "Numerals that do not fall under another type", + "label": "cardinal number", + }, + "DATE": { + "iri": "http://dbpedia.org/ontology/date", + "definition": "Absolute or relative dates or periods", + "label": "date", + }, + "EVENT": { + "iri": "http://dbpedia.org/ontology/Event", + "definition": "Named hurricanes, battles, wars, sports events, etc.", + "label": "event", + }, + "FAC": { + "iri": "http://dbpedia.org/ontology/Infrastructure", + "definition": "Buildings, airports, highways, bridges, etc.", + "label": "infrastructure", + }, + "GPE": { + "iri": "http://dbpedia.org/ontology/Country", + "definition": "Countries, cities, states", + "label": "country", + }, + "LANGUAGE": { + "iri": "http://dbpedia.org/ontology/Language", + "definition": "Any named language", + "label": "language", + }, + "LAW": { + "iri": "http://dbpedia.org/ontology/Law", + "definition": "Named documents made into laws", + "label": "law", + }, + "LOC": { + "iri": "http://dbpedia.org/ontology/Place", + "definition": "Non-GPE locations, mountain ranges, bodies of water", + "label": "place", + }, + "MONEY": { + "iri": "http://dbpedia.org/resource/Money", + "definition": "Monetary values, including unit", + "label": "money", + }, + "NORP": { + "iri": "http://dbpedia.org/ontology/nationality", + "definition": "Nationalities or religious or political groups", + "label": "nationality", + }, + "ORDINAL": { + "iri": "http://dbpedia.org/resource/Ordinal_number", + "definition": "Ordinal number, i.e., first, second, etc.", + "label": "ordinal number", + }, + "ORG": { + "iri": "http://dbpedia.org/ontology/Organisation", + "definition": "Companies, agencies, institutions, etc.", + "label": "organization", + }, + "PERCENT": { + "iri": "http://dbpedia.org/resource/Percentage", + "definition": "Percentage", + "label": "percentage", + }, + "PERSON": { + "iri": "http://dbpedia.org/ontology/Person", + "definition": "People, including fictional", + "label": "person", + }, + "PRODUCT": { + "iri": "http://dbpedia.org/ontology/product", + "definition": "Vehicles, weapons, foods, etc. (Not services)", + "label": "product", + }, + "QUANTITY": { + "iri": "http://dbpedia.org/resource/Quantity", + "definition": "Measurements, as of weight or distance", + "label": "quantity", + }, + "TIME": { + "iri": "http://dbpedia.org/ontology/time", + "definition": "Times smaller than a day", + "label": "time", + }, + "WORK OF ART": { + "iri": "http://dbpedia.org/resource/Work_of_art", + "definition": "Titles of books, songs, etc.", + "label": "work of art", + }, + }) + + NS_PREFIX: typing.Dict[ str, str ] = OrderedDict({ + "dbc": "http://dbpedia.org/resource/Category:", + "dbt": "http://dbpedia.org/resource/Template:", + "dbr": "http://dbpedia.org/resource/", + "yago":"http://dbpedia.org/class/yago/", + "dbd": "http://dbpedia.org/datatype/", + "dbo": "http://dbpedia.org/ontology/", + "dbp": "http://dbpedia.org/property/", + "units": "http://dbpedia.org/units/", + "dbpedia-commons": "http://commons.dbpedia.org/resource/", + "dbpedia-wikicompany": "http://dbpedia.openlinksw.com/wikicompany/", + "dbpedia-wikidata": "http://wikidata.dbpedia.org/resource/", + "wd": "http://www.wikidata.org/", + "wd_ent": "http://www.wikidata.org/entity/", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "schema": "https://schema.org/", + "owl": "http://www.w3.org/2002/07/owl#", + }) + + + def __init__ ( # pylint: disable=W0102 + self, + *, + spotlight_api: str = DBPEDIA_SPOTLIGHT_API, + dbpedia_search_api: str = DBPEDIA_SEARCH_API, + dbpedia_sparql_api: str = DBPEDIA_SPARQL_API, + wikidata_api: str = WIKIDATA_API, + ner_map: dict = NER_MAP, + ns_prefix: dict = NS_PREFIX, + min_alias: float = DBPEDIA_MIN_ALIAS, + min_similarity: float = DBPEDIA_MIN_SIM, + ) -> None: + """ +Constructor. + + spotlight_api: +`DBPedia Spotlight` API or equivalent local service + + dbpedia_search_api: +`DBPedia Search` API or equivalent local service + + dbpedia_sparql_api: +`DBPedia SPARQL` API or equivalent local service + + wikidata_api: +`Wikidata Search` API or equivalent local service + + ner_map: +named entity map for standardizing IRIs + + ns_prefix: +RDF namespace prefixes + + min_alias: +minimum alias probability threshold for accepting linked entities + + min_similarity: +minimum label similarity threshold for accepting linked entities + """ + self.spotlight_api: str = spotlight_api + self.dbpedia_search_api: str = dbpedia_search_api + self.dbpedia_sparql_api: str = dbpedia_sparql_api + self.wikidata_api: str = wikidata_api + self.ner_map: dict = ner_map + self.ns_prefix: dict = ns_prefix + self.min_alias: float = min_alias + self.min_similarity: float = min_similarity + + self.ent_cache: dict = {} + self.iri_cache: dict = {} + + self.markdowner = markdown2.Markdown() + + + def augment_pipe ( + self, + factory: PipelineFactory, + ) -> None: + """ +Encapsulate a `spaCy` call to `add_pipe()` configuration. + + factory: +a `PipelineFactory` used to configure components + """ + factory.aux_pipe.add_pipe( + "dbpedia_spotlight", + config = { + "dbpedia_rest_endpoint": self.spotlight_api, # type: ignore + }, + ) + + + def remap_ner ( + self, + label: typing.Optional[ str ], + ) -> typing.Optional[ str ]: + """ +Remap the OntoTypes4 values from NER output to more general-purpose IRIs. + + label: +input NER label, an `OntoTypes4` value + + returns: +an IRI for the named entity + """ + if label is None: + return None + + try: + iri: typing.Optional[ dict ] = self.ner_map.get(label) + + if iri is not None: + return iri["iri"] + + except TypeError as ex: + ic(ex) + print(f"unknown label: {label}") + + return None + + + def normalize_prefix ( + self, + iri: str, + *, + debug: bool = False, + ) -> str: + """ +Normalize the given IRI using the standard DBPedia namespace prefixes. + + iri: +input IRI, in fully-qualified domain representation + + debug: +debugging flag + + returns: +the compact IRI representation, using an RDF namespace prefix + """ + iri_parse: urllib.parse.ParseResult = urllib.parse.urlparse(iri) + + if debug: + ic(iri_parse) + + for prefix, ns_fqdn in self.ns_prefix.items(): + ns_parse: urllib.parse.ParseResult = urllib.parse.urlparse(ns_fqdn) + + if debug: + ic(prefix, ns_parse.netloc, ns_parse.path, ns_parse.fragment) + + if iri_parse.netloc == ns_parse.netloc and iri_parse.path.startswith(ns_parse.path): + if len(iri_parse.fragment) > 0: + return f"{prefix}:{iri_parse.fragment}" + + slug: str = iri_parse.path.replace(ns_parse.path, "") + return f"{prefix}:{slug}" + + # normalization failed + return iri + + + def perform_entity_linking ( + self, + graph: SimpleGraph, + pipe: Pipeline, + *, + debug: bool = False, + ) -> None: + """ +Perform _entity linking_ based on `DBPedia Spotlight` and other services. + + graph: +source graph + + pipe: +configured pipeline for the current document + + debug: +debugging flag + """ + # first pass: use "spotlight" API to markup text + iter_ents: typing.Iterator[ LinkedEntity ] = self._link_spotlight_entities( + pipe, + debug = debug + ) + + for link in iter_ents: + _ = self._make_link( + graph, + pipe, + link, + str(rdflib.RDF.type), + debug = debug, + ) + + _ = self._secondary_entity_linking( + graph, + pipe, + link, + debug = debug, + ) + + # second pass: use KG search on entities which weren't linked by Spotlight + iter_ents = self._link_kg_search_entities( + pipe, + debug = debug, + ) + + for link in iter_ents: + _ = self._make_link( + graph, + pipe, + link, + str(rdflib.RDF.type), + debug = debug, + ) + + _ = self._secondary_entity_linking( + graph, + pipe, + link, + debug = debug, + ) + + + def resolve_rel_iri ( + self, + rel: str, + *, + lang: str = "en", + debug: bool = False, + ) -> typing.Optional[ str ]: + """ +Resolve a `rel` string from a _relation extraction_ model which has +been trained on this _knowledge graph_, which defaults to using the +`WikiMedia` graphs. + + rel: +relation label, generation these source from Wikidata for many RE projects + + lang: +language identifier + + debug: +debugging flag + + returns: +a resolved IRI + """ + # first, check the cache + if rel in self.iri_cache: + return self.iri_cache.get(rel) + + # otherwise construct a Wikidata API search + try: + hit: dict = self._wikidata_endpoint( + rel, + search_type = "property", + lang = lang, + debug = debug, + ) + + if debug: + ic(hit["label"], hit["id"]) + + # get the `claims` of the Wikidata property + prop_id: str = hit["id"] + prop_dict: dict = get_entity_dict_from_api(prop_id) + claims: dict = prop_dict["claims"] + + if "P1628" in claims: + # use `equivalent property` if available + iri: str = claims["P1628"][0]["mainsnak"]["datavalue"]["value"] + elif "P2235" in claims: + # use `external superproperty` as a fallback + iri = claims["P2235"][0]["mainsnak"]["datavalue"]["value"] + else: + ic("no related claims", rel) + return None + + if debug: + ic(iri) + + # update the cache + self.iri_cache[rel] = iri + return iri + + except requests.exceptions.ConnectionError as r_ex: + ic(r_ex) + return None + except Exception as ex: # pylint: disable=W0718 + ic(ex) + traceback.print_exc() + return None + + + ###################################################################### + ## private methods, customized per KG instance + + def _wikidata_endpoint ( + self, + query: str, + *, + search_type: str = "item", + lang: str = "en", + debug: bool = False, + ) -> dict: + """ +Call a generic endpoint for Wikidata API. +Raises various untrapped exceptions, to be handled by caller. + + query: +query string + + search_type: +search type + + lang: +language identifier + + debug: +debugging flag + """ + hit: dict = {} + + params: dict = { + "action": "wbsearchentities", + "type": search_type, + "language": lang, + "format": "json", + "continue": "0", + "search": query, + } + + response: requests.models.Response = requests.get( + self.wikidata_api, + params = params, + verify = False, + headers = { + "Accept": "application/json", + }, + ) + + if debug: + ic(response.status_code) + + # check for API success + if http.HTTPStatus.OK == response.status_code: + dat: dict = response.json() + hit = dat["search"][0] + + #print(json.dumps(hit, indent = 2, sort_keys = True)) + + return hit + + + @classmethod + def _match_aliases ( + cls, + query: str, + label: str, + aliases: typing.List[ str ], + *, + debug: bool = False, + ) -> typing.Tuple[ float, str ]: + """ +Find the best-matching aliases for a search term. + + query: +query string + + label: +entity label to be matched against the available aliases + + aliases: +list of the available aliases + + debug: +debugging flag + """ + # best case scenario: the label is an exact match + if query == label.lower(): + return ( 1.0, label, ) + + # ...therefore the label is not an exact match + prob_list: typing.List[ typing.Tuple[ float, str ]] = [ + ( SequenceMatcher(None, query, label.lower()).ratio(), label, ) + ] + + # fallback: test the aliases + for alias in aliases: + prob: float = SequenceMatcher(None, query, alias.lower()).ratio() + + if prob == 1.0: + # early termination for success + return ( prob, alias, ) + + prob_list.append(( prob, alias, )) + + # find the closest match + prob_list.sort(reverse = True) + + if debug: + ic(prob_list) + + return prob_list[0] + + + def _md_to_text ( + self, + md_text: str, + ) -> str: + """ +Convert markdown to plain text. + + + md_text: +markdown text (unrendered) + + returns: +rendered plain text as a string + """ + soup: BeautifulSoup = BeautifulSoup( + self.markdowner.convert(md_text), + features = "html.parser", + ) + + return soup.get_text().strip() + + + def wikidata_search ( + self, + query: str, + *, + lang: str = "en", + debug: bool = False, + ) -> typing.Optional[ KGSearchHit ]: + """ +Query the Wikidata search API. + + query: +query string + + lang: +language identifier + + debug: +debugging flag + + returns: +search hit, if any + """ + try: + hit: dict = self._wikidata_endpoint( + query, + search_type = "item", + lang = lang, + debug = debug, + ) + + # extract the needed properties + url: str = hit["concepturi"] + label: str = hit["label"] + descrip: str = hit["description"] + + # determine match likelihood + prob, _ = self._match_aliases( + query.lower(), + label, + [], + debug = debug, + ) + + if debug: + ic(query, url, label, descrip, prob) + + # return a linked entity + wiki_ent: KGSearchHit = KGSearchHit( + url, + label, + descrip, + [], + prob, + ) + + return wiki_ent + + except requests.exceptions.ConnectionError as r_ex: + ic(r_ex) + except Exception as ex: # pylint: disable=W0718 + ic(ex) + traceback.print_exc() + + return None + + + def dbpedia_search_entity ( # pylint: disable=R0914 + self, + query: str, + *, + lang: str = "en", + debug: bool = False, + ) -> typing.Optional[ KGSearchHit ]: + """ +Perform a DBPedia API search. + + query: +query string + + lang: +language identifier + + debug: +debugging flag + + returns: +search hit, if any + """ + # first, check the cache + key: str = "dbpedia:" + query.lower() + + if key in self.ent_cache: + return self.ent_cache.get(key) + + params: dict = { + "format": "json", + "language": lang, + "query": query, + } + + try: + response: requests.models.Response = requests.get( + self.dbpedia_search_api, + params = params, + verify = False, + headers = { + "Accept": "application/json", + }, + ) + + if debug: + ic(response.status_code) + + # check for failed API calls + if http.HTTPStatus.OK != response.status_code: + return None + + dat: dict = response.json() + hit: dict = dat["docs"][0] + + if debug: + ic(json.dumps(hit, indent = 2)) + + iri: str = hit["resource"][0] + label: str = self._md_to_text(hit["label"][0]) + descrip: str = self._md_to_text(hit["comment"][0]) + + aliases: typing.List[ str ] = [ + self._md_to_text(alias) + for alias in hit["redirectlabel"] + ] + + prob, best_match = self._match_aliases( + query.lower(), + label, + aliases, + debug = debug, + ) + + if debug: + ic(iri, label, descrip, aliases, prob, best_match) + + ent: KGSearchHit = KGSearchHit( + iri, + label, + descrip, + aliases, + prob, + ) + + # update the cache + self.ent_cache[key] = ent + return ent + + except requests.exceptions.ConnectionError as r_ex: + ic(r_ex) + return None + except Exception as ex: # pylint: disable=W0718 + ic(ex) + traceback.print_exc() + return None + + + def dbpedia_sparql_query ( + self, + sparql: str, + *, + debug: bool = False, + ) -> dict: + """ +Perform a SPARQL query on DBPedia. + + sparql: +SPARQL query string + + debug: +debugging flag + + returns: +dictionary of query results + """ + dat: dict = {} + + if debug: + print(sparql) + + params: dict = { + "query": sparql, + } + + try: + response: requests.models.Response = requests.get( + self.dbpedia_sparql_api, + params = params, + verify = False, + headers = { + "Accept": "application/json", + }, + ) + + if debug: + ic(response.status_code) + + # check for failed API calls + if http.HTTPStatus.OK == response.status_code: + dat = response.json() + + except requests.exceptions.ConnectionError as r_ex: + ic(r_ex) + except Exception as ex: # pylint: disable=W0718 + ic(ex) + traceback.print_exc() + + return dat + + + def dbpedia_wikidata_equiv ( + self, + dbpedia_iri: str, + *, + debug: bool = False, + ) -> typing.Optional[ str ]: + """ +Perform a SPARQL query on DBPedia to find an equivalent Wikidata entity. + + dbpedia_iri: +IRI in DBpedia + + debug: +debugging flag + + returns: +equivalent IRI in Wikidata + """ + # first, check the cache + if dbpedia_iri in self.iri_cache: + return self.iri_cache.get(dbpedia_iri) + + sparql: str = """ +SELECT DISTINCT ?wikidata_concept +WHERE {{ + {} owl:sameAs ?wikidata_concept . + FILTER(CONTAINS(STR(?wikidata_concept), "www.wikidata.org")) +}} +LIMIT 1000 + """.strip().replace("\n", " ").format(dbpedia_iri) + + dat: dict = self.dbpedia_sparql_query( + sparql, + debug = debug, + ) + + try: + hit: dict = dat["results"]["bindings"][0] + + if debug: + print(json.dumps(hit, indent = 2)) + + equiv_iri: str = hit["wikidata_concept"]["value"] + + if debug: + ic(equiv_iri) + + # update the cache + self.iri_cache[dbpedia_iri] = equiv_iri + return equiv_iri + + except Exception as ex: # pylint: disable=W0718 + ic(ex) + traceback.print_exc() + return None + + + ###################################################################### + ## entity linking + + def _link_spotlight_entities ( # pylint: disable=R0912,R0914 + self, + pipe: Pipeline, + *, + debug: bool = False, + ) -> typing.Iterator[ LinkedEntity ]: + """ +Iterator for the results of using `DBPedia Spotlight` to markup +text with _entity linking_ + + pipe: +configured pipeline for the current document + + debug: +debugging flag + + yields: +candidates linked entities + """ + ents: typing.List[ spacy.tokens.span.Span ] = [] + + if pipe.aux_doc is not None: + list(pipe.aux_doc.ents) + + if debug: + ic(ents) + + ent_idx: int = 0 + tok_idx: int = 0 + + for i, tok in enumerate(pipe.tokens): # pylint: disable=R1702 + if debug: + print() + ic(tok_idx, tok.text, tok.pos) + ic(ent_idx, len(ents)) + + if ent_idx < len(ents): + ent = ents[ent_idx] + + if debug: + ic(ent.start, tok_idx) + + if ent.start == tok_idx: + try: + if debug: + ic(ent.text, ent.start, len(ent)) + ic(ent.kb_id_, ent._.dbpedia_raw_result["@similarityScore"]) + ic(ent._.dbpedia_raw_result) + + prob: float = float(ent._.dbpedia_raw_result["@similarityScore"]) + count: int = int(ent._.dbpedia_raw_result["@support"]) + + if tok.pos == "PROPN" and prob >= self.min_similarity: + kg_ent: typing.Optional[ KGSearchHit ] = self.dbpedia_search_entity( # type: ignore # pylint: disable=C0301 + ent.text, + debug = debug, + ) + + if debug: + ic(kg_ent) + + if kg_ent is not None and kg_ent.prob > self.min_alias: # type: ignore + iri: str = ent.kb_id_ + + dbp_link: LinkedEntity = LinkedEntity( + ent, + iri, + len(ent), + "dbpedia", + prob, + i, + kg_ent, # type: ignore + count = count, + ) + + if debug: + ic("found", dbp_link) + + yield dbp_link + + except Exception as ex: # pylint: disable=W0718 + ic(ex) + traceback.print_exc() + + ent_idx += 1 + + tok_idx += tok.length + + + def _link_kg_search_entities ( + self, + pipe: Pipeline, + *, + debug: bool = False, + ) -> typing.Iterator[ LinkedEntity ]: + """ +Iterator for the results of using `DBPedia Search` directly for +_entity linking_. + + graph: +source graph + + pipe: +configured pipeline for the current document + + debug: +debugging flag + + yields: +search hits + """ + for i, node in enumerate(pipe.tokens): # pylint: disable=R1702 + if node.kind in [ NodeEnum.ENT ] and len(node.entity) < 1: + kg_ent: typing.Optional[ KGSearchHit ] = self.dbpedia_search_entity( # type: ignore # pylint: disable=C0301 + node.text, + debug = debug, + ) + + if kg_ent.prob > self.min_alias: # type: ignore + dbp_link: LinkedEntity = LinkedEntity( + node.span, + kg_ent.iri, # type: ignore + node.length, + "dbpedia", + kg_ent.prob, # type: ignore + i, + kg_ent, # type: ignore + ) + + if debug: + ic("found", dbp_link) + + yield dbp_link + + + def _make_link ( + self, + graph: SimpleGraph, + pipe: Pipeline, + link: LinkedEntity, + rel: str, + *, + debug: bool = False, + ) -> Node: + """ +Link to previously constructed entity node; +otherwise construct a new node for this linked entity. + + graph: +source graph + + pipe: +configured pipeline for the current document + + link: +entity to be linked + + rel: +relation label + + debug: +debugging flag + + returns: +the constructed `Node` object + """ + if debug: + ic(link) + + # special case of `make_node()` + if link.iri in graph.nodes: + graph.nodes[link.iri].count += 1 + + else: + graph.nodes[link.iri] = Node( + len(graph.nodes), + link.iri, + link.kg_ent.descrip, # type: ignore + rel, + NodeEnum.IRI, + span = link.span, + label = link.kg_ent.label, # type: ignore + length = link.length, + count = 1, + ) + + src_node: Node = pipe.tokens[link.token_id] + src_node.annotated = True + + dst_node: Node = graph.nodes.get(link.iri) # type: ignore + + if debug: + ic(src_node, dst_node) + + # back-link to the parsed entity object + pipe.tokens[link.token_id].entity.append(link) + + # construct a directed edge between them + edge: Edge = graph.make_edge( # type: ignore + src_node, + dst_node, + RelEnum.IRI, + rel, + link.prob, + debug = debug, + ) + + if debug: + ic(edge) + + if edge is not None: + pipe.edges.append(edge) + + # return the linked node + return dst_node + + + def _secondary_entity_linking ( + self, + graph: SimpleGraph, + pipe: Pipeline, + link: LinkedEntity, + *, + debug: bool = False, + ) -> typing.Optional[ Edge ]: + """ +Perform secondary _entity linking_, e.g., based on Wikidata API. + + graph: +source graph + + pipe: +configured pipeline for the current document + + link: +entity to be linked + + debug: +debugging flag + + returns: +the constructed `Edge` object + """ + wd_ent: typing.Optional[ KGSearchHit ] = self.wikidata_search( # type: ignore + link.kg_ent.label, # type: ignore + debug = debug, + ) + + if debug: + ic(link.span, wd_ent) + + if wd_ent is not None and wd_ent.prob > self.min_similarity: + wd_link: LinkedEntity = LinkedEntity( + link.span, + wd_ent.iri, + len(link.span), # type: ignore + "wikidata", + wd_ent.prob, + link.token_id, + wd_ent, + ) + + if debug: + ic(wd_link) + + src_node: Node = graph.nodes.get(link.iri) # type: ignore + + dst_node: Node = self._make_link( + graph, + pipe, + wd_link, + str(rdflib.RDF.type), + debug = debug, + ) + + # add an equivalency edge between the two linked entities + edge: Edge = graph.make_edge( # type: ignore + src_node, + dst_node, + RelEnum.IRI, + str(rdflib.OWL.sameAs), + wd_link.prob, + debug = debug, + ) + + if edge is not None: + pipe.edges.append(edge) + + # return the constructed edge + return edge + + return None + + +if __name__ == "__main__": + kg: KGWikiMedia = KGWikiMedia() + + ## resolve rel => iri + rel_list: typing.List[ str ] = [ + "country of citizenship", + "father", + "child", + "significant event", + "child", + "foo", + ] + + for test_rel in rel_list: + start_time: float = time.time() + + result: typing.Optional[ str ] = kg.resolve_rel_iri( + test_rel, + debug = True, + ) + + duration: float = round(time.time() - start_time, 3) + + ic(test_rel, result) + print(f"resolve: {round(duration, 3)} sec") + + ## search DBPedia + query_list: typing.List[ str ] = [ + "filmmaking", + "filmmaker", + "Werner Herzog", + "Werner Herzog", + "Werner", + "Marlene Dietrich", + "Dietrich", + "America", + ] + + for test_query in query_list: + start_time = time.time() + + _kg_ent: KGSearchHit = kg.dbpedia_search_entity( # type: ignore # pylint: disable=W0212 + test_query, + debug = True, + ) + + duration = round(time.time() - start_time, 3) + + ic(test_query, _kg_ent) + print(f"lookup: {round(duration, 3)} sec") + + + ## find Wikidata IRIs that correpond to DBPedia IRIs + dbp_iri_list: typing.List[ str ] = [ + "http://dbpedia.org/resource/Filmmaking", + "http://dbpedia.org/resource/Werner_Herzog", + "http://dbpedia.org/resource/United_States", + ] + + for dbp_iri in dbp_iri_list: + start_time = time.time() + + wd_iri: str = kg.dbpedia_wikidata_equiv( # pylint: disable=W0212 + kg.normalize_prefix(dbp_iri, debug = False), # type: ignore + debug = False, + ) + + duration = round(time.time() - start_time, 3) + + ic(dbp_iri, wd_iri) + print(f"query: {round(duration, 3)} sec") diff --git a/textgraphs/ner.py b/textgraphs/ner.py new file mode 100644 index 0000000000000000000000000000000000000000..32ed97a63c843a405dd507bafc0b6794cbc0f1c6 --- /dev/null +++ b/textgraphs/ner.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Classes for encapsulating NER models. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +from .defaults import NER_MODEL +from .pipe import Component, PipelineFactory + + +###################################################################### +## class definitions + +class NERSpanMarker (Component): # pylint: disable=R0903 + """ +Configures a `spaCy` pipeline component for `SpanMarkerNER` + """ + + def __init__ ( + self, + *, + ner_model: str = NER_MODEL, + ) -> None: + """ +Constructor. + + ner_model: +model to be used in `SpanMarker` + """ + self.ner_model: str = ner_model + + + def augment_pipe ( + self, + factory: PipelineFactory, + ) -> None: + """ +Encapsulate a `spaCy` call to `add_pipe()` configuration. + + factory: +the `PipelineFactory` used to configure this pipeline component + """ + factory.tok_pipe.add_pipe( + "span_marker", + config = { + "model": self.ner_model, + }, + ) + + factory.ner_pipe.add_pipe( + "span_marker", + config = { + "model": self.ner_model, + }, + ) + + factory.aux_pipe.add_pipe( + "span_marker", + config = { + "model": self.ner_model, + }, + ) diff --git a/textgraphs/pipe.py b/textgraphs/pipe.py new file mode 100644 index 0000000000000000000000000000000000000000..bf76658f7d9e32c7d300d114c7178fe6bc14f362 --- /dev/null +++ b/textgraphs/pipe.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Leveraging a factory pattern for NLP pipelines. + +This class handles processing for one "chunk" of raw text input to +analyze, which is typically a paragraph. In other words, objects in +this class are expected to get recycled when processing moves on to +the next paragraph, to ease memory requirements. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +from collections import OrderedDict +import abc +import asyncio +import functools +import itertools +import operator +import traceback +import typing + +from icecream import ic # pylint: disable=E0401,W0611 +import networkx as nx # pylint: disable=E0401 +import spacy # pylint: disable=E0401 + +from .defaults import SPACY_MODEL +from .elem import Edge, Node, NodeEnum, NounChunk +from .graph import SimpleGraph + + +###################################################################### +## class definitions + +class Component (abc.ABC): # pylint: disable=R0903 + """ +Abstract base class for a `spaCy` pipeline component. + """ + + @abc.abstractmethod + def augment_pipe ( + self, + factory: "PipelineFactory", + ) -> None: + """ +Encapsulate a `spaCy` call to `add_pipe()` configuration. + + factory: +a `PipelineFactory` used to configure components + """ + raise NotImplementedError + + +class KnowledgeGraph (Component): + """ +Base class for a _knowledge graph_ interface. + """ + NER_MAP: typing.Dict[ str, dict ] = OrderedDict({}) + NS_PREFIX: typing.Dict[ str, str ] = OrderedDict({}) + + + def augment_pipe ( + self, + factory: "PipelineFactory", + ) -> None: + """ +Encapsulate a `spaCy` call to `add_pipe()` configuration. + + factory: +a `PipelineFactory` used to configure components + """ + pass # pylint: disable=W0107 + + + def remap_ner ( + self, + label: typing.Optional[ str ], + ) -> typing.Optional[ str ]: + """ +Remap the OntoTypes4 values from NER output to more general-purpose IRIs. + + label: +input NER label, an `OntoTypes4` value + + returns: +an IRI for the named entity + """ + return label + + + def normalize_prefix ( + self, + iri: str, + *, + debug: bool = False, # pylint: disable=W0613 + ) -> str: + """ +Normalize the given IRI to use standard namespace prefixes. + + iri: +input IRI, in fully-qualified domain representation + + debug: +debugging flag + + returns: +the compact IRI representation, using an RDF namespace prefix + """ + return iri + + + def perform_entity_linking ( + self, + graph: SimpleGraph, + pipe: "Pipeline", + *, + debug: bool = False, + ) -> None: + """ +Perform _entity linking_ based on "spotlight" and other services. + + graph: +source graph + + pipe: +configured pipeline for the current document + + debug: +debugging flag + """ + pass # pylint: disable=W0107 + + + def resolve_rel_iri ( + self, + rel: str, + *, + lang: str = "en", # pylint: disable=W0613 + debug: bool = False, # pylint: disable=W0613 + ) -> typing.Optional[ str ]: + """ +Resolve a `rel` string from a _relation extraction_ model which has +been trained on this knowledge graph. + + rel: +relation label, generation these source from Wikidata for many RE projects + + lang: +language identifier + + debug: +debugging flag + + returns: +a resolved IRI + """ + return rel + + +class InferRel (abc.ABC): # pylint: disable=R0903 + """ +Abstract base class for a _relation extraction_ model wrapper. + """ + + @abc.abstractmethod + def gen_triples ( + self, + pipe: "Pipeline", + *, + debug: bool = False, + ) -> typing.Iterator[typing.Tuple[ Node, str, Node ]]: + """ +Infer relations as triples through a generator _iteratively_. + + pipe: +configured pipeline for the current document + + debug: +debugging flag + + yields: +generated triples + """ + raise NotImplementedError + + + async def gen_triples_async ( + self, + pipe: "Pipeline", + queue: asyncio.Queue, + *, + debug: bool = False, + ) -> None: + """ +Infer relations as triples produced to a queue _concurrently_. + + pipe: +configured pipeline for the current document + + queue: +queue of inference tasks to be performed + + debug: +debugging flag + """ + for src, iri, dst in self.gen_triples(pipe, debug = debug): + await queue.put(( src, iri, dst, )) + + +class Pipeline: # pylint: disable=R0902,R0903 + """ +Manage parsing of a document, which is assumed to be paragraph-sized. + """ + + def __init__ ( # pylint: disable=R0913 + self, + text_input: str, + tok_pipe: spacy.Language, + ner_pipe: spacy.Language, + aux_pipe: spacy.Language, + kg: KnowledgeGraph, # pylint: disable=C0103 + infer_rels: typing.List[ InferRel ], + ) -> None: + """ +Constructor. + + text_input: +raw text to be parsed + + tok_pipe: +the `spaCy.Language` pipeline used for tallying individual tokens + + ner_pipe: +the `spaCy.Language` pipeline used for tallying named entities + + aux_pipe: +the `spaCy.Language` pipeline used for auxiliary components (e.g., `DBPedia Spotlight`) + + kg: +knowledge graph used for entity linking + + infer_rels: +a list of components for inferring relations + """ + self.text: str = text_input + + # `tok_doc` provides a stream of individual tokens + self.tok_doc: spacy.tokens.Doc = tok_pipe(self.text) + + # `ner_doc` provides the merged-entity spans from NER + self.ner_doc: spacy.tokens.Doc = ner_pipe(self.text) + + # `aux_doc` e.g., re-indexing spans for Spotlight entity linking + # NB: this is optional, in case the Spotlight service is down + self.aux_doc: typing.Optional[ spacy.tokens.Doc ] = None + + try: + self.aux_doc = aux_pipe(self.text) + except Exception as ex: # pylint: disable=W0718 + ic(ex) + + self.kg: KnowledgeGraph = kg # pylint: disable=C0103 + self.infer_rels: typing.List[ InferRel ] = infer_rels + + # list of Node objects for each parsed token, in sequence + self.tokens: typing.List[ Node ] = [] + + # set of Edge objects generated by this Pipeline + self.edges: typing.List[ Edge ] = [] + + + @classmethod + def get_lemma_key ( + cls, + span: typing.Union[ spacy.tokens.span.Span, spacy.tokens.token.Token ], + *, + placeholder: bool = False, + ) -> str: + """ +Compose a unique, invariant lemma key for the given span. + + span: +span of tokens within the lemma + + placeholder: +flag for whether to create a placeholder + + returns: +a composed lemma key + """ + if isinstance(span, spacy.tokens.token.Token): + terms: typing.List[ str ] = [ + span.lemma_.strip().lower(), + span.pos_, + ] + + if placeholder: + terms.insert(0, str(span.i)) + + else: + terms = functools.reduce( + operator.iconcat, + [ + [ token.lemma_.strip().lower(), token.pos_, ] + for token in span + ], + [], + ) + + return ".".join(terms) + + + def get_ent_lemma_keys ( + self, + ) -> typing.Iterator[ typing.Tuple[ str, int ]]: + """ +Iterate through the fully qualified lemma keys for an extracted entity. + + yields: +the lemma keys within an extracted entity + """ + for ent in self.tok_doc.ents: + yield self.get_lemma_key(ent), len(ent) + + + def link_noun_chunks ( + self, + nodes: dict, + *, + debug: bool = False, + ) -> typing.List[ NounChunk ]: + """ +Link any noun chunks which are not already subsumed by named entities. + + nodes: +dictionary of `Node` objects in the graph + + debug: +debugging flag + + returns: +a list of identified noun chunks which are novel + """ + chunks: typing.List[ NounChunk ] = [] + + # first pass: note the available noun chunks + for sent_id, sent in enumerate(self.tok_doc.sents): + for span in sent.noun_chunks: + lemma_key: str = self.get_lemma_key(span) + + chunks.append( + NounChunk( + span, + span.text, + len(span), + lemma_key, + lemma_key not in nodes, + sent_id, + ) + ) + + # second pass: remap span indices to the merged entities pipeline + for i, span in enumerate(self.ner_doc.noun_chunks): + if span.text == self.tokens[span.start].text: + chunks[i].unseen = False + elif chunks[i].unseen: + chunks[i].start = span.start + + if debug: + ic(chunks[i]) + + return chunks + + + ###################################################################### + ## relation extraction + + def iter_entity_pairs ( + self, + pipe_graph: nx.MultiGraph, + max_skip: int, + *, + debug: bool = True, + ) -> typing.Iterator[ typing.Tuple[ Node, Node ]]: + """ +Iterator for entity pairs for which the algorithm infers relations. + + pipe_graph: +a `networkx.MultiGraph` representation of the graph, reused for graph algorithms + + max_skip: +maximum distance between entities for inferred relations + + debug: +debugging flag + + yields: +pairs of entities within a range, e.g., to use for relation extraction + """ + ent_list: typing.List[ Node ] = [ + node + for node in self.tokens + if node.kind in [ NodeEnum.ENT ] + ] + + for pair in itertools.product(ent_list, repeat = 2): + if pair[0] != pair[1]: + src: Node = pair[0] + dst: Node = pair[1] + + try: + path: typing.List[ int ] = nx.shortest_path( + pipe_graph, + source = src.node_id, + target = dst.node_id, + weight = "weight", + method = "dijkstra", + ) + + if debug: + ic(src.node_id, dst.node_id, path) + + if len(path) <= max_skip: + yield ( src, dst, ) + except nx.NetworkXNoPath: + pass + except Exception as ex: # pylint: disable=W0718 + ic(ex) + ic("ERROR", src, dst) + traceback.print_exc() + + +class PipelineFactory: # pylint: disable=R0903 + """ +Factory pattern for building a pipeline, which is one of the more +expensive operations with `spaCy` + """ + + def __init__ ( # pylint: disable=W0102 + self, + *, + spacy_model: str = SPACY_MODEL, + ner: typing.Optional[ Component ] = None, + kg: KnowledgeGraph = KnowledgeGraph(), # pylint: disable=C0103 + infer_rels: typing.List[ InferRel ] = [] + ) -> None: + """ +Constructor which instantiates the `spaCy` pipelines: + + * `tok_pipe` -- regular generator for parsed tokens + * `ner_pipe` -- with entities merged + * `aux_pipe` -- spotlight entity linking + +which will be needed for parsing and entity linking. + + spacy_model: +the specific model to use in `spaCy` pipelines + + ner: +optional custom NER component + + kg: +knowledge graph used for entity linking + + infer_rels: +a list of components for inferring relations + """ + self.ner: typing.Optional[ Component ] = ner + self.kg: KnowledgeGraph = kg # pylint: disable=C0103 + self.infer_rels: typing.List[ InferRel ] = infer_rels + + # determine the NER model to be used + exclude: typing.List[ str ] = [] + + if self.ner is not None: + exclude.append("ner") + + # build the pipelines + # NB: `spaCy` team doesn't quite get the PEP 621 restrictions which PyPa mangled: + # https://github.com/explosion/spaCy/issues/3536 + # https://github.com/explosion/spaCy/issues/4592#issuecomment-704373657 + if not spacy.util.is_package(spacy_model): + spacy.cli.download(spacy_model) + + self.tok_pipe = spacy.load( + spacy_model, + exclude = exclude, + ) + + self.ner_pipe = spacy.load( + spacy_model, + exclude = exclude, + ) + + self.aux_pipe = spacy.load( + spacy_model, + exclude = exclude, + ) + + # add NER + if self.ner is not None: + self.ner.augment_pipe(self) + + # `aux_pipe` only: entity linking + self.kg.augment_pipe(self) + + # `ner_pipe` only: merge entities + self.ner_pipe.add_pipe( + "merge_entities", + ) + + + def create_pipeline ( + self, + text_input: str, + ) -> Pipeline: + """ +Instantiate the document pipelines needed to parse the input text. + + text_input: +raw text to be parsed + + returns: +a configured `Pipeline` object + """ + pipe: Pipeline = Pipeline( + text_input, + self.tok_pipe, + self.ner_pipe, + self.aux_pipe, + self.kg, + self.infer_rels, + ) + + return pipe diff --git a/textgraphs/rel.py b/textgraphs/rel.py new file mode 100644 index 0000000000000000000000000000000000000000..e664c5b8571e93650e80938d1eca7d4722c5574e --- /dev/null +++ b/textgraphs/rel.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +These classes provide wrappers for _relation extraction_ models: + + * ThuNLP `OpenNRE` + * Babelscape `REBEL` + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +import typing + +from icecream import ic # pylint: disable=E0401 +import networkx as nx # pylint: disable=E0401 +import opennre # pylint: disable=E0401 +import transformers # pylint: disable=E0401 + +from .defaults import MAX_SKIP, MREBEL_MODEL, OPENNRE_MIN_PROB, OPENNRE_MODEL +from .elem import Node +from .pipe import InferRel, Pipeline + + +###################################################################### +## class definitions + +class InferRel_OpenNRE (InferRel): # pylint: disable=C0103,R0903 + """ +Perform relation extraction based on the `OpenNRE` model. + + """ + def __init__ ( + self, + *, + model: str = OPENNRE_MODEL, + max_skip: int = MAX_SKIP, + min_prob: float = OPENNRE_MIN_PROB, + ) -> None: + """ +Constructor. + + model: +the specific model to be used in `OpenNRE` + + max_skip: +maximum distance between entities for inferred relations + + min_prob: +minimum probability threshold for accepting an inferred relation + """ + self.max_skip: int = max_skip + self.min_prob: float = min_prob + + self.nre_pipeline: opennre.model.softmax_nn.SoftmaxNN = opennre.get_model(model) + + + def gen_triples ( + self, + pipe: Pipeline, + *, + debug: bool = False, + ) -> typing.Iterator[typing.Tuple[ Node, str, Node ]]: + """ +Iterate on entity pairs to drive `OpenNRE`, inferring relations +represented as triples which get produced by a generator. + + pipe: +configured pipeline for the current document + + debug: +debugging flag + + yields: +generated triples as candidates for inferred relations + """ + node_list: list = [ + node.node_id + for node in pipe.tokens + ] + + pipe_graph: nx.MultiGraph = nx.MultiGraph() + pipe_graph.add_nodes_from(node_list) + + pipe_graph.add_edges_from([ + ( edge.src_node, edge.dst_node, ) + for edge in pipe.edges + if edge is not None and edge.src_node in node_list and edge.dst_node in node_list + ]) + + for src, dst in pipe.iter_entity_pairs(pipe_graph, self.max_skip, debug = debug): + rel, prob = self.nre_pipeline.infer({ # type: ignore + "text": pipe.text, + "h": { "pos": src.get_pos() }, + "t": { "pos": dst.get_pos() }, + }) + + if prob >= self.min_prob: + if debug: + ic(src.text, dst.text) + ic(rel, prob) + + # use the knowledge graph to resolve the IRI + iri: typing.Optional[ str ] = pipe.kg.resolve_rel_iri( + rel, + ) + + if iri is None: + iri = "opennre:" + rel.replace(" ", "_") + + yield src, iri, dst + + +class InferRel_Rebel (InferRel): # pylint: disable=C0103,R0903 + """ +Perform relation extraction based on the `REBEL` model. + + + """ + + def __init__ ( + self, + *, + lang: str = "en_XX", + mrebel_model: str = MREBEL_MODEL, + ) -> None: + """ +Constructor. + + lang: +language identifier + + mrebel_model: +tokenizer model to be used + """ + self.lang = lang + + self.hf_pipeline: transformers.pipeline = transformers.pipeline( + "translation_xx_to_yy", + model = mrebel_model, + tokenizer = mrebel_model, + ) + + + def tokenize_sent ( + self, + text: str, + ) -> str: + """ +Apply the tokenizer manually, since we need to extract special tokens. + + text: +input text for the sentence to be tokenized + + returns: +extracted tokens + """ + tokenized: list = self.hf_pipeline( + text, + decoder_start_token_id = 250058, + src_lang = self.lang, + tgt_lang = "", + return_tensors = True, + return_text = False, + ) + + extracted: list = self.hf_pipeline.tokenizer.batch_decode([ + tokenized[0]["translation_token_ids"] + ]) + + return extracted[0] + + + def extract_triplets_typed ( + self, + text: str, + ) -> list: + """ +Parse the generated text and extract its triplets. + + text: +input text for the sentence to use in inference + + returns: +a list of extracted triples + """ + triplets: list = [] + current: str = "x" + subject: str = "" + subject_type: str = "" + relation: str = "" + object_: str = "" + object_type: str = "" + + text = text.strip()\ + .replace("", "")\ + .replace("", "")\ + .replace("", "")\ + .replace("tp_XX", "")\ + .replace("__en__", "") + + for token in text.split(): + if token in [ "", "" ]: + current = "t" + + if relation != "": + triplets.append({ + "head": subject.strip(), + "head_type": subject_type, + "type": relation.strip(), + "tail": object_.strip(), + "tail_type": object_type, + }) + + relation = "" + + subject = "" + + elif token.startswith("<") and token.endswith(">"): + if current in [ "t", "o" ]: + current = "s" + + if relation != "": + triplets.append({ + "head": subject.strip(), + "head_type": subject_type, + "type": relation.strip(), + "tail": object_.strip(), + "tail_type": object_type, + }) + + object_ = "" + subject_type = token[1:-1] + else: + current = "o" + object_type = token[1:-1] + relation = "" + + else: + if current == "t": + subject += " " + token + elif current == "s": + object_ += " " + token + elif current == "o": + relation += " " + token + + if subject != "" and relation != "" and object_ != "" and object_type != "" and subject_type != "": # pylint: disable=C0301 + triplets.append({ + "head": subject.strip(), + "head_type": subject_type, + "tail": object_.strip(), + "tail_type": object_type, + "rel": relation.strip(), + }) + + return triplets + + + def gen_triples ( + self, + pipe: Pipeline, + *, + debug: bool = False, + ) -> typing.Iterator[typing.Tuple[ Node, str, Node ]]: + """ +Drive `REBEL` to infer relations for each sentence, represented as +triples which get produced by a generator. + + pipe: +configured pipeline for the current document + + debug: +debugging flag + + yields: +generated triples as candidates for inferred relations + """ + for sent in pipe.ner_doc.sents: + extract: str = self.tokenize_sent(str(sent).strip()) + triples: typing.List[ dict ] = self.extract_triplets_typed(extract) + + tok_map: dict = { + token.text: pipe.tokens[token.i] + for token in sent + } + + if debug: + ic(extract, triples) + + for triple in triples: + src: typing.Optional[ Node ] = tok_map.get(triple["head"]) + dst: typing.Optional[ Node ] = tok_map.get(triple["tail"]) + rel: str = triple["rel"] + + if src is not None and dst is not None: + if debug: + ic(src, dst, rel) + + # use the knowledge graph to resolve the IRI + iri: typing.Optional[ str ] = pipe.kg.resolve_rel_iri( + rel, + ) + + if iri is None: + iri = "mrebel:" + rel.replace(" ", "_") + + yield src, iri, dst + + +if __name__ == "__main__": + _rebel: InferRel_Rebel = InferRel_Rebel() + + _para: list = [ + "Werner Herzog is a remarkable filmmaker and intellectual from Germany, the son of Dietrich Herzog.", # pylint: disable=C0301 + "After the war, Werner fled to America to become famous.", + "Instead, Herzog became President and decided to nuke Slovenia.", + ] + + for _sent in _para: + _extract: str = _rebel.tokenize_sent(_sent.strip()) + ic(_extract) + + _triples: list = _rebel.extract_triplets_typed(_extract) + ic(_triples) diff --git a/textgraphs/util.py b/textgraphs/util.py new file mode 100644 index 0000000000000000000000000000000000000000..f7cc9c7f089214dc41a17580de1c0f07f86054c5 --- /dev/null +++ b/textgraphs/util.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Utility functions for the `TextGraphs` library. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +import enum +import math +import typing + +import numpy as np # type: ignore # pylint: disable=E0401 +import pandas as pd # type: ignore # pylint: disable=E0401 + + +###################################################################### +## class definitions + +class EnumBase (enum.IntEnum): + """ +A mixin for Enum codecs. + """ + + @property + def decoder ( + self + ) -> typing.List[ str ]: + """ +Property used for codec. + """ + return [ "xyzzy" ] + + + @classmethod + def decode ( + cls, + text: str, + ) -> enum.IntEnum: + """ +Codec for loading from a string. + + text: +string representation for the input value being decoded + """ + return cls[text.strip().upper()] + + + def __str__ ( + self + ) -> str: + """ +Codec for representing as a string. + + returns: +decoded string representation of the enumerated value + """ + return self.decoder[self.value] + + +###################################################################### +## utility functions + +def calc_quantile_bins ( + num_rows: int + ) -> np.ndarray: + """ +Calculate the bins to use for a quantile stripe, +using [`numpy.linspace`](https://numpy.org/doc/stable/reference/generated/numpy.linspace.html) + + num_rows: +number of rows in the target dataframe + + returns: +calculated bins, as a `numpy.ndarray` + """ + granularity = max(round(math.log(num_rows) * 4), 1) + + return np.linspace( + 0, + 1, + num = granularity, + endpoint = True, + ) + + +def stripe_column ( + values: list, + bins: int, + ) -> np.ndarray: + """ +Stripe a column in a dataframe, by interpolating quantiles into a set of discrete indexes. + + values: +list of values to stripe + + bins: +quantile bins; see [`calc_quantile_bins()`](#calc_quantile_bins-function) + + returns: +the striped column values, as a `numpy.ndarray` + """ + s = pd.Series(values) # pylint: disable=C0103 + q = s.quantile(bins, interpolation = "nearest") # pylint: disable=C0103 + + try: + stripe = np.digitize(values, q) - 1 + return stripe + except ValueError as ex: + # should never happen? + print("ValueError:", str(ex), values, s, q, bins) + raise + + +def root_mean_square ( + values: typing.List[ float ] + ) -> float: + """ +Calculate the [*root mean square*](https://mathworld.wolfram.com/Root-Mean-Square.html) +of the values in the given list. + + values: +list of values to use in the RMS calculation + + returns: +RMS metric as a float + """ + s: float = sum(map(lambda x: float(x)**2.0, values)) # pylint: disable=C0103 + n: float = float(len(values)) # pylint: disable=C0103 + + return math.sqrt(s / n) diff --git a/textgraphs/version.py b/textgraphs/version.py new file mode 100644 index 0000000000000000000000000000000000000000..867fa7093d1fcfa1eed6951f57e07207eec3927f --- /dev/null +++ b/textgraphs/version.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Describe the GitHub repo version tags and commit hash for +the `TextGraphs` library. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +from os.path import dirname, abspath +import pathlib +import typing + +from git import Repo # pylint: disable=E0401 # type: ignore + + +## use the local Git info for version info, if available +REPO_HASH: str = "xxxxxxxxx" # default/placeholder +REPO_TAGS: str = "refs/tags/v1.0.0" # default/placeholder + +try: + repo_path: pathlib.Path = pathlib.Path(dirname(abspath(__file__))) + repo: Repo = Repo(repo_path.parents[0]) + + REPO_HASH = str(repo.head.commit) + REPO_TAGS = repo.tags +except Exception as ex: # pylint: disable=W0703 + print(ex) + + +# cast version string into a float +try: + v_seq: typing.List[ str ] = str(REPO_TAGS[-1]).replace("v", "").split(".")[:3] + + __version__ = ".".join(v_seq) # this is the OpenAPI documentation version + + __version_major__ = int(v_seq[0]) + __version_minor__ = int(v_seq[1]) + __version_patch__ = int(v_seq[2]) +except IndexError: + # the code above may fail in Github Actions workflow + __version__ = "0.0+test" + + __version_major__ = 0 + __version_minor__ = 0 + __version_patch__ = 0 + + +def get_repo_version ( + ) -> typing.Tuple[ str, str ]: + """ +Access the Git repository information and return items to identify +the version/commit running in production. + + returns: +version tag and commit hash + """ + return __version__, REPO_HASH diff --git a/textgraphs/vis.py b/textgraphs/vis.py new file mode 100644 index 0000000000000000000000000000000000000000..5b80c6df1186e9fd6cac7595cdfbc421d108027a --- /dev/null +++ b/textgraphs/vis.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pylint: disable=R0801 + +""" +Visualization methods based on `PyVis`, `wordcloud`, and so on. + +This class handles visualizations of graphs and graph elements. + +see copyright/license https://huggingface.co/spaces/DerwenAI/textgraphs/blob/main/README.md +""" + +from dataclasses import dataclass +import typing + +from icecream import ic # pylint: disable=E0401 +import matplotlib.colors as mcolors # pylint: disable=E0401 +import networkx as nx # pylint: disable=E0401 +import pyvis # pylint: disable=E0401 +import wordcloud # pylint: disable=E0401 + +from .elem import NodeEnum, RelEnum +from .graph import SimpleGraph +from .pipe import KnowledgeGraph + + +###################################################################### +## class definitions + +@dataclass(order=False, frozen=True) +class NodeStyle: # pylint: disable=R0902 + """ +Dataclass used for styling PyVis nodes. + """ + label: NodeEnum + shape: str + color: str + +NODE_STYLES: typing.List[ NodeStyle ] = [ + NodeStyle( + label = NodeEnum.DEP, + shape = "star", + color = "hsla(72, 19%, 90%, 0.4)", + ), + NodeStyle( + label = NodeEnum.LEM, + shape = "square", + color = "hsl(306, 45%, 57%)", + ), + NodeStyle( + label = NodeEnum.ENT, + shape = "circle", + color = "hsl(65, 46%, 58%)", + ), + NodeStyle( + label = NodeEnum.CHU, + shape = "triangle", + color = "hsla(72, 19%, 90%, 0.9)", + ), + NodeStyle( + label = NodeEnum.IRI, + shape = "diamond", + color = "hsla(55, 17%, 49%, 0.5)", + ), +] + +# shapes: image, circularImage, diamond, dot, star, triangle, triangleDown, square, icon + + +class RenderPyVis: # pylint: disable=R0903 + """ +Render the _lemma graph_ as a `PyVis` network. + """ + HTML_HEIGHT_WITH_CONTROLS: int = 1200 + + def __init__ ( + self, + graph: SimpleGraph, + kg: KnowledgeGraph, # pylint: disable=C0103 + ) -> None: + """ +Constructor. + + graph: +source graph to be visualized + + kg: +knowledge graph used for entity linking + """ + self.graph: SimpleGraph = graph + self.kg: KnowledgeGraph = kg # pylint: disable=C0103 + + + def render_lemma_graph ( # pylint: disable=R0912 + self, + *, + debug: bool = True, + ) -> pyvis.network.Network: + """ +Prepare the structure of the `NetworkX` graph to use for building +and returning a `PyVis` network to render. + +Make sure to call beforehand: `TextGraphs.calc_phrase_ranks()` + + debug: +debugging flag + + returns: + typing.Dict[ int, int ]: + """ +Cluster the communities in the _lemma graph_, then draw a +`NetworkX` graph of the notes with a specific color for each +community. + +Make sure to call beforehand: `TextGraphs.calc_phrase_ranks()` + + spring_distance: +`NetworkX` parameter used to separate clusters visually + + debug: +debugging flag + + returns: +a map of the calculated communities + """ + # cluster the communities, using girvan-newman + comm_iter: typing.Generator = nx.community.girvan_newman( + self.graph.lemma_graph, + ) + + _ = next(comm_iter) + next_level = next(comm_iter) + communities: list = sorted(map(sorted, next_level)) + + if debug: + ic(communities) + + comm_map: typing.Dict[ int, int ] = { + node_id: i + for i, comm in enumerate(communities) + for node_id in comm + } + + # map from community => color + xkcd_colors: typing.List[ str ] = list(mcolors.XKCD_COLORS.values()) + + colors: typing.List[ str ] = [ + xkcd_colors[comm_map[n]] + for n in list(self.graph.lemma_graph.nodes()) + ] + + # prep the labels + labels: typing.Dict[ int, str ] = { + node.node_id: self.kg.normalize_prefix(node.get_name()) + for node in self.graph.nodes.values() + } + + # ¡dibuja, hombre! + nx.draw_networkx( + self.graph.lemma_graph, + pos = nx.spring_layout( + self.graph.lemma_graph, + k = spring_distance / len(communities), + ), + labels = labels, + node_color = colors, + edge_color = "#bbb", + with_labels = True, + font_size = 9, + ) + + return comm_map + + + def generate_wordcloud ( + self, + *, + background: str = "black", + ) -> wordcloud.WordCloud: + """ +Generate a tag cloud from the given phrases. + +Make sure to call beforehand: `TextGraphs.calc_phrase_ranks()` + + background: +background color for the rendering + + returns: +the rendering as a `wordcloud.WordCloud` object, which can be used to generate PNG images, etc. + """ + terms: dict = {} + max_weight: float = 0.0 + + for node in self.graph.nodes.values(): + if node.weight > 0.0: + phrase: str = node.text.replace(" ", "_") + max_weight = max(max_weight, node.weight) + terms[phrase] = node.weight + + freq: dict = { + phrase: round(weight / max_weight * 1000.0) + for phrase, weight in terms.items() + } + + cloud: wordcloud.WordCloud = wordcloud.WordCloud( + background_color = background, + ) + + return cloud.generate_from_frequencies(freq)