commit 55ab4e4a73c9345eb62f3b2a03688c17fb3ed8be Author: wehub-resource-sync Date: Mon Jul 13 12:36:30 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 0000000..dd171dd --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,10 @@ +[bumpversion] +current_version = 0.2a47 +commit = True +tag = True +parse = (?P\d+)\.(?P\d+)a(?P\d+) +serialize = {major}.{minor}a{patch} + +[bumpversion:file:setup.py] +search = version="{current_version}" +replace = version="{new_version}" diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..daa7560 --- /dev/null +++ b/.flake8 @@ -0,0 +1,69 @@ +[flake8] +min_python_version = 3.7.0 +max-line-length = 88 +ban-relative-imports = true +# flake8-use-fstring: https://github.com/MichaelKim0407/flake8-use-fstring#--percent-greedy-and---format-greedy +format-greedy = 1 +inline-quotes = double +enable-extensions = TC, TC1 +type-checking-exempt-modules = typing, typing-extensions +eradicate-whitelist-extend = ^-.*; +extend-ignore = + # E203: Whitespace before ':' (pycqa/pycodestyle#373) + E203, + # SIM106: Handle error-cases first + SIM106, + # ANN101: Missing type annotation for self in method + ANN101, + # ANN102: Missing type annotation for cls in classmethod + ANN102, + F401, + F403, + I252, + SIM115, + N806, + N803, + B007, + E501, + C408, + SIM113, + N802, + N400, + E501, + E802, + E800, + B001, + B006, + B020, + C414, + C416, + E402, + E711, + E712, + E722, + E741, + F405, + F541, + F811, + F821, + F841, + FS001, + FS002, + I250, + N801, + N804, + N812, + N816, + Q002, + SIM102, + SIM105, + SIM111, + SIM114, + SIM118, + SIM201, + SIM203, + SIM300, + SIM401, + TC002 + + diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md new file mode 100644 index 0000000..2839b7a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -0,0 +1,32 @@ +--- +name: "\U0001F41E Bug Report" +about: Did you find a bug? +title: '' +labels: Bug, Triage +assignees: '' + +--- + + + + +- [ ] I am on the [latest](https://github.com/easy-graph/Easy-Graph/releases/latest) EasyGraph version. +- [ ] I have searched the [issues](https://github.com/easy-graph/Easy-Graph/issues) of this repo and believe that this is not a duplicate. + + + +- **OS version and name**: +- **EasyGraph version**: + + +## Issue + diff --git a/.github/ISSUE_TEMPLATE/---documentation.md b/.github/ISSUE_TEMPLATE/---documentation.md new file mode 100644 index 0000000..24277b6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---documentation.md @@ -0,0 +1,22 @@ +--- +name: "\U0001F4DA Documentation" +about: Did you find errors, problems, or anything unintelligible in the docs (https://easy-graph.github.io/)? +title: '' +labels: Documentation, Triage +assignees: '' + +--- + + + + +- [ ] I have searched the [issues](https://github.com/easy-graph/Easy-Graph/issues) of this repo and believe that this is not a duplicate. + +## Issue + diff --git a/.github/ISSUE_TEMPLATE/---everything-else.md b/.github/ISSUE_TEMPLATE/---everything-else.md new file mode 100644 index 0000000..3de8b9c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---everything-else.md @@ -0,0 +1,20 @@ +--- +name: "\U0001F5C3 Everything Else" +about: For questions and issues that do not fall in any of the other categories. This + can include questions about EasyGraph's roadmap. For support questions, please post + on StackOverflow. +title: '' +labels: '' +assignees: '' + +--- + + + + + +- [ ] I have searched the [issues](https://github.com/easy-graph/Easy-Graph/issues) of this repo and believe that this is not a duplicate. +- [ ] I have searched the [documentation](https://easy-graph.github.io/) and believe that my question is not covered. + +## Issue + diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---feature-request.md new file mode 100644 index 0000000..c7eb2db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---feature-request.md @@ -0,0 +1,23 @@ +--- +name: "\U0001F381 Feature Request" +about: Do you have ideas for new features and improvements? +title: '' +labels: Feature, Triage +assignees: '' + +--- + + + + +- [ ] I have searched the [issues](https://github.com/easy-graph/Easy-Graph/issues) of this repo and believe that this is not a duplicate. +- [ ] I have searched the [documentation](https://easy-graph.github.io/) and believe that my question is not covered. + +## Feature Request + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a1249f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +# Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser +blank_issues_enabled: true +contact_links: +- name: '💬 Discord Server' + url: https://discord.gg/JV6N8Whe + about: | + Chat with the community, ask questions and learn about best practices. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7a4ac0c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +# Pull Request Check List + +Resolves: #issue-number-here + + + +- [ ] Added **tests** for changed code. +- [ ] Updated **documentation** for changed code. + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4eeafeb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/.github/workflows/archive/release-all-mlwb.yaml b/.github/workflows/archive/release-all-mlwb.yaml new file mode 100644 index 0000000..5eb1cab --- /dev/null +++ b/.github/workflows/archive/release-all-mlwb.yaml @@ -0,0 +1,44 @@ +# https://github.com/marketplace/actions/manylinux-wheel-builder +name: pypi deployer +on: + workflow_dispatch + # push + # push: + # tags: + # - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 +jobs: + Linux-build: + runs-on: ubuntu-22.04 + env: + TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} + TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} + steps: + - uses: actions/checkout@v2 + - name: build and upload manylinux wheels + # cSpell:disable + uses: Niraj-Kamdar/manylinux-wheel-builder@master + # cSpell:enable + Matrix-build: + runs-on: ${{ matrix.os }} + env: + TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} + TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} + strategy: + matrix: + python-version: [3.6, 3.7, 3.8, 3.9] + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: build wheel + run: | + pip install wheel + python setup.py bdist_wheel + - name: upload wheel + run: | + pip install twine + twine upload dist/* + continue-on-error: true diff --git a/.github/workflows/archive/release-macos.yaml b/.github/workflows/archive/release-macos.yaml new file mode 100644 index 0000000..8379682 --- /dev/null +++ b/.github/workflows/archive/release-macos.yaml @@ -0,0 +1,41 @@ +name: Build and Publish to PyPI (macOS) + +# on: push +# disabled on push cuz Error: Container action is only supported on Linux +# no solution found yet +on: workflow_dispatch + +jobs: + build-n-publish: + # name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI + name: Build and publish Python 🐍 distributions 📦 to PyPI + # runs-on: ubuntu-22.04 + runs-on: macos-latest + strategy: + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9"] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install pypa/build + run: >- + python -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: >- + python -m + build + --sdist + --wheel + --outdir dist/ + . + - name: Publish distribution 📦 to PyPI + # if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/archive/release-manylinux.yaml b/.github/workflows/archive/release-manylinux.yaml new file mode 100644 index 0000000..40773ba --- /dev/null +++ b/.github/workflows/archive/release-manylinux.yaml @@ -0,0 +1,50 @@ +name: Build and Publish to PyPI (manylinux) + +on: workflow_dispatch + +jobs: + build-n-publish: + # name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI + name: Build and publish Python 🐍 distributions 📦 to PyPI + # runs-on: ubuntu-22.04 + runs-on: ubuntu-22.04 + # strategy: + # matrix: + # python-version: ["3.6", "3.7", "3.8", "3.9"] + steps: + - uses: actions/checkout@v2 + # - name: Set up Python ${{ matrix.python-version }} + # uses: actions/setup-python@v2 + # with: + # python-version: ${{ matrix.python-version }} + # - name: Install pypa/build + # run: >- + # python -m + # pip install + # build + # --user + # - name: Build a binary wheel and a source tarball + # run: >- + # python -m + # build + # --sdist + # --wheel + # --outdir dist/ + # . + # cSpell:disable + - name: Build on manylinux + uses: RalfG/python-wheels-manylinux-build@v0.4.2-manylinux2014_x86_64 + # cSpell:enable + with: + python-versions: "cp36-cp36m cp37-cp37m cp38-cp38m cp39-cp39m" + # build-requirements: "cython numpy" + # system-packages: "lrzip-devel zlib-devel" + # pre-build-command: "sh pre-build-script.sh" + # package-path: "my_project" + # pip-wheel-args: "-w ./dist --no-deps" + # pip-wheel-args: "--sdist --wheel --outdir dist/" + - name: Publish distribution 📦 to PyPI + # if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/benchmarking.yaml b/.github/workflows/benchmarking.yaml new file mode 100644 index 0000000..a58a3a2 --- /dev/null +++ b/.github/workflows/benchmarking.yaml @@ -0,0 +1,15 @@ +name: "Run Teddy's benchmarking workflow" +on: + # push: + # pull_request: + workflow_dispatch: + +jobs: + trigger: + permissions: write-all + name: "Trigger Teddy's benchmarking workflow" + uses: tddschn/easygraph-bench/.github/workflows/bench-all.yaml@master + secrets: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + releaseTag: "CI-easygraph-bench" diff --git a/.github/workflows/diagram.yml b/.github/workflows/diagram.yml new file mode 100644 index 0000000..622115f --- /dev/null +++ b/.github/workflows/diagram.yml @@ -0,0 +1,16 @@ +name: Create diagram +on: + workflow_dispatch: {} + # push: + # branches: + # - master +jobs: + get_data: + runs-on: ubuntu-22.04 + steps: + - name: Checkout code + uses: actions/checkout@master + - name: Update diagram + uses: githubocto/repo-visualizer@main + with: + excluded_paths: "ignore,.github" diff --git a/.github/workflows/pr_code_check.yaml b/.github/workflows/pr_code_check.yaml new file mode 100644 index 0000000..9ed8768 --- /dev/null +++ b/.github/workflows/pr_code_check.yaml @@ -0,0 +1,50 @@ +name: PR code review + +on: + workflow_dispatch: + pull_request: + branches: + - pybind11 + push: + branches: + - pybind11 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + strategy: + fail-fast: false + matrix: + platform: [windows-latest, macos-latest, ubuntu-latest] + python-version: ["3.9", "3.12"] + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Installing dependencies + uses: py-actions/py-dependency-install@v4 + with: + path: "requirements.txt" + + - name: Add requirements + run: python -m pip install --upgrade setuptools wheel build packaging + + - name: Build and install + run: pip install --verbose . + + - name: Smoke test + run: python -c "import os, tempfile; os.chdir(tempfile.gettempdir()); import easygraph as eg; G = eg.Graph(); G.add_edge(1, 2); assert G.number_of_nodes() == 2; assert G.number_of_edges() == 1" diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..dfffd26 --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,38 @@ +name: pre-commit + +on: + workflow_dispatch: + pull_request: + branches: + - pybind11 + push: + branches: + - pybind11 + +jobs: + pre-commit: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install pre-commit + run: python -m pip install pre-commit + + - name: Run pre-commit on changed files + shell: bash + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + base_ref="${{ github.event.pull_request.base.sha }}" + elif [[ -n "${{ github.event.before }}" && ! "${{ github.event.before }}" =~ ^0+$ ]]; then + base_ref="${{ github.event.before }}" + else + base_ref="$(git rev-parse HEAD~1)" + fi + + pre-commit run --show-diff-on-failure --color=always --from-ref "$base_ref" --to-ref "${{ github.sha }}" diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml new file mode 100644 index 0000000..9968375 --- /dev/null +++ b/.github/workflows/stale.yaml @@ -0,0 +1,19 @@ +name: "Close stale issues and PRs" +on: + schedule: + - cron: "30 1 * * *" + +jobs: + stale: + runs-on: ubuntu-22.04 + steps: + - uses: actions/stale@v4 + with: + stale-issue-message: "This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days." + stale-pr-message: "This PR is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 10 days." + close-issue-message: "This issue was closed because it has been stalled for 5 days with no activity." + close-pr-message: "This PR was closed because it has been stalled for 10 days with no activity." + days-before-issue-stale: 30 + days-before-pr-stale: 45 + days-before-issue-close: 5 + days-before-pr-close: 10 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..33ff418 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,46 @@ +name: Test the package with pytest + +on: + workflow_dispatch: + pull_request: + branches: + - pybind11 + push: + branches: + - pybind11 + +jobs: + pytest: + name: Test with pytest + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + # ubuntu 22.04 has deprecated python 3.6 + python-version: [ "3.8", "3.9", "3.10","3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Installing dependencies + uses: py-actions/py-dependency-install@v4 + with: + path: "requirements.txt" + + - name: Installing test dependencies + run: | + pip install pytest + + - name: Build and install package + run: | + pip install . + + - name: Test with pytest + run: | + pytest easygraph --disable-warnings diff --git a/.github/workflows/translator.yaml b/.github/workflows/translator.yaml new file mode 100644 index 0000000..0ae2e53 --- /dev/null +++ b/.github/workflows/translator.yaml @@ -0,0 +1,34 @@ +#name: 'issue-translator' +#on: +# issue_comment: +# types: [created] +# issues: +# types: [opened] +# +#jobs: +# build: +# runs-on: ubuntu-latest +# steps: +# - uses: usthe/issues-translate-action@v2.7 +# with: +# IS_MODIFY_TITLE: false +# # not require, default false, . Decide whether to modify the issue title +# # if true, the robot account @Issues-translate-bot must have modification permissions, invite @Issues-translate-bot to your project or use your custom bot. +# CUSTOM_BOT_NOTE: Bot detected the issue body's language is not English, translate it automatically. 👯👭🏻🧑‍🤝‍🧑👫🧑🏿‍🤝‍🧑🏻👩🏾‍🤝‍👨🏿👬🏿 +# # not require. Customize the translation robot prefix message. +name: 'issue-translator' +on: + issue_comment: + types: [created] + issues: + types: [opened] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: usthe/issues-translate-action@v2.7 + with: + CUSTOM_BOT_NOTE: hello + BOT_GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }} + diff --git a/.github/workflows/wheel_publish.yml b/.github/workflows/wheel_publish.yml new file mode 100644 index 0000000..2065873 --- /dev/null +++ b/.github/workflows/wheel_publish.yml @@ -0,0 +1,75 @@ +name: Build,download and publish wheels +on: workflow_dispatch +jobs: + build_sdist: + name: Build source distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # sdist used to compile in user local environment + - name: Build sdist + run: pipx run build --sdist + + - uses: actions/upload-artifact@v4 + with: + name: cibw-sdist + path: dist/*.tar.gz + + build_wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + # macos-13 is closing down, macos-15 is apple silicon + # macos-15-intel is for the x86_64 + # note: Apple has discontinued support for the x86_64 (Intel) architecture + os: [ ubuntu-latest, windows-latest, macos-15, macos-15-intel] +# modify python version in project.toml + steps: + # 检出代码 + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v5 + + # You can test your matrix by printing the current Python version + - name: Display Python version + run: python -c "import sys; print(sys.version)" + - name: Install dependencies + run: python -m pip install --upgrade pip setuptools wheel build packaging + + + - name: Build wheels + uses: pypa/cibuildwheel@v2.23.3 + with: + output-dir: dist/ + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} + path: dist/*.whl + + + upload_pypi: + needs: [build_wheels, build_sdist] + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + # unpacks all CIBW artifacts into dist/ + pattern: cibw-* + path: dist + merge-multiple: true + + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@v1.12.4 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7398e80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,212 @@ +*.tmp +.vscode +__pycache__ +test/ +related-repos/ +tmp_test/test.py +tmp_test/dynamic_hypergraph.pdf +tmp_test/hypergraph_learning_tutorial.py +tmp_test/visulation_test.py +hypergraph-bench/ +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# 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 + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__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/ + +# End of https://www.toptal.com/developers/gitignore/api/python +.idea/ + +# Visual Studio project files +x64/ +.vs/ +*.sln +*.vcxproj +*.vcxproj.filters +*.vcxproj.user + +workspace/ +.DS_Store# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +tmp_test/allset_test.py +tmp_test/ +RandomNetwork.txt +TEST/ +CON_TEST/ +Comm_TEST/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..523376d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "cpp_easygraph/pybind11"] + path = cpp_easygraph/pybind11 + url = https://github.com/pybind/pybind11.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c653558 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,94 @@ +ci: + autofix_prs: false + +repos: + # - repo: https://github.com/pre-commit/pre-commit-hooks + # rev: v4.3.0 + # hooks: + # - id: trailing-whitespace + # - id: end-of-file-fixer + # exclude: ^.*\.egg-info/ + # - id: check-merge-conflict + # - id: check-case-conflict + # - id: check-json + # - id: check-toml + # - id: check-yaml + # - id: pretty-format-json + # args: [--autofix, --no-ensure-ascii, --no-sort-keys] + # - id: check-ast + # - id: debug-statements + # - id: check-docstring-first + + # - repo: https://github.com/pre-commit/pygrep-hooks + # rev: v1.9.0 + # hooks: + # - id: python-check-mock-methods + # - id: python-use-type-annotations + # - id: python-check-blanket-noqa + + # - repo: https://github.com/asottile/yesqa + # rev: v1.3.0 + # hooks: + # - id: yesqa + # additional_dependencies: &flake8_deps + # - flake8-annotations==2.9.0 + # - flake8-broken-line==0.4.0 + # - flake8-bugbear==22.4.25 + # - flake8-comprehensions==3.10.0 + # - flake8-eradicate==1.2.1 + # - flake8-quotes==3.3.1 + # - flake8-simplify==0.19.2 + # - flake8-tidy-imports==4.8.0 + # - flake8-type-checking==1.5.0 + # - flake8-typing-imports==1.12.0 + # - flake8-use-fstring==1.3 + # - pep8-naming==0.12.1 + + # - repo: https://github.com/asottile/pyupgrade + # rev: v2.37.1 + # hooks: + # - id: pyupgrade + # args: [--py36-plus] + # exclude: ^(install|get)-poetry.py$ + + # - repo: https://github.com/hadialqattan/pycln + # rev: v2.0.4 + # hooks: + # - id: pycln + # args: [--all] + + - repo: https://github.com/pycqa/isort + # rev: 5.10.1 + # https://stackoverflow.com/questions/75269700/pre-commit-fails-to-install-isort-5-11-4-with-error-runtimeerror-the-poetry-co + rev: 5.12.0 + hooks: + - id: isort + name: "isort (python)" + types: [python] + args: [] + # args: [--add-import, from __future__ import annotations] + # exclude: | + # (?x)( + # ^(install|get)-poetry.py$ + # | ^src/poetry/__init__.py$ + # ) + # - id: isort + # name: "isort (pyi)" + # types: [pyi] + # args: [--lines-after-imports, "-1"] + + - repo: https://github.com/psf/black + rev: 22.6.0 + hooks: + - id: black + + # - repo: https://github.com/pycqa/flake8 + # rev: 4.0.1 + # hooks: + # - id: flake8 + # additional_dependencies: *flake8_deps + + - repo: https://github.com/pre-commit/pre-commit + rev: v2.20.0 + hooks: + - id: validate_manifest diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..ca28feb --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.23) + +project(easygraph) + +option(EASYGRAPH_ENABLE_OPENMP "Enable OpenMP acceleration (auto-detect)" ON) + +option(EASYGRAPH_ENABLE_GPU "EASYGRAPH_ENABLE_GPU" OFF) + +add_subdirectory(cpp_easygraph) + +if (EASYGRAPH_ENABLE_GPU) + message("easygraph gpu module is enabled") + add_subdirectory(gpu_easygraph) + target_include_directories(cpp_easygraph + PRIVATE gpu_easygraph + ) +else() + message("easygraph gpu module is disabled") +endif() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..281a957 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,253 @@ +# Contributing to easygraph + +First off, thanks for taking the time to contribute! + +The following is a set of guidelines for contributing to easygraph on GitHub. These are mostly guidelines, not rules. Use your best judgement, and feel free to propose changes to this document in a pull request. + +#### Table of contents + +[How to contribute](#how-to-contribute) + + * [Reporting bugs](#reporting-bugs) + * [Suggesting enhancements](#suggesting-enhancements) + * [Contributing to documentation](#contributing-to-documentation) + * [Contributing to code](#contributing-to-code) + * [Issue triage](#issue-triage) + * [Git workflow](#git-workflow) + + +## How to contribute + +### Reporting bugs + +This section guides you through submitting a bug report for easygraph. +Following these guidelines helps maintainers and the community understand your report, reproduce the behavior, and find related reports. + +Before creating bug reports, please check [this list](#before-submitting-a-bug-report) to be sure that you need to create one. When you are creating a bug report, please include as many details as possible. Fill out the [required template](https://github.com/easy-graph/Easy-Graph/blob/master/.github/ISSUE_TEMPLATE/---bug-report.md), the information it asks helps the maintainers resolve the issue faster. + +> **Note:** If you find a **Closed** issue that seems like it is the same thing that you're experiencing, open a new issue and include a link to the original issue in the body of your new one. + +#### Before submitting a bug report + + +* **Check that your issue does not already exist in the [issue tracker](https://github.com/easy-graph/Easy-Graph/issues)**. + +#### How do I submit a bug report? + +Bugs are tracked on the [official issue tracker](https://github.com/easy-graph/Easy-Graph/issues) where you can create a new one and provide the following information by filling in [the template](https://github.com/easy-graph/Easy-Graph/blob/master/.github/ISSUE_TEMPLATE/---bug-report.md). + +Explain the problem and include additional details to help maintainers reproduce the problem: + +* **Use a clear and descriptive title** for the issue to identify the problem. +* **Describe the exact steps which reproduce the problem** in as many details as possible. + +* **Provide specific examples to demonstrate the steps to reproduce the issue**. Include links to files or GitHub projects, or copy-paste-able snippets, which you use in those examples. +* **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. +* **Explain which behavior you expected to see instead and why.** + + +Provide more context by answering these questions: + +* **Did the problem start happening recently** (e.g. after updating to a new version of easygraph) or was this always a problem? +* If the problem started happening recently, **can you reproduce the problem in an older version of easygraph?** What's the most recent version in which the problem doesn't happen? +* **Can you reliably reproduce the issue?** If not, provide details about how often the problem happens and under which conditions it normally happens. + +Include details about your configuration and environment: + +* **Which version of easygraph are you using?** +* **Which Python version easygraph has been installed for?** +* **What's the name and version of the OS you're using**? + + +### Suggesting enhancements + +This section guides you through submitting an enhancement suggestion for easygraph, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion and find related suggestions. + +Before creating enhancement suggestions, please check [this list](#before-submitting-an-enhancement-suggestion) as you might find out that you don't need to create one. When you are creating an enhancement suggestion, please [include as many details as possible](#how-do-i-submit-an-enhancement-suggestion). Fill in [the template](https://github.com/easy-graph/Easy-Graph/blob/master/.github/ISSUE_TEMPLATE/---feature-request.md), including the steps that you imagine you would take if the feature you're requesting existed. + +#### Before submitting an enhancement suggestion + + +* **Check that your issue does not already exist in the [issue tracker](https://github.com/easy-graph/Easy-Graph/issues)**. + +#### How do I submit an Enhancement suggestion? + +Enhancement suggestions are tracked on the [official issue tracker](https://github.com/easy-graph/Easy-Graph/issues) where you can create a new one and provide the following information: + +* **Use a clear and descriptive title** for the issue to identify the suggestion. +* **Provide a step-by-step description of the suggested enhancement** in as many details as possible. +* **Provide specific examples to demonstrate the steps**.. +* **Describe the current behavior** and **explain which behavior you expected to see instead** and why. + +### Contributing to documentation + +One of the simplest ways to get started contributing to a project is through improving documentation. easygraph is constantly evolving, this means that sometimes our documentation has gaps. You can help by +adding missing sections, editing the existing content so it is more accessible or creating new content (tutorials, FAQs, etc). + +> **Note:** A great way to understand easygraph's design and how it all fits together, is to add FAQ entries for commonly +> asked questions. easygraph members usually mark issues with [candidate/faq](https://github.com/easy-graph/Easy-Graph/issues?q=is%3Aissue+label%3Acandidate%2Ffaq+) to indicate that the issue either contains a response +> that explains how something works or might benefit from an entry in the FAQ. + +Issues pertaining to the documentation are usually marked with the [Documentation](https://github.com/easy-graph/Easy-Graph/labels/Documentation) label. + +### Contributing to code + +#### Picking an issue + +> **Note:** If you are a first time contributor, and are looking for an issue to take on, you might want to look for [Good First Issue](https://github.com/easy-graph/Easy-Graph/issues?q=is%3Aopen+is%3Aissue+label%3A%22Good+First+Issue%22) +> labelled issues. We do our best to label such issues, however we might fall behind at times. So, ask us. + + +the code base, join us on our [Discord Server](https://discord.gg/ppgcw2wUPg). + +#### Local development + +You will need easygraph to start contributing on the easygraph codebase. Refer to the [documentation](https://easy-graph.github.io/) to start using easygraph. + +> **Note:** Local development of easygraph requires Python 3.8 or newer. + +You will first need to clone the repository using `git` and place yourself in its directory: + +```bash +git clone git@github.com:python-easygraph/easygraph.git +cd easygraph +``` + +> **Note:** We recommend that you use a personal [fork](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo) for this step. If you are new to GitHub collaboration, +> you can refer to the [Forking Projects Guide](https://guides.github.com/activities/forking/). + +Now, you will need to install the required dependency for easygraph and be sure that the current +tests are passing on your machine: + +```bash +pytest +``` + + + +easygraph uses the [black](https://github.com/psf/black) coding style and you must ensure that your +code follows it. If not, the CI will fail and your Pull Request will not be merged. + +Similarly, the import statements are sorted with [isort](https://github.com/timothycrosley/isort) +and special care must be taken to respect it. If you don't, the CI will fail as well. + +To make sure that you don't accidentally commit code that does not follow the coding style, you can +install a [pre-commit](https://pre-commit.com/) hook that will check that everything is in order: + +```bash +pre-commit install +``` + +You can also run it anytime using: + +```bash +pre-commit run --all-files +``` + +Your code must always be accompanied by corresponding tests, if tests are not present your code +will not be merged. + +#### Run the Tests Locally + +- Install [tox](https://tox.readthedocs.io/) and [pyenv](https://github.com/pyenv/pyenv) +- Use pyenv to install python 3.6 to 3.10, and make sure that `python3.6`, `python3.7`, ..., `python3.10` is in your `$PATH`. +- Run `tox` to run the tests across python 3.6 to 3.10. +- To only run tox on python3.9 and 3.10, run `tox -e py39,py310`. + +#### Pull requests + +* Fill in [the required template](https://github.com/easy-graph/Easy-Graph/blob/master/.github/PULL_REQUEST_TEMPLATE.md) +* Be sure that your pull request contains tests that cover the changed or added code. +* If your changes warrant a documentation change, the pull request must also update the documentation. + +> **Note:** Make sure your branch is [rebased](https://docs.github.com/en/free-pro-team@latest/github/using-git/about-git-rebase) against the latest main branch. A maintainer might ask you to ensure the branch is +> up-to-date prior to merging your Pull Request if changes have conflicts. + +All pull requests, unless otherwise instructed, need to be first accepted into the main branch (`master`). + +### Issue triage + + + +If you are helping with the triage of reported issues, this section provides some useful information to assist you in your contribution. + +#### Triage steps + + +1. Attempt to reproduce the issue with the reported easygraph version or request further clarification from the issue author. +1. Ensure the issue is not already resolved. You can attempt to reproduce using the latest preview release and/or easygraph from the main branch. +1. If the issue cannot be reproduced, + 1. clarify with the issue's author, + +1. If the issue can be reproduced, + 1. comment on the issue confirming so + + 1. if possible, identify the root cause of the issue. + 1. if interested, attempt to fix it via a pull request. + + + +### Git Workflow + +All development work is performed against easygraph's main branch (`master`). All changes are expected to be submitted and accepted to this +branch. + +#### Release branch + +When a release is ready, the following are required before a release is tagged. + +1. A release branch with the prefix `release-`, eg: `release-1.1.0rc1`. +1. A pull request from the release branch to the main branch (`master`) if it's a minor or major release. Otherwise, to the bug fix branch (eg: `1.0`). + 1. The pull request description MUST include the change log corresponding to the release (eg: [#2971](https://github.com/easy-graph/Easy-Graph/pull/2971)). + 1. The pull request must contain a commit that updates [CHANGELOG.md](CHANGELOG.md) and bumps the project version (eg: [#2971](https://github.com/easy-graph/Easy-Graph/pull/2971/commits/824e7b79defca435cf1d765bb633030b71b9a780)). + 1. The pull request must have the `Release` label specified. + + + +1. Tag the branch with the version identifier (eg: `1.1.0rc1`). +2. Merge the pull request once the release is created and assets are uploaded by the CI. + +> **Note:** In this case, we prefer a merge commit instead of squash or rebase merge. + +#### Bug fix branch + +Once a minor version (eg: `1.1.0`) is released, a new branch for the minor version (eg: `1.1`) is created for the bug fix releases. Changes identified +or acknowledged by the easygraph team as requiring a bug fix can be submitted as a pull requests against this branch. + +At the time of writing only issues meeting the following criteria may be accepted into a bug fix branch. Trivial fixes may be accepted on a +case-by-case basis. + +1. The issue breaks a core functionality and/or is a critical regression. +1. The change set does not introduce a new feature or changes an existing functionality. +1. A new minor release is not expected within a reasonable time frame. +1. If the issue affects the next minor/major release, a corresponding fix has been accepted into the main branch. + +> **Note:** This is subject to the interpretation of a maintainer within the context of the issue. diff --git a/EG_framework.png b/EG_framework.png new file mode 100644 index 0000000..0acc213 Binary files /dev/null and b/EG_framework.png differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..938144a --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2020, Mobile Systems and Networking Group, Fudan University +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..3fe40ac --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include README.md +include CMakeLists.txt +recursive-include cpp_easygraph * \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..64739a6 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +pre-commit: + pre-commit run --all-files + +test: + tox + +publish: + gh workflow run release-cibuildwheel.yaml + +bump: + bump2version patch + +.PHONY: * \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb9aeb8 --- /dev/null +++ b/README.md @@ -0,0 +1,187 @@ +EasyGraph +================== + +Copyright (C) <2020-2026> by [DataNET Group, Fudan University](https://fudan-datanet.mysxl.cn/) + +___________________________________________________________________________ + +[![PyPI Version][pypi-image]][pypi-url] +[![Python][python-image]][python-url] +[![License][license-image]][license-url] +[![Downloads][downloads-image]][downloads-url] + +[pypi-image]: https://img.shields.io/pypi/v/Python-EasyGraph.svg?label=PyPI +[pypi-url]: https://pypi.org/project/Python-EasyGraph/ +[python-image]: https://img.shields.io/pypi/pyversions/Python-EasyGraph.svg?label=Python +[python-url]: https://pypi.org/project/Python-EasyGraph/ +[license-image]: https://img.shields.io/pypi/l/Python-EasyGraph?label=License +[license-url]: https://github.com/easy-graph/Easy-Graph/blob/master/LICENSE +[downloads-image]: https://img.shields.io/pepy/dt/python-easygraph?label=Downloads&labelColor=brightgreen&color=yellowgreen +[downloads-url]: https://pypi.org/project/Python-EasyGraph/ + +- **Documentation:** https://easy-graph.github.io/ +- **Source Code:** https://github.com/easy-graph/Easy-Graph +- **Issue Tracker:** https://github.com/easy-graph/Easy-Graph/issues +- **PyPI Homepage:** https://pypi.org/project/Python-EasyGraph/ +- **Youtube channel:** https://www.youtube.com/@python-easygraph + +# Introduction +The framework of EasyGraph is composed of four components: **EasyGraph (Core)**, **EasyHypergraph**, **EGGPU**, and **EasyGNN**. +![Framework of EasyGraph.](EG_framework.png) + +**EasyGraph** is an open-source network analysis library primarily written in Python. It supports both undirected and directed networks and accommodates various network data formats. EasyGraph includes a comprehensive suite of network analysis algorithms such as community detection, structural hole spanner detection, network embedding, and motif detection. Additionally, it optimizes performance by implementing key components in C++ and utilizing multiprocessing. + + +👉 For more details, please refer to our [documentation](https://easy-graph.github.io/) page. + + +--- +**EasyHypergraph** is a comprehensive, computation-effective, and storage-saving hypergraph computation tool designed not only for in-depth hypergraph analysis but also for the growing field of hypergraph learning. +It bridges the gap between EasyGraph and higher-order relationships. EasyHypergraph is developed as an integrated module within the EasyGraph framework, maintaining full compatibility with its core architecture. + +👉 For more details, please refer to its [documentation](https://easy-graph.github.io/docs/hypergraph.html) page. + +--- +**EGGPU** is a high-performance GPU-accelerated network analysis library that supports essential functions such as betweenness centrality, k-core centrality, and single-source shortest path,as well as structural hole metrics like constraint. Built on top of the EasyGraph library, EGGPU features an efficient system architecture and native CUDA implementation, while providing a user-friendly Python API and significant speedups for large-scale network analysis. + +👉 For more details, please refer to its [documentation](https://easy-graph.github.io/docs/eggpu.html) page. + +# 📢 EasyGraph News + +## 📣 Media & Press +- [08-09-2025] [EasyHypergraph: Fast, Efficient Higher-Order Network Analysis](https://scienmag.com/easyhypergraph-fast-efficient-higher-order-network-analysis/) +- [01-15-2025] [开放原子大赛OpenRank开源数字生态分析与应用创新大赛全国一等奖 (in Chinese)](https://mp.weixin.qq.com/s/e54JHaP2AAEUN3S8RZ-Y2g) +- [01-07-2025] [计算机科学技术学院教授陈阳入选“2024中国开源先锋33人” (in Chinese)](https://news.fudan.edu.cn/2025/0107/c2463a143932/page.htm) +- [12-04-2024] [国际开源基准委员会的"顶级开源证书" (in Chinese)](https://chenyang03.wordpress.com/wp-content/uploads/2025/07/image.png?w=1024) +- [10-16-2024] [2023年度上海开源创新卓越成果奖 (in Chinese)](https://mp.weixin.qq.com/s/kO6Dpyolf74dlDvKuoLlJA) +- [11-06-2023] [复旦大学陈阳Patterns:EasyGraph——面向多学科的高性能网络结构分析工具箱|Cell Press论文速递 (in Chinese)](https://mp.weixin.qq.com/s/f2LCyQv1dYuquM_EfGX6Ow?poc_token=HBV092ijI2L534IrD0Jl_fnf3VjhX8UudcPFLH6b) +- [11-04-2023] [EasyGraph:多功能、跨平台、高效率的跨学科网络分析库 (in Chinese)](https://swarma.org/?p=46252) + +## 🚀 Releases & Milestones +- [06-07-2026] EasyGraph **v1.6.2** released (Community functions upgraded) +- [05-07-2026] EasyGraph **v1.6.1** released (Add OpenMP-powered path-based functions) +- [02-01-2026] EasyGraph **v1.6** released (OpenMP-powered functions for large network analysis) +- **[01-16-2026] 🎉 1M Downloads! Thanks to our amazing community!** +- [01-01-2026] EasyGraph **v1.5.3** released ([The Hypergraph Interchange Format (HIF) standard](https://github.com/HIF-org/HIF-standard)) +- [11-23-2025] EasyGraph **v1.5.2** released (LS algorithm for effective community detection) +- [10-11-2025] EasyGraph **v1.5.1** released (Python 3.14 supported) +- [07-27-2025] EasyGraph **v1.5** released (This version integrates the HWNN model and supports 11 representative network datasets) +- **[06-29-2025] 🎉 800K+ Downloads!** +- [11-22-2024] EasyGraph **v1.4.1** released (Python 3.13 supported) +- [09-20-2024] EasyGraph **v1.4** released (GPU-powered functions for large network analysis) +- [05-27-2024] EasyGraph **v1.3** released (issues related to hypergraph analysis and visualization resolved) +- [04-09-2024] EasyGraph **v1.2** released (Python 3.12 supported) +- [02-05-2024] EasyGraph **v1.1** released (hypergraph analysis and learning for higher-order networks) +- [08-17-2023] EasyGraph **v1.0** released +- [07-22-2020] EasyGraph **first public release** + +## 📈 Publications +- [05-30-2025] 🎉 Our paper "EasyHypergraph: an open-source software for fast and memory-saving analysis and learning of higher-order networks" was accepted by Humanities and Social Sciences Communications (Nature Portfolio)! [[PDF](https://www.nature.com/articles/s41599-025-05180-5)] +- [08-08-2023] 🎉 Our paper "EasyGraph: A Multifunctional, Cross-Platform, and Effective Library for Interdisciplinary Network Analysis" was accepted by Patterns (Cell Press)! [[PDF](https://www.sciencedirect.com/science/article/pii/S2666389923002180)] + +# Stargazers + +[![Stars][star-image]][star-url] + +[star-image]:https://reporoster.com/stars/easy-graph/Easy-Graph +[star-url]: https://github.com/easy-graph/Easy-Graph/stargazers + +# Install + +## Supported Versions + +``3.8 <= Python <= 3.14`` is required. + +## Installation With pip +``` + $ pip install --upgrade Python-EasyGraph +``` +The conda package is no longer updated or maintained. + +If you've previously installed EasyGraph with conda, please uninstall it with ``conda`` and reinstall with ``pip``. + +## Build From Source +If prebuilt EasyGraph wheels are not supported for your platform (OS / CPU arch, check [here](https://pypi.org/simple/python-easygraph/)), or you want to have GPU-based functions enabled, you can build it locally. + +### Prerequisites +- CMake >= 3.23 +- A compiler that fully supports C++11 +- CUDA Toolkit 11.8 or later would be preferred (If need GPUs enabled) + +### Installation +#### On Linux +``` + git clone --recursive https://github.com/easy-graph/Easy-Graph + export EASYGRAPH_ENABLE_GPU="TRUE" # for users who want to enable GPUs + pip install ./Easy-Graph +``` + +#### On Windows +``` + % For Windows users who want to enable GPU-based functions, % + % you must execute the commands below in cmd but not PowerShell. % + git clone --recursive https://github.com/easy-graph/Easy-Graph + set EASYGRAPH_ENABLE_GPU=TRUE % for users who want to enable GPUs % + pip install ./Easy-Graph +``` + +#### On macOS +``` + # Since macOS doesn't support CUDA, we can't have GPUs enabled on macOS + git clone --recursive https://github.com/easy-graph/Easy-Graph + pip install ./Easy-Graph +``` + +## Hint + +EasyGraph uses 1.12.1 <= [PyTorch](https://pytorch.org/get-started/locally/) < 2.0 for machine learning functions. +Note that this does not prevent your from running non-machine learning functions normally, if there is no PyTorch in your environment. +But you will receive some warnings which remind you some unavailable modules when they depend on it. + +# Simple Example + +This example demonstrates the general usage of methods in EasyGraph. +```python + >>> import easygraph as eg + >>> G = eg.Graph() + >>> G.add_edges([(1,2), (2,3), (1,3), (3,4), (4,5), (3,5), (5,6)]) + >>> eg.pagerank(G) + {1: 0.14272233049003707, 2: 0.14272233049003694, 3: 0.2685427766200994, 4: 0.14336430577918527, 5: 0.21634929087322705, 6: 0.0862989657474143} +``` +This is a simple example for the detection of [structural hole spanners](https://en.wikipedia.org/wiki/Structural_holes) +using the [HIS](https://keg.cs.tsinghua.edu.cn/jietang/publications/WWW13-Lou&Tang-Structural-Hole-Information-Diffusion.pdf) algorithm. + +```python + >>> import easygraph as eg + >>> G = eg.Graph() + >>> G.add_edges([(1,2), (2,3), (1,3), (3,4), (4,5), (3,5), (5,6)]) + >>> _, _, H = eg.get_structural_holes_HIS(G, C=[frozenset([1,2,3]), frozenset([4,5,6])]) + >>> H # The structural hole score of each node. Note that node `4` is regarded as the most possible structural hole spanner. + {1: {0: 0.703948974609375}, + 2: {0: 0.703948974609375}, + 3: {0: 1.2799804687499998}, + 4: {0: 1.519976806640625}, + 5: {0: 1.519976806640625}, + 6: {0: 0.83595703125} + } +``` +# Citation + +If you use EasyGraph in a scientific publication, we kindly request that you cite the following paper: +``` + @article{gao2023easygraph, + title={{EasyGraph: A Multifunctional, Cross-Platform, and Effective Library for Interdisciplinary Network Analysis}}, + author={Min Gao and Zheng Li and Ruichen Li and Chenhao Cui and Xinyuan Chen and Bodian Ye and Yupeng Li and Weiwei Gu and Qingyuan Gong and Xin Wang and Yang Chen}, + year={2023}, + journal={Patterns}, + volume={4}, + number={10}, + pages={100839}, + } +``` +📢 If you notice anything unexpected, please open an issue and let us know. If you have any questions or require a specific feature, feel free to discuss them with us. We are motivated to constantly make EasyGraph even better and let more developers benefit! diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..b60e85f --- /dev/null +++ b/README.rst @@ -0,0 +1,135 @@ +EasyGraph +================== + +Copyright (C) <2020-2024> by DataNET Group, Fudan University + +.. image:: https://img.shields.io/pypi/v/Python-EasyGraph.svg?label=PyPI + :target: https://pypi.org/project/Python-EasyGraph/ + +.. image:: https://img.shields.io/pypi/pyversions/Python-EasyGraph.svg?label=Python + :target: https://pypi.org/project/Python-EasyGraph/ + +.. image:: https://img.shields.io/pypi/l/Python-EasyGraph?label=License + :target: https://github.com/easy-graph/Easy-Graph/blob/master/LICENSE + +.. image:: https://static.pepy.tech/personalized-badge/python-easygraph?period=total&units=international_system&left_color=brightgreen&right_color=yellowgreen&left_text=Downloads + :target: https://pypi.org/project/Python-EasyGraph/ + +- **Documentation:** https://easy-graph.github.io/ +- **Source Code:** https://github.com/easy-graph/Easy-Graph +- **Issue Tracker:** https://github.com/easy-graph/Easy-Graph/issues +- **PyPI Homepage:** https://pypi.org/project/Python-EasyGraph/ +- **Youtube channel:** https://www.youtube.com/@python-easygraph + +Introduction +------------ +**EasyGraph** is an open-source network analysis library. It is mainly written in Python and supports analysis for undirected networks and directed networks. EasyGraph supports various formats of network data and covers a series of important network analysis algorithms for community detection, structural hole spanner detection, network embedding, and motif detection. Moreover, EasyGraph implements some key elements using C++ and introduces multiprocessing optimization to achieve better efficiency. + +New Features in Version 1.1 +------------ +- **Support for more hypergraph metrics and algorithms.** Such as `hypercoreness `_, `vector-centrality `_, `s-centrality `_, and so on. +- **Support for more hypergraph datasets.** `Static hypergraph datasets and dynamic datasets `_ can be both loaded by calling corresponding dataset name. +- **Support for more flexible dynamic hypergraph visualization.** Users can define dynamic hypergraphs and visualize the structure of the hypergraph at each timestamp. +- **Support for more efficient hypergraph computation and hypergraph learning** Adoption of suitable storage structure and caching strategy for different metrics/hypergraph neural networks. + +If you need more details, please see our `documentation `_ of latest version. + +News +---- +- [02-05-2024] We release EasyGraph 1.1! This version features hypergraph analysis and learning for higher-order network modeling and representation. +- [08-17-2023] We release EasyGraph 1.0! +- [08-08-2023] Our paper "EasyGraph: A Multifunctional, Cross-Platform, and Effective Library for Interdisciplinary Network Analysis" has been accepted by Patterns! + +Stargazers +---------- +.. image:: https://reporoster.com/stars/easy-graph/Easy-Graph + :target: https://github.com/easy-graph/Easy-Graph/stargazers + :alt: Stargazers repo roster for @easy-graph/Easy-Graph + +Install +------- + +.. The current version on PyPI is outdated, we'll push the latest version as soon as we figure out how to integrate the C++ binding framework we use with our CI pipeline. + +.. In the meantime, here's a work around you can try to install the latest version of easygraph on your machine: + +- **Prerequisites** + +``3.8 <= Python <= 3.11`` is required. + +.. Installation with ``pip`` (outdated) + +- **Installation with** ``pip`` + +.. code:: + + $ pip install --upgrade Python-EasyGraph + +The conda package is no longer updated or maintained. + +If you've installed EasyGraph this way before, please uninstall it with ``conda`` and install it with ``pip``. + +If prebuilt EasyGraph wheels are not supported for your platform (OS / CPU arch, check `here `_), you can build it locally this way: + +.. code:: bash + + git clone https://github.com/easy-graph/Easy-Graph && cd Easy-Graph && git checkout pybind11 + pip install pybind11 + python3 setup.py build_ext + python3 setup.py install + +- **Hint** + + EasyGraph uses 1.12.1 <= `PyTorch `_ < 2.0 for machine + learning functions. + Note that this does not prevent your from running non-machine learning functions normally, + if there is no PyTorch in your environment. + But you will receive some warnings which remind you some unavailable modules when they depend on it. + +Simple Example +-------------- + + +This example shows the general usage of methods in EasyGraph. + +.. code:: python + + >>> import easygraph as eg + >>> G = eg.Graph() + >>> G.add_edges([(1,2), (2,3), (1,3), (3,4), (4,5), (3,5), (5,6)]) + >>> eg.pagerank(G) + {1: 0.14272233049003707, 2: 0.14272233049003694, 3: 0.2685427766200994, 4: 0.14336430577918527, 5: 0.21634929087322705, 6: 0.0862989657474143} + +This is a simple example for the detection of `structural hole spanners `_ +using the `HIS `_ algorithm. + +.. code:: python + + >>> import easygraph as eg + >>> G = eg.Graph() + >>> G.add_edges([(1,2), (2,3), (1,3), (3,4), (4,5), (3,5), (5,6)]) + >>> _, _, H = eg.get_structural_holes_HIS(G, C=[frozenset([1,2,3]), frozenset([4,5,6])]) + >>> H # The structural hole score of each node. Note that node `4` is regarded as the most possible structural hole spanner. + {1: {0: 0.703948974609375}, + 2: {0: 0.703948974609375}, + 3: {0: 1.2799804687499998}, + 4: {0: 1.519976806640625}, + 5: {0: 1.519976806640625}, + 6: {0: 0.83595703125} + } + +Citation +-------- + +If you use EasyGraph in a scientific publication, we would appreciate citations to the following paper: + +.. code:: bash + + @article{gao2023easygraph, + title={{EasyGraph: A Multifunctional, Cross-Platform, and Effective Library for Interdisciplinary Network Analysis}}, + author={Min Gao and Zheng Li and Ruichen Li and Chenhao Cui and Xinyuan Chen and Bodian Ye and Yupeng Li and Weiwei Gu and Qingyuan Gong and Xin Wang and Yang Chen}, + year={2023}, + journal={Patterns}, + volume={4}, + number={10} + } diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..85d39cd --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`easy-graph/Easy-Graph` +- 原始仓库:https://github.com/easy-graph/Easy-Graph +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 0000000..efeaf40 --- /dev/null +++ b/_typos.toml @@ -0,0 +1,23 @@ +[default] +# extend-ignore-identifiers-re = [ +# # *sigh* this just isn't worth the cost of fixing +# "AttributeID.*Supress.*", +# ] + +[default.extend-identifiers] +# *sigh* this just isn't worth the cost of fixing +# AttributeIDSupressMenu = "AttributeIDSupressMenu" + +[default.extend-words] +# Don't correct the surname "Teh" +# teh = "teh" +2nd = "2nd" +l_2nd = 'l_2nd' +loss_2nd = 'loss_2nd' +nd = 'nd' + +[files] +extend-exclude = [ + "_typos.toml", + "easygraph/functions/graph_embedding/embedding-citeseer.ipynb", +] diff --git a/cpp_easygraph/CMakeLists.txt b/cpp_easygraph/CMakeLists.txt new file mode 100644 index 0000000..ae15d7c --- /dev/null +++ b/cpp_easygraph/CMakeLists.txt @@ -0,0 +1,83 @@ +cmake_minimum_required(VERSION 3.23) +project(cpp_easygraph) +set(CMAKE_CXX_STANDARD 11) + +file(GLOB SOURCES + classes/*.cpp + common/*.cpp + functions/*/*.cpp + cpp_easygraph.cpp +) + +add_subdirectory(pybind11) + +option(EASYGRAPH_ENABLE_OPENMP "Enable OpenMP acceleration (auto-detect)" ON) +option(EASYGRAPH_ENABLE_GPU "EASYGRAPH_ENABLE_GPU" OFF) + +if (EASYGRAPH_ENABLE_GPU) + + pybind11_add_module(cpp_easygraph + ${SOURCES} + $ + ) + + set_property(TARGET cpp_easygraph PROPERTY CUDA_ARCHITECTURES all) + + target_compile_definitions(cpp_easygraph + PRIVATE EASYGRAPH_ENABLE_GPU + ) + + target_link_libraries(cpp_easygraph + PRIVATE cudart_static + ) + +else() + pybind11_add_module(cpp_easygraph + ${SOURCES} + ) + +endif() + +if (EASYGRAPH_ENABLE_OPENMP) + if (APPLE AND CMAKE_CXX_COMPILER_ID MATCHES "Clang") + find_path(EG_LIBOMP_INCLUDE_DIR + NAMES omp.h + PATHS + /opt/homebrew/opt/libomp/include + /usr/local/opt/libomp/include + ) + + find_library(EG_LIBOMP_LIBRARY + NAMES omp libomp + PATHS + /opt/homebrew/opt/libomp/lib + /usr/local/opt/libomp/lib + ) + + if (EG_LIBOMP_INCLUDE_DIR AND EG_LIBOMP_LIBRARY) + message(STATUS "libomp found: ${EG_LIBOMP_LIBRARY}") + + set(OpenMP_C_FLAGS "-Xpreprocessor -fopenmp" CACHE STRING "" FORCE) + set(OpenMP_CXX_FLAGS "-Xpreprocessor -fopenmp" CACHE STRING "" FORCE) + + set(OpenMP_CXX_INCLUDE_DIR "${EG_LIBOMP_INCLUDE_DIR}" CACHE PATH "" FORCE) + set(OpenMP_omp_LIBRARY "${EG_LIBOMP_LIBRARY}" CACHE FILEPATH "" FORCE) + else() + message(STATUS + "libomp not found on macOS. OpenMP will be disabled.\n" + "To enable OpenMP, run: brew install libomp" + ) + endif() + endif() + + find_package(OpenMP QUIET) +endif() + +if (OpenMP_CXX_FOUND) + message(STATUS "OpenMP found, enabling parallel acceleration.") + target_link_libraries(cpp_easygraph PRIVATE OpenMP::OpenMP_CXX) + target_compile_definitions(cpp_easygraph PRIVATE EASYGRAPH_USE_OPENMP=1) +else() + message(STATUS "OpenMP not found, building in single-thread mode.") + target_compile_definitions(cpp_easygraph PRIVATE EASYGRAPH_USE_OPENMP=0) +endif() \ No newline at end of file diff --git a/cpp_easygraph/classes/__init__.h b/cpp_easygraph/classes/__init__.h new file mode 100644 index 0000000..a9bd9b0 --- /dev/null +++ b/cpp_easygraph/classes/__init__.h @@ -0,0 +1,5 @@ +#pragma once + +#include "graph.h" +#include "directed_graph.h" +#include "operation.h" \ No newline at end of file diff --git a/cpp_easygraph/classes/coo_graph.h b/cpp_easygraph/classes/coo_graph.h new file mode 100644 index 0000000..02eb3c8 --- /dev/null +++ b/cpp_easygraph/classes/coo_graph.h @@ -0,0 +1,13 @@ +#pragma once + +#include "../common/common.h" + +struct COOGraph { + std::vector row; + std::vector col; + std::vector unweighted_W; + std::unordered_map>> W_map; + + std::vector nodes; + std::unordered_map node2idx; +}; \ No newline at end of file diff --git a/cpp_easygraph/classes/csr_graph.h b/cpp_easygraph/classes/csr_graph.h new file mode 100644 index 0000000..117a72d --- /dev/null +++ b/cpp_easygraph/classes/csr_graph.h @@ -0,0 +1,13 @@ +#pragma once + +#include "../common/common.h" + +struct CSRGraph { + std::vector V; + std::vector E; + std::vector unweighted_W; + std::unordered_map>> W_map; + + std::vector nodes; + std::unordered_map node2idx; +}; \ No newline at end of file diff --git a/cpp_easygraph/classes/directed_graph.cpp b/cpp_easygraph/classes/directed_graph.cpp new file mode 100644 index 0000000..e148680 --- /dev/null +++ b/cpp_easygraph/classes/directed_graph.cpp @@ -0,0 +1,613 @@ +#include "directed_graph.h" + +#include "../common/utils.h" + +DiGraph::DiGraph() : Graph() { + +} + +py::object DiGraph__init__(py::args args, py::kwargs kwargs) { + py::object self = args[0]; + self.attr("__init__")(); + DiGraph& self_ = self.cast(); + py::dict graph_attr = kwargs; + self_.graph.attr("update")(graph_attr); + self_.nodes_cache = py::dict(); + self_.adj_cache = py::dict(); + return py::none(); +} + +py::object DiGraph_out_degree(py::object self, py::object weight) { + py::dict degree = py::dict(); + py::list edges = self.attr("edges").cast(); + py::object u, v; + py::dict d; + for (int i = 0; i < py::len(edges); i++) { + py::tuple edge = edges[i].cast(); + u = edge[0]; + v = edge[1]; + d = edge[2].cast(); + if (degree.contains(u)) { + degree[u] = py::object(degree[u]) + d.attr("get")(weight, 1); + } else { + degree[u] = d.attr("get")(weight, 1); + } + } + py::list nodes = py::list(self.attr("nodes")); + for (int i = 0; i < py::len(nodes); i++) { + py::object node = nodes[i]; + if (!degree.contains(node)) { + degree[node] = 0; + } + } + return degree; +} + +py::object DiGraph_in_degree(py::object self, py::object weight) { + py::dict degree = py::dict(); + py::list edges = self.attr("edges").cast(); + py::object u, v; + py::dict d; + for (int i = 0; i < py::len(edges); i++) { + py::tuple edge = edges[i].cast(); + u = edge[0]; + v = edge[1]; + d = edge[2].cast(); + if (degree.contains(v)) { + degree[v] = py::object(degree[v]) + d.attr("get")(weight, 1); + } else { + degree[v] = d.attr("get")(weight, 1); + } + } + py::list nodes = py::list(self.attr("nodes")); + for (int i = 0; i < py::len(nodes); i++) { + py::object node = nodes[i]; + if (!degree.contains(node)) { + degree[node] = 0; + } + } + return degree; +} + +py::object DiGraph_degree(py::object self, py::object weight) { + py::dict degree = py::dict(); + py::dict out_degree = self.attr("out_degree")(weight).cast(); + py::dict in_degree = self.attr("in_degree")(weight).cast(); + py::list nodes = py::list(self.attr("nodes")); + for (int i = 0; i < py::len(nodes); i++) { + py::object u = nodes[i]; + degree[u] = out_degree[u] + in_degree[u]; + } + return degree; +} + +py::object DiGraph_size(py::object self, py::object weight) { + py::dict out_degree = self.attr("out_degree")(weight).cast(); + py::object s = py_sum(out_degree.attr("values")()); + return (weight.is_none()) ? py::int_(s) : s; +} + +py::object DiGraph_number_of_edges(py::object self, py::object u, py::object v) { + if (u.is_none()) { + return self.attr("size")(); + } + Graph& G = self.cast(); + node_t u_id = G.node_to_id[u].cast(); + node_t v_id = G.node_to_id.attr("get")(v, -1).cast(); + return py::cast(int(v_id != -1 && G.adj[u_id].count(v_id))); +} + +py::object DiGraph_neighbors(py::object self, py::object node) { + Graph& self_ = self.cast(); + if (self_.node_to_id.contains(node)) { + return self.attr("adj")[node].attr("__iter__")(); + } else { + PyErr_Format(PyExc_KeyError, "No node %R", node.ptr()); + return py::none(); + } +} + +py::object DiGraph_predecessors(py::object self, py::object node) { + DiGraph& self_ = self.cast(); + adj_dict_factory& pred = self_.pred; + node_t node_id = self_.node_to_id[node].cast(); + if (pred.find(node_id) != pred.end()) { + adj_attr_dict_factory node_pred_dict = pred[node_id]; + py::dict node_pred = py::dict(); + for (adj_attr_dict_factory::iterator i = node_pred_dict.begin(); + i != node_pred_dict.end(); i++) { + edge_attr_dict_factory edge_attr_dict = i->second; + node_pred[self_.id_to_node[py::cast(i->first)]] = attr_to_dict(edge_attr_dict); + } + return node_pred.attr("__iter__")(); + } else { + PyErr_Format(PyExc_KeyError, "No node %R", node.ptr()); + return py::none(); + } +} + +node_t DiGraph_add_one_node(DiGraph& self, py::object one_node_for_adding, + py::object node_attr = py::dict()) { + node_t id; + if (self.node_to_id.contains(one_node_for_adding)) { + id = self.node_to_id[one_node_for_adding].cast(); + } else { + id = ++(self.id); + self.id_to_node[py::cast(id)] = one_node_for_adding; + self.node_to_id[one_node_for_adding] = id; + } + py::list items = py::list(node_attr.attr("items")()); + self.node[id] = node_attr_dict_factory(); + self.adj[id] = adj_attr_dict_factory(); + self.pred[id] = adj_attr_dict_factory(); + + for (int i = 0; i < len(items); i++) { + py::tuple kv = items[i].cast(); + py::object pkey = kv[0]; + std::string weight_key = weight_to_string(pkey); + weight_t value = kv[1].cast(); + self.node[id].insert(std::make_pair(weight_key, value)); + } + return id; +} + +py::object DiGraph_add_node(py::args args, py::kwargs kwargs) { + DiGraph& self = args[0].cast(); + self.dirty_nodes = true; + self.dirty_adj = true; + py::object one_node_for_adding = args[1]; + py::dict node_attr = kwargs; + DiGraph_add_one_node(self, one_node_for_adding, node_attr); + return py::none(); +} + +py::object DiGraph_add_nodes(DiGraph& self, py::list nodes_for_adding, py::list nodes_attr) { + self.dirty_nodes = true; + self.dirty_adj = true; + if (py::len(nodes_attr) != 0) { + if (py::len(nodes_for_adding) != py::len(nodes_attr)) { + PyErr_Format(PyExc_AssertionError, "Nodes and Attributes lists must have same length."); + return py::none(); + } + } + for (int i = 0; i < py::len(nodes_for_adding); i++) { + py::object one_node_for_adding = nodes_for_adding[i]; + py::dict node_attr; + if (py::len(nodes_attr)) { + node_attr = nodes_attr[i].cast(); + } else { + node_attr = py::dict(); + } + DiGraph_add_one_node(self, one_node_for_adding, node_attr); + } + return py::none(); +} + +py::object DiGraph_add_nodes_from(py::args args, py::kwargs kwargs) { + DiGraph& self = args[0].cast(); + self.dirty_nodes = true; + self.dirty_adj = true; + py::list nodes_for_adding = py::list(args[1]); + for (int i = 0; i < py::len(nodes_for_adding); i++) { + bool newnode; + py::dict attr = kwargs; + py::dict newdict, ndict; + py::object n = nodes_for_adding[i]; + try { + newnode = !self.node_to_id.contains(n); + newdict = attr; + } catch (const py::error_already_set&) { + PyObject *type, *value, *traceback; + PyErr_Fetch(&type, &value, &traceback); + if (PyErr_GivenExceptionMatches(PyExc_TypeError, type)) { + py::tuple n_pair = n.cast(); + n = n_pair[0]; + ndict = n_pair[1].cast(); + newnode = !self.node_to_id.contains(n); + newdict = attr.attr("copy")(); + newdict.attr("update")(ndict); + } else { + PyErr_Restore(type, value, traceback); + return py::none(); + } + } + if (newnode) { + if (n.is_none()) { + PyErr_Format(PyExc_ValueError, "None cannot be a node"); + return py::none(); + } + DiGraph_add_one_node(self, n); + } + node_t id = self.node_to_id[n].cast(); + py::list items = py::list(newdict.attr("items")()); + for (int i = 0; i < len(items); i++) { + py::tuple kv = items[i].cast(); + py::object pkey = kv[0]; + std::string weight_key = weight_to_string(pkey); + weight_t value = kv[1].cast(); + self.node[id].insert(std::make_pair(weight_key, value)); + } + } + return py::none(); +} + +py::object DiGraph_remove_node(DiGraph& self, py::object node_to_remove) { + self.dirty_nodes = true; + self.dirty_adj = true; + if (!self.node_to_id.contains(node_to_remove)) { + PyErr_Format(PyExc_KeyError, "No node %R in graph.", node_to_remove.ptr()); + return py::none(); + } + node_t node_to_remove_id = self.node_to_id[node_to_remove].cast(); + adj_attr_dict_factory succs = self.adj[node_to_remove_id]; + adj_attr_dict_factory preds = self.pred[node_to_remove_id]; + self.node.erase(node_to_remove_id); + for (adj_attr_dict_factory::iterator i = succs.begin(); i != succs.end(); i++) { + self.pred[i->first].erase(node_to_remove_id); + } + for (adj_attr_dict_factory::iterator i = preds.begin(); i != preds.end(); i++) { + self.adj[i->first].erase(node_to_remove_id); + } + self.adj.erase(node_to_remove_id); + self.pred.erase(node_to_remove_id); + self.node_to_id.attr("pop")(node_to_remove); + self.id_to_node.attr("pop")(node_to_remove_id); + return py::none(); +} + +py::object DiGraph_remove_nodes(py::object self, py::list nodes_to_remove) { + DiGraph& self_ = self.cast(); + self_.dirty_nodes = true; + self_.dirty_adj = true; + for (int i = 0; i < py::len(nodes_to_remove); i++) { + py::object node_to_remove = nodes_to_remove[i]; + if (!self_.node_to_id.contains(node_to_remove)) { + PyErr_Format(PyExc_KeyError, "No node %R in graph.", node_to_remove.ptr()); + return py::none(); + } + } + for (int i = 0; i < py::len(nodes_to_remove); i++) { + py::object node_to_remove = nodes_to_remove[i]; + self.attr("remove_node")(node_to_remove); + } + return py::none(); +} + +void DiGraph_add_one_edge(DiGraph& self, py::object u_of_edge, + py::object v_of_edge, py::object edge_attr) { + node_t u, v; + if (!self.node_to_id.contains(u_of_edge)) { + u = DiGraph_add_one_node(self, u_of_edge); + } else { + u = self.node_to_id[u_of_edge].cast(); + } + if (!self.node_to_id.contains(v_of_edge)) { + v = DiGraph_add_one_node(self, v_of_edge); + } else { + v = self.node_to_id[v_of_edge].cast(); + } + py::list items = py::list(edge_attr.attr("items")()); + self.adj[u][v] = node_attr_dict_factory(); + self.pred[v][u] = edge_attr_dict_factory(); + for (int i = 0; i < len(items); i++) { + py::tuple kv = items[i].cast(); + py::object pkey = kv[0]; + std::string weight_key = weight_to_string(pkey); + weight_t value = kv[1].cast(); + self.adj[u][v].insert(std::make_pair(weight_key, value)); + self.pred[v][u].insert(std::make_pair(weight_key, value)); + } +} + +py::object DiGraph_add_edge(py::args args, py::kwargs kwargs) { + DiGraph& self = args[0].cast(); + self.dirty_nodes = true; + self.dirty_adj = true; + py::object u_of_edge = args[1], v_of_edge = args[2]; + py::dict edge_attr = kwargs; + DiGraph_add_one_edge(self, u_of_edge, v_of_edge, edge_attr); + return py::none(); +} + +py::object DiGraph_add_edges(DiGraph& self, py::list edges_for_adding, py::list edges_attr) { + self.dirty_nodes = true; + self.dirty_adj = true; + if (py::len(edges_attr) != 0) { + if (py::len(edges_for_adding) != py::len(edges_attr)) { + PyErr_Format(PyExc_AssertionError, "Edges and Attributes lists must have same length."); + return py::none(); + } + } + for (int i = 0; i < py::len(edges_for_adding); i++) { + py::tuple one_edge_for_adding = edges_for_adding[i].cast(); + py::dict edge_attr; + if (py::len(edges_attr)) { + edge_attr = edges_attr[i].cast(); + } else { + edge_attr = py::dict(); + } + DiGraph_add_one_edge(self, one_edge_for_adding[0], one_edge_for_adding[1], edge_attr); + } + return py::none(); +} + +py::object DiGraph_add_edges_from(py::args args, py::kwargs attr) { + DiGraph& self = args[0].cast(); + self.dirty_nodes = true; + self.dirty_adj = true; + py::list ebunch_to_add = py::list(args[1]); + for (int i = 0; i < len(ebunch_to_add); i++) { + py::list e = py::list(ebunch_to_add[i]); + py::object u, v; + py::dict dd; + switch (len(e)) { + case 2: { + u = e[0]; + v = e[1]; + break; + } + case 3: { + u = e[0]; + v = e[1]; + dd = e[2].cast(); + break; + } + default: { + PyErr_Format(PyExc_ValueError, "Edge tuple %R must be a 2 - tuple or 3 - tuple.", e.ptr()); + return py::none(); + } + } + node_t u_id, v_id; + if (!self.node_to_id.contains(u)) { + if (u.is_none()) { + PyErr_Format(PyExc_ValueError, "None cannot be a node"); + return py::none(); + } + u_id = DiGraph_add_one_node(self, u); + + } else { + u_id = self.node_to_id[u].cast(); + } + if (!self.node_to_id.contains(v)) { + if (v.is_none()) { + PyErr_Format(PyExc_ValueError, "None cannot be a node"); + return py::none(); + } + v_id = DiGraph_add_one_node(self, v); + + } else { + v_id = self.node_to_id[v].cast(); + } + auto datadict = self.adj[u_id].count(v_id) ? self.adj[u_id][v_id] : node_attr_dict_factory(); + py::list items = py::list(attr.attr("items")()); + items.attr("extend")(py::list(dd.attr("items")())); + for (int j = 0; j < py::len(items); j++) { + py::tuple kv = items[j].cast(); + py::object pkey = kv[0]; + std::string weight_key = weight_to_string(pkey); + weight_t value = kv[1].cast(); + datadict.insert(std::make_pair(weight_key, value)); + } + // Warning: in Graph.py the edge attr is directed assigned by the dict extended from the original attr + self.adj[u_id][v_id].insert(datadict.begin(), datadict.end()); + } + return py::none(); +} + +py::object DiGraph_add_edges_from_file(DiGraph& self, py::str file, py::object weighted, py::object is_transform) { + self.dirty_nodes = true; + self.dirty_adj = true; + bool _is_transform = is_transform.cast(); + struct commactype : std::ctype { + commactype() : std::ctype(get_table()) {} + std::ctype_base::mask const* get_table() { + std::ctype_base::mask* rc = 0; + if (rc == 0) { + rc = new std::ctype_base::mask[std::ctype::table_size]; + std::fill_n(rc, std::ctype::table_size, std::ctype_base::mask()); + rc[','] = std::ctype_base::space; + rc[' '] = std::ctype_base::space; + rc['\t'] = std::ctype_base::space; + rc['\n'] = std::ctype_base::space; + rc['\r'] = std::ctype_base::space; + } + return rc; + } + }; + + std::ios::sync_with_stdio(0); + std::string file_path = file.cast(); + std::ifstream in; + in.open(file_path); + if (!in.is_open()) { + PyErr_Format(PyExc_FileNotFoundError, "Please check the file and make sure the path only contains English"); + return py::none(); + } + in.imbue(std::locale(std::locale(), new commactype)); + std::string data, key("weight"); + std::string su, sv; + weight_t weight; + while (in >> su >> sv) { + py::str pu(su), pv(sv); + node_t u, v; + if (!self.node_to_id.contains(pu)) { + u = DiGraph_add_one_node(self, pu); + } else { + u = self.node_to_id[pu].cast(); + } + if (!self.node_to_id.contains(pv)) { + v = DiGraph_add_one_node(self, pv); + } else { + v = self.node_to_id[pv].cast(); + } + if (weighted.cast()) { + in >> weight; + self.adj[u][v][key] = weight; + self.pred[v][u][key] = weight; + } else { + if (!self.adj[u].count(v)) { + self.adj[u][v] = node_attr_dict_factory(); + self.pred[v][u] = node_attr_dict_factory(); + } + } + } + if(_is_transform){ + Graph_L g_l = graph_to_linkgraph(self,true, key, true, false); + self.linkgraph_structure = g_l; + self.linkgraph_dirty = false; + } + in.close(); + return py::none(); +} + +py::object DiGraph_add_weighted_edge(DiGraph& self, py::object u_of_edge, py::object v_of_edge, weight_t weight) { + self.dirty_nodes = true; + self.dirty_adj = true; + py::dict edge_attr; + edge_attr["weight"] = weight; + DiGraph_add_one_edge(self, u_of_edge, v_of_edge, edge_attr); + return py::none(); +} + +py::object DiGraph_remove_edge(DiGraph& self, py::object u, py::object v) { + self.dirty_nodes = true; + self.dirty_adj = true; + if (self.node_to_id.contains(u) && self.node_to_id.contains(v)) { + node_t u_id = self.node_to_id[u].cast(); + node_t v_id = self.node_to_id[v].cast(); + auto& u_neighbors_info = self.adj[u_id]; + if (u_neighbors_info.find(v_id) != u_neighbors_info.end()) { + u_neighbors_info.erase(v_id); + auto& v_predecessors_info = self.pred[v_id]; + v_predecessors_info.erase(u_id); + return py::none(); + } + } + PyErr_Format(PyExc_KeyError, "No edge %R-%R in graph.", u.ptr(), v.ptr()); + return py::none(); +} + +py::object DiGraph_remove_edges(py::object self, py::list edges_to_remove) { + DiGraph& self_ = self.cast(); + for (int i = 0; i < py::len(edges_to_remove); i++) { + py::tuple edge = edges_to_remove[i].cast(); + py::object u = edge[0], v = edge[1]; + self.attr("remove_edge")(u, v); + } + self_.dirty_nodes = true; + self_.dirty_adj = true; + return py::none(); +} + +py::object DiGraph_remove_edges_from(py::object self, py::list ebunch) { + DiGraph& self_ = self.cast(); + for (int i = 0; i < py::len(ebunch); i++) { + py::tuple edge = ebunch[i].cast(); + node_t u_id = edge[0].cast(); + node_t v_id = edge[1].cast(); + if (self_.adj[u_id].find(v_id) != self_.adj[u_id].end() && self_.adj[v_id].find(u_id) != self_.adj[v_id].end()) { + self_.adj[u_id].erase(v_id); + self_.pred[v_id].erase(u_id); + } + } + return py::none(); +} + +py::object DiGraph_nodes_subgraph(py::object self, py::list from_nodes) { + py::object G = self.attr("__class__")(); + Graph& self_ = self.cast(); + DiGraph& G_ = G.cast(); + G_.graph.attr("update")(self_.graph); + py::object nodes = self.attr("nodes"); + py::object adj = self.attr("adj"); + for (int i = 0; i < py::len(from_nodes); i++) { + py::object node = from_nodes[i]; + if (self_.node_to_id.contains(node)) { + py::object node_attr = nodes[node]; + DiGraph_add_one_node(G_, node, node_attr); + } + py::object out_edges = adj[node]; + py::list edge_items = py::list(out_edges.attr("items")()); + for (int j = 0; j < py::len(edge_items); j++) { + py::tuple item = edge_items[j].cast(); + py::object v = item[0]; + py::object edge_attr = item[1]; + if (from_nodes.contains(v)) { + DiGraph_add_one_edge(G_, node, v, edge_attr); + } + } + } + return G; +} + +py::object DiGraph_generate_linkgraph(py::object self, py::object weight){ + DiGraph& G_ = self.cast(); + std::string w = weight_to_string(weight); + Graph_L g_l = graph_to_linkgraph(G_, true, w, true, false); + G_.linkgraph_dirty = false; + G_.linkgraph_structure = g_l; + return py::none(); +} + +py::object DiGraph_copy(py::object self) { + DiGraph& self_ = self.cast(); + py::object G = self.attr("__class__")(); + DiGraph& G_ = G.cast(); + G_.graph.attr("update")(self_.graph); + G_.id_to_node.attr("update")(self_.id_to_node); + G_.node_to_id.attr("update")(self_.node_to_id); + G_.node = self_.node; + G_.adj = self_.adj; + G_.pred = self_.pred; + return py::object(G); +} + +py::object DiGraph_is_directed(py::object self) { + return py::cast(true); +} + +py::object DiGraph_py(py::object self) { + py::object G = py::module_::import("easygraph").attr("DiGraph")(); + G.attr("graph").attr("update")(self.attr("graph")); + G.attr("adj").attr("update")(self.attr("adj")); + G.attr("nodes").attr("update")(self.attr("nodes")); + G.attr("pred").attr("update")(self.attr("pred")); +// G.attr("succ").attr("update")(self.attr("succ")); + return G; +} + +py::object DiGraph::get_pred() { + adj_dict_factory pred = this->pred; + py::dict predecessors = py::dict(); + for (const auto& ego_edges : this->pred) { + node_t start_point = ego_edges.first; + py::dict ego_edges_dict = py::dict(); + for (const auto& edge_info : ego_edges.second) { + node_t end_point = edge_info.first; + const auto& edge_attr = edge_info.second; + ego_edges_dict[this->id_to_node[py::cast(end_point)]] = attr_to_dict(edge_attr); + } + predecessors[this->id_to_node[py::cast(start_point)]] = ego_edges_dict; + } + + return predecessors; +} + +py::object DiGraph::get_edges() { + py::list edges = py::list(); + std::set > seen; + for (const auto& ego_edges : this->adj) { + node_t u = ego_edges.first; + for (const auto& edge_info : ego_edges.second) { + node_t v = edge_info.first; + const auto& edge_attr = edge_info.second; + if (seen.find(std::make_pair(u, v)) == seen.end()) { + seen.insert(std::make_pair(u, v)); + edges.append(py::make_tuple(this->id_to_node[py::cast(u)], this->id_to_node[py::cast(v)], + attr_to_dict(edge_attr))); + } + } + } + return edges; +} + diff --git a/cpp_easygraph/classes/directed_graph.h b/cpp_easygraph/classes/directed_graph.h new file mode 100644 index 0000000..69d7f52 --- /dev/null +++ b/cpp_easygraph/classes/directed_graph.h @@ -0,0 +1,37 @@ +#pragma once + +#include "../common/common.h" +#include "graph.h" + +struct DiGraph : public Graph { + DiGraph(); + py::object get_edges(); + py::object get_pred(); + + adj_dict_factory pred; +}; + +py::object DiGraph__init__(py::args args, py::kwargs kwargs); +py::object DiGraph_out_degree(py::object self, py::object weight); +py::object DiGraph_in_degree(py::object self, py::object weight); +py::object DiGraph_degree(py::object self, py::object weight); +py::object DiGraph_size(py::object self, py::object weight); +py::object DiGraph_number_of_edges(py::object self, py::object u, py::object v); +py::object DiGraph_predecessors(py::object self, py::object node); +py::object DiGraph_add_node(py::args args, py::kwargs kwargs); +py::object DiGraph_add_nodes(DiGraph& self, py::list nodes_for_adding, py::list nodes_attr); +py::object DiGraph_add_nodes_from(py::args args, py::kwargs kwargs); +py::object DiGraph_remove_node(DiGraph& self, py::object node_to_remove); +py::object DiGraph_remove_nodes(py::object self, py::list nodes_to_remove); +py::object DiGraph_add_edge(py::args args, py::kwargs kwargs); +py::object DiGraph_add_edges(DiGraph& self, py::list edges_for_adding, py::list edges_attr); +py::object DiGraph_add_edges_from_file(DiGraph& self, py::str file, py::object weighted, py::object is_transform); +py::object DiGraph_add_edges_from(py::args args, py::kwargs attr); +py::object DiGraph_remove_edge(DiGraph& self, py::object u, py::object v); +py::object DiGraph_remove_edges(py::object self, py::list edges_to_remove); +py::object DiGraph_remove_edges_from(py::object self, py::list ebunch); +py::object DiGraph_add_weighted_edge(DiGraph& self, py::object u_of_edge, py::object v_of_edge, weight_t weight); +py::object DiGraph_nodes_subgraph(py::object self, py::list from_nodes); +py::object DiGraph_is_directed(py::object self); +py::object DiGraph_py(py::object self); +py::object DiGraph_generate_linkgraph(py::object self, py::object weight); \ No newline at end of file diff --git a/cpp_easygraph/classes/graph.cpp b/cpp_easygraph/classes/graph.cpp new file mode 100644 index 0000000..122469b --- /dev/null +++ b/cpp_easygraph/classes/graph.cpp @@ -0,0 +1,990 @@ +#include "graph.h" +#include "linkgraph.h" +#include "../common/utils.h" + + +Graph::Graph() { + this->id = 0; + this->dirty_nodes = true; + this->dirty_adj = true; + this->linkgraph_dirty = true; + this->csr_graph = nullptr; + this->node_to_id = py::dict(); + this->id_to_node = py::dict(); + this->graph = py::dict(); + this->nodes_cache = py::dict(); + this->adj_cache = py::dict(); + this->coo_graph = nullptr; +} + +py::object Graph__init__(py::args args, py::kwargs kwargs) { + py::object self = args[0]; + self.attr("__init__")(); + Graph& self_ = self.cast(); + py::dict graph_attr = kwargs; + self_.graph.attr("update")(graph_attr); + self_.nodes_cache = py::dict(); + self_.adj_cache = py::dict(); + return py::none(); +} + +py::object Graph__iter__(py::object self) { + return self.attr("nodes").attr("__iter__")(); +} + +py::object Graph__len__(py::object self) { + Graph& self_ = self.cast(); + return py::cast(py::len(self_.node_to_id)); +} + +py::object Graph__contains__(py::object self, py::object node) { + Graph& self_ = self.cast(); + try { + return py::cast(self_.node_to_id.contains(node)); + } catch (const py::error_already_set&) { + PyObject *type, *value, *traceback; + PyErr_Fetch(&type, &value, &traceback); + if (PyErr_GivenExceptionMatches(PyExc_TypeError, type)) { + return py::cast(false); + } else { + PyErr_Restore(type, value, traceback); + return py::none(); + } + } +} + +py::object Graph__getitem__(py::object self, py::object node) { + return self.attr("adj")[node]; +} + +node_t _add_one_node(Graph& self, py::object one_node_for_adding, py::object node_attr = py::dict()) { + node_t id; + if (self.node_to_id.contains(one_node_for_adding)) { + id = self.node_to_id[one_node_for_adding].cast(); + } else { + id = ++(self.id); + self.id_to_node[py::cast(id)] = one_node_for_adding; + self.node_to_id[one_node_for_adding] = id; + } + py::list items = py::list(node_attr.attr("items")()); + self.node[id] = node_attr_dict_factory(); + self.adj[id] = adj_attr_dict_factory(); + for (int i = 0; i < len(items); i++) { + py::tuple kv = items[i].cast(); + py::object pkey = kv[0]; + std::string weight_key = weight_to_string(pkey); + weight_t value = kv[1].cast(); + self.node[id].insert(std::make_pair(weight_key, value)); + } + return id; +} + +py::object Graph_add_node(py::args args, py::kwargs kwargs) { + Graph& self = args[0].cast(); + self.drop_cache(); + py::object one_node_for_adding = args[1]; + py::dict node_attr = kwargs; + _add_one_node(self, one_node_for_adding, node_attr); + return py::none(); +} + +py::object Graph_add_nodes(Graph& self, py::list nodes_for_adding, py::list nodes_attr) { + self.drop_cache(); + if (py::len(nodes_attr) != 0) { + if (py::len(nodes_for_adding) != py::len(nodes_attr)) { + PyErr_Format(PyExc_AssertionError, "Nodes and Attributes lists must have same length."); + return py::none(); + } + } + for (int i = 0; i < py::len(nodes_for_adding); i++) { + py::object one_node_for_adding = nodes_for_adding[i]; + py::dict node_attr; + if (py::len(nodes_attr)) { + node_attr = nodes_attr[i].cast(); + } else { + node_attr = py::dict(); + } + _add_one_node(self, one_node_for_adding, node_attr); + } + return py::none(); +} + +py::object Graph_add_nodes_from(py::args args, py::kwargs kwargs) { + Graph& self = args[0].cast(); + self.drop_cache(); + py::list nodes_for_adding = py::list(args[1]); + for (int i = 0; i < py::len(nodes_for_adding); i++) { + bool newnode; + py::dict attr = kwargs; + py::dict newdict, ndict; + py::object n = nodes_for_adding[i]; + try { + newnode = !self.node_to_id.contains(n); + newdict = attr; + } + catch (const py::error_already_set&) { + PyObject *type, *value, *traceback; + PyErr_Fetch(&type, &value, &traceback); + if (PyErr_GivenExceptionMatches(PyExc_TypeError, type)) { + py::tuple n_pair = n.cast(); + n = n_pair[0]; + ndict = n_pair[1].cast(); + newnode = !self.node_to_id.contains(n); + newdict = attr.attr("copy")(); + newdict.attr("update")(ndict); + } else { + PyErr_Restore(type, value, traceback); + return py::none(); + } + } + if (newnode) { + if (n.is_none()) { + PyErr_Format(PyExc_ValueError, "None cannot be a node"); + return py::none(); + } + _add_one_node(self, n); + } + node_t id = self.node_to_id[n].cast(); + for (auto item : newdict) { + std::string weight_key = weight_to_string(item.first.cast()); + weight_t value = item.second.cast(); + self.node[id].insert(std::make_pair(weight_key, value)); + } + } + return py::none(); +} + +py::object Graph_remove_node(Graph& self, py::object node_to_remove) { + self.drop_cache(); + if (!self.node_to_id.contains(node_to_remove)) { + PyErr_Format(PyExc_KeyError, "No node %R in graph.", node_to_remove.ptr()); + return py::none(); + } + node_t node_id = self.node_to_id[node_to_remove].cast(); + for (const auto& neighbor_info : self.adj[node_id]) { + node_t neighbor_id = neighbor_info.first; + self.adj[neighbor_id].erase(node_id); + } + self.adj.erase(node_id); + self.node.erase(node_id); + self.node_to_id.attr("pop")(node_to_remove); + self.id_to_node.attr("pop")(node_id); + return py::none(); +} + +py::object Graph_remove_nodes(py::object self, py::list nodes_to_remove) { + Graph& self_ = self.cast(); + self_.drop_cache(); + for (int i = 0; i < py::len(nodes_to_remove); i++) { + py::object node_to_remove = nodes_to_remove[i]; + if (!self_.node_to_id.contains(node_to_remove)) { + PyErr_Format(PyExc_KeyError, "No node %R in graph.", node_to_remove.ptr()); + return py::none(); + } + } + for (int i = 0; i < py::len(nodes_to_remove); i++) { + py::object node_to_remove = nodes_to_remove[i]; + self.attr("remove_node")(node_to_remove); + } + return py::none(); +} + +py::object Graph_number_of_nodes(Graph& self) { + return py::cast(int(self.node.size())); +} + +py::object Graph_has_node(Graph& self, py::object node) { + return py::cast(self.node_to_id.contains(node)); +} + +py::object Graph_nbunch_iter(py::object self, py::object nbunch) { + py::object bunch = py::none(); + if (nbunch.is_none()) { + bunch = self.attr("adj").attr("__iter__")(); + } else if (self.contains(nbunch)) { + py::list nbunch_wrapper = py::list(); + nbunch_wrapper.append(nbunch); + bunch = nbunch_wrapper.attr("__iter__")(); + } else { + py::list nbunch_list = py::list(nbunch), nodes_list = py::list(); + for (int i = 0; i < py::len(nbunch_list); i++) { + py::object n = nbunch_list[i]; + if (self.contains(n)) { + nodes_list.append(n); + } + } + bunch = nbunch_list.attr("__iter__")(); + } + return bunch; +} + +void _add_one_edge(Graph& self, py::object u_of_edge, py::object v_of_edge, py::object edge_attr) { + node_t u, v; + if (!self.node_to_id.contains(u_of_edge)) { + u = _add_one_node(self, u_of_edge); + } else { + u = self.node_to_id[u_of_edge].cast(); + } + if (!self.node_to_id.contains(v_of_edge)) { + v = _add_one_node(self, v_of_edge); + } else { + v = self.node_to_id[v_of_edge].cast(); + } + py::list items = py::list(edge_attr.attr("items")()); + self.adj[u][v] = node_attr_dict_factory(); + self.adj[v][u] = node_attr_dict_factory(); + for (int i = 0; i < len(items); i++) { + py::tuple kv = items[i].cast(); + py::object pkey = kv[0]; + std::string weight_key = weight_to_string(pkey); + weight_t value = kv[1].cast(); + self.adj[u][v].insert(std::make_pair(weight_key, value)); + self.adj[v][u].insert(std::make_pair(weight_key, value)); + } +} + +py::object Graph_add_edge(py::args args, py::kwargs kwargs) { + Graph& self = args[0].cast(); + self.drop_cache(); + py::object u_of_edge = args[1], v_of_edge = args[2]; + py::dict edge_attr = kwargs; + _add_one_edge(self, u_of_edge, v_of_edge, edge_attr); + return py::none(); +} + +py::object Graph_add_edges(Graph& self, py::list edges_for_adding, py::list edges_attr) { + self.drop_cache(); + if (py::len(edges_attr) != 0) { + if (py::len(edges_for_adding) != py::len(edges_attr)) { + PyErr_Format(PyExc_AssertionError, "Edges and Attributes lists must have same length."); + return py::none(); + } + } + for (int i = 0; i < py::len(edges_for_adding); i++) { + py::tuple one_edge_for_adding = edges_for_adding[i].cast(); + py::dict edge_attr; + if (py::len(edges_attr)) { + edge_attr = edges_attr[i].cast(); + } else { + edge_attr = py::dict(); + } + _add_one_edge(self, one_edge_for_adding[0], one_edge_for_adding[1], edge_attr); + } + return py::none(); +} + +py::object Graph_add_edges_from(py::args args, py::kwargs attr) { + Graph& self = args[0].cast(); + self.drop_cache(); + py::list ebunch_to_add = py::list(args[1]); + for (int i = 0; i < len(ebunch_to_add); i++) { + py::list e = py::list(ebunch_to_add[i]); + py::object u, v; + py::dict dd; + switch (len(e)) { + case 2: { + u = e[0]; + v = e[1]; + break; + } + case 3: { + u = e[0]; + v = e[1]; + dd = e[2].cast(); + break; + } + default: { + PyErr_Format(PyExc_ValueError, "Edge tuple %R must be a 2 - tuple or 3 - tuple.", e.ptr()); + return py::none(); + } + } + node_t u_id, v_id; + if (!self.node_to_id.contains(u)) { + if (u.is_none()) { + PyErr_Format(PyExc_ValueError, "None cannot be a node"); + return py::none(); + } + u_id = _add_one_node(self, u); + } else { + u_id = self.node_to_id[u].cast(); + } + if (!self.node_to_id.contains(v)) { + if (v.is_none()) { + PyErr_Format(PyExc_ValueError, "None cannot be a node"); + return py::none(); + } + v_id = _add_one_node(self, v); + } else { + v_id = (self.node_to_id[v]).cast(); + } + auto datadict = self.adj[u_id].count(v_id) ? self.adj[u_id][v_id] : node_attr_dict_factory(); + py::list items = py::list(attr.attr("items")()); + items.attr("extend")(py::list(dd.attr("items")())); + for (int i = 0; i < py::len(items); i++) { + py::tuple kv = items[i].cast(); + py::object pkey = kv[0]; + std::string weight_key = weight_to_string(pkey); + weight_t value = kv[1].cast(); + datadict.insert(std::make_pair(weight_key, value)); + } + // Warning: in Graph.py the edge attr is directed assigned by the dict extended from the original attr + self.adj[u_id][v_id].insert(datadict.begin(), datadict.end()); + self.adj[v_id][u_id].insert(datadict.begin(), datadict.end()); + } + return py::none(); +} + +py::object Graph_add_edges_from_file(Graph& self, py::str file, py::object weighted, py::object is_transform) { + self.drop_cache(); + bool _is_transform = is_transform.cast(); + struct commactype : std::ctype { + commactype() : std::ctype(get_table()) {} + std::ctype_base::mask const* get_table() { + std::ctype_base::mask* rc = 0; + if (rc == 0) { + rc = new std::ctype_base::mask[std::ctype::table_size]; + std::fill_n(rc, std::ctype::table_size, std::ctype_base::mask()); + rc[','] = std::ctype_base::space; + rc[' '] = std::ctype_base::space; + rc[' '] = std::ctype_base::space; + rc['\t'] = std::ctype_base::space; + rc['\n'] = std::ctype_base::space; + rc['\r'] = std::ctype_base::space; + } + return rc; + } + }; + + std::ios::sync_with_stdio(0); + std::string file_path = file.cast(); + std::ifstream in; + in.open(file_path); + if (!in.is_open()) { + PyErr_Format(PyExc_FileNotFoundError, "Please check the file and make sure the path only contains English"); + return py::none(); + } + in.imbue(std::locale(std::locale(), new commactype)); + std::string data, key("weight"); + std::string su, sv; + weight_t weight; + while (in >> su >> sv) { + py::str pu(su), pv(sv); + node_t u, v; + if (!self.node_to_id.contains(pu)) { + u = _add_one_node(self, pu); + } else { + u = self.node_to_id[pu].cast(); + } + if (!self.node_to_id.contains(pv)) { + v = _add_one_node(self, pv); + } else { + v = self.node_to_id[pv].cast(); + } + if (weighted.cast()) { + in >> weight; + self.adj[u][v][key] = self.adj[v][u][key] = weight; + } else { + if (!self.adj[u].count(v)) { + self.adj[u][v] = node_attr_dict_factory(); + } + if (!self.adj[v].count(u)) { + self.adj[v][u] = node_attr_dict_factory(); + } + } + } + in.close(); + if(_is_transform){ + Graph_L g_l = graph_to_linkgraph(self, false, key, true, false); + self.linkgraph_structure = g_l; + self.linkgraph_dirty = false; + } + return py::none(); +} + +py::object Graph_add_weighted_edge(Graph& self, py::object u_of_edge, py::object v_of_edge, weight_t weight) { + self.drop_cache(); + py::dict edge_attr; + edge_attr["weight"] = weight; + _add_one_edge(self, u_of_edge, v_of_edge, edge_attr); + return py::none(); +} + +py::object Graph_remove_edge(Graph& self, py::object u, py::object v) { + self.drop_cache(); + if (self.node_to_id.contains(u) && self.node_to_id.contains(v)) { + node_t u_id = self.node_to_id[u].cast(); + node_t v_id = self.node_to_id[v].cast(); + auto& v_neighbors_info = self.adj[u_id]; + if (v_neighbors_info.find(v_id) != v_neighbors_info.end()) { + v_neighbors_info.erase(v_id); + if (u_id != v_id) { + self.adj[v_id].erase(u_id); + } + return py::none(); + } + } + PyErr_Format(PyExc_KeyError, "No edge %R-%R in graph.", u.ptr(), v.ptr()); + return py::none(); +} + +py::object Graph_remove_edges(py::object self, py::list edges_to_remove) { + Graph& self_ = self.cast(); + for (int i = 0; i < py::len(edges_to_remove); i++) { + py::tuple edge = edges_to_remove[i].cast(); + py::object u = edge[0], v = edge[1]; + self.attr("remove_edge")(u, v); + } + self_.drop_cache(); + return py::none(); +} + +py::object Graph_number_of_edges(py::object self, py::object u, py::object v) { + if (u.is_none()) { + return self.attr("size")(); + } + Graph& self_ = self.cast(); + node_t u_id = self_.node_to_id.attr("get")(u, -1).cast(); + node_t v_id = self_.node_to_id.attr("get")(v, -1).cast(); + return py::cast(int(self_.adj.count(u_id) && self_.adj[u_id].count(v_id))); +} + +py::object Graph_has_edge(Graph& self, py::object u, py::object v) { + if (self.node_to_id.contains(u) && self.node_to_id.contains(v)) { + node_t u_id = self.node_to_id[u].cast(); + node_t v_id = self.node_to_id[v].cast(); + auto& v_neighbors_info = self.adj[u_id]; + if (v_neighbors_info.find(v_id) != v_neighbors_info.end()) { + return py::cast(true); + } + } + return py::cast(false); +} + +py::object Graph_copy(py::object self) { + Graph& self_ = self.cast(); + py::object G = self.attr("__class__")(); + Graph& G_ = G.cast(); + G_.graph.attr("update")(self_.graph); + G_.id_to_node.attr("update")(self_.id_to_node); + G_.node_to_id.attr("update")(self_.node_to_id); + G_.id = self_.id; + G_.node = self_.node; + G_.adj = self_.adj; + return G; +} + +py::object Graph_degree(py::object self, py::object weight) { + py::dict degree; + py::list edges = self.attr("edges").cast(); + py::object u, v; + py::dict d; + for (int i = 0; i < py::len(edges); i++) { + py::tuple edge = edges[i].cast(); + u = edge[0]; + v = edge[1]; + d = edge[2].cast(); + if (degree.contains(u)) { + degree[u] = py::object(degree[u]) + d.attr("get")(weight, 1); + } else { + degree[u] = d.attr("get")(weight, 1); + } + if (degree.contains(v)) { + degree[v] = py::object(degree[v]) + d.attr("get")(weight, 1); + } else { + degree[v] = d.attr("get")(weight, 1); + } + } + py::list nodes = py::list(self.attr("nodes")); + for (int i = 0; i < py::len(nodes); i++) { + py::object node = nodes[i]; + if (!degree.contains(node)) { + degree[node] = 0; + } + } + return degree; +} + +py::object Graph_neighbors(py::object self, py::object node) { + Graph& self_ = self.cast(); + if (self_.node_to_id.contains(node)) { + return self.attr("adj")[node].attr("__iter__")(); + } else { + PyErr_Format(PyExc_KeyError, "No node %R", node.ptr()); + return py::none(); + } +} + +py::object Graph_generate_linkgraph(py::object self, py::object weight){ + Graph& G_ = self.cast(); + std::string w = weight_to_string(weight); + Graph_L g_l = graph_to_linkgraph(G_, false, w, true, false); + G_.linkgraph_dirty = false; + G_.linkgraph_structure = g_l; + return py::none(); +} + +py::object Graph_nodes_subgraph(py::object self, py::list from_nodes) { + py::object G = self.attr("__class__")(); + Graph& self_ = self.cast(); + Graph& G_ = G.cast(); + G_.graph.attr("update")(self_.graph); + py::object nodes = self.attr("nodes"); + py::object adj = self.attr("adj"); + for (int i = 0; i < py::len(from_nodes); i++) { + py::object node = from_nodes[i]; + if (self_.node_to_id.contains(node)) { + py::object node_attr = nodes[node]; + _add_one_node(G_, node, node_attr); + } + py::object out_edges = adj[node]; + py::list edge_items = py::list(out_edges.attr("items")()); + for (int j = 0; j < py::len(edge_items); j++) { + py::tuple item = edge_items[j].cast(); + py::object v = item[0]; + py::object edge_attr = item[1]; + if (from_nodes.contains(v)) { + _add_one_edge(G_, node, v, edge_attr); + } + } + } + return G; +} + +py::object Graph_ego_subgraph(py::object self, py::object center) { + py::list neighbors_of_center = py::list(self.attr("all_neighbors")(center)); + neighbors_of_center.append(center); + return self.attr("nodes_subgraph")(neighbors_of_center); +} + +py::object Graph_size(py::object self, py::object weight) { + py::dict degree = self.attr("degree")(weight).cast(); + weight_t s = 0; + for (auto item : degree) { + s += item.second.cast(); + } + return (weight.is_none()) ? py::cast(int(s) / 2) : py::cast(s / 2); +} + +py::object Graph_is_directed(py::object self) { + return py::cast(false); +} + +py::object Graph_is_multigraph(py::object self) { + return py::cast(false); +} + +py::object Graph_to_index_node_graph(py::object self, py::object begin_index) { + py::object G = self.attr("__class__")(); + G.attr("graph").attr("update")(self.attr("graph")); + py::dict index_of_node = py::dict(), node_of_index = py::dict(); + int begin = begin_index.cast(); + int index = 0; + for (auto item : self.attr("nodes").cast()) { + py::object node = item.first.cast(); + py::dict node_attr = item.second.cast(); + G.attr("add_node")(py::cast(index + begin), **node_attr); + index_of_node[node] = index + begin; + node_of_index[py::cast(index + begin)] = node; + index++; + } + for (auto item : self.attr("adj").cast()) { + py::object u = item.first.cast(); + py::dict nbrs = item.second.cast(); + for (auto item_ : nbrs) { + py::object v = item_.first.cast(); + py::dict edge_data = item_.second.cast(); + G.attr("add_edge")(index_of_node[u], index_of_node[v], **edge_data); + } + } + return py::make_tuple(G, index_of_node, node_of_index); +} + +py::object Graph_py(py::object self) { + py::object G = py::module_::import("easygraph").attr("Graph")(); + G.attr("graph").attr("update")(self.attr("graph")); + G.attr("adj").attr("update")(self.attr("adj")); + G.attr("nodes").attr("update")(self.attr("nodes")); + return G; +} + +py::object Graph::get_nodes() { + if (this->dirty_nodes) { + py::dict nodes = py::dict(); + for (const auto& node_info : node) { + node_t id = node_info.first; + const auto& node_attr = node_info.second; + nodes[this->id_to_node[py::cast(id)]] = attr_to_dict(node_attr); + } + this->nodes_cache = nodes; + this->dirty_nodes = false; + } + return this->nodes_cache; +} + +py::object Graph::get_name() { + return this->graph.attr("get")("name", ""); +} + +py::object Graph::set_name(py::object name) { + this->graph[py::cast("name")] = name; + return py::none(); +} + +py::object Graph::get_node_index() { + py::dict node_index = py::dict(); + int len = py::len(this->node_to_id); + for(int i = 1; i <= len; i++){ + node_index[this->id_to_node[py::cast(i)]] = py::cast(i - 1); + } + return node_index; +} +py::object Graph::get_graph() { + return this->graph; +} + +py::object Graph::get_adj() { + if (this->dirty_adj) { + py::dict adj = py::dict(); + for (const auto& ego_edges : this->adj) { + node_t start_point = ego_edges.first; + py::dict ego_edges_dict = py::dict(); + for (const auto& edge_info : ego_edges.second) { + node_t end_point = edge_info.first; + const auto& edge_attr = edge_info.second; + ego_edges_dict[this->id_to_node[py::cast(end_point)]] = attr_to_dict(edge_attr); + } + adj[this->id_to_node[py::cast(start_point)]] = ego_edges_dict; + } + this->adj_cache = adj; + this->dirty_adj = false; + } + return this->adj_cache; +} + +py::object Graph::get_edges() { + py::list edges = py::list(); + std::set > seen; + for (const auto& ego_edges : this->adj) { + node_t u = ego_edges.first; + for (const auto& edge_info : ego_edges.second) { + node_t v = edge_info.first; + const auto& edge_attr = edge_info.second; + if (seen.find(std::make_pair(u, v)) == seen.end()) { + seen.insert(std::make_pair(u, v)); + seen.insert(std::make_pair(v, u)); + edges.append(py::make_tuple(this->id_to_node[py::cast(u)], this->id_to_node[py::cast(v)], attr_to_dict(edge_attr))); + } + } + } + return edges; +} + + +Graph_L Graph::_get_linkgraph_structure() { + return this->linkgraph_structure; +} +bool Graph::is_linkgraph_dirty(){ + return this->linkgraph_dirty; +} +std::vector Graph::_get_edges(bool if_directed) { + std::vector edges; + std::set > seen; + for (const auto& ego_edges : this->adj) { + node_t u = ego_edges.first; + for (const auto& edge_info : ego_edges.second) { + node_t v = edge_info.first; + const auto& edge_attr = edge_info.second; + if (seen.find(std::make_pair(u, v)) == seen.end()) { + + seen.insert(std::make_pair(u, v)); + if(!if_directed){ + seen.insert(std::make_pair(v, u)); + } + + edges.emplace_back(u, v, edge_attr); + } + } + } + return edges; +} + +void Graph::drop_cache() { + dirty_nodes = true; + dirty_adj = true; + linkgraph_dirty = true; + csr_graph = nullptr; +} + +std::shared_ptr Graph::gen_CSR(const std::string& weight) { + if (csr_graph != nullptr) { + if (csr_graph->W_map.find(weight) == csr_graph->W_map.end()) { + auto W = std::make_shared>(); + + // According to C++ Standard, the iteration order of a unordered contrainer will + // not change without rehashing which only happens during a insert. + for (node_t n : csr_graph->nodes) { + // if n is not in adj, this way can raise an exception + const auto& n_adjs = adj.find(n)->second; + + for (auto adj_it = n_adjs.begin(); adj_it != n_adjs.end(); ++adj_it) { + const edge_attr_dict_factory& edge_attr = adj_it->second; + auto edge_it = edge_attr.find(weight); + weight_t w = edge_it != edge_attr.end() ? edge_it->second : 1.0; + + W->push_back(w); + } + } + + csr_graph->W_map[weight] = W; + } + } else { + // the graph has been modified + + csr_graph = std::make_shared(); + + std::vector& nodes = csr_graph->nodes; + for (auto it = node.begin(); it != node.end(); ++it) { + nodes.push_back(it->first); + } + + std::sort(nodes.begin(), nodes.end()); + + std::unordered_map& node2idx = csr_graph->node2idx; + + for (int i = 0; i < nodes.size(); ++i) { + node2idx[nodes[i]] = i; + } + + std::vector& V = csr_graph->V; + std::vector& E = csr_graph->E; + auto W = std::make_shared>(); + + for (int idx = 0; idx < nodes.size(); ++idx) { + V.push_back(E.size()); + + node_t n = nodes[idx]; + + // if n is not in adj, this way can raise an exception + const auto& n_adjs = adj.find(n)->second; + + for (auto adj_it = n_adjs.begin(); adj_it != n_adjs.end(); ++adj_it) { + const edge_attr_dict_factory& edge_attr = adj_it->second; + auto edge_it = edge_attr.find(weight); + weight_t w = edge_it != edge_attr.end() ? edge_it->second : 1.0; + + W->push_back(w); + E.push_back(node2idx[adj_it->first]); + } + } + + V.push_back(E.size()); + + csr_graph->W_map[weight] = W; + } + + return csr_graph; +} + +std::shared_ptr Graph::gen_CSR() { + if (csr_graph != nullptr) { + if (csr_graph->unweighted_W.size() != csr_graph->E.size()) { + csr_graph->unweighted_W = std::vector(csr_graph->E.size(), 1.0); + } + } else { + // the graph has been modified + + csr_graph = std::make_shared(); + + std::vector& nodes = csr_graph->nodes; + for (auto it = node.begin(); it != node.end(); ++it) { + nodes.push_back(it->first); + } + + std::sort(nodes.begin(), nodes.end()); + + std::unordered_map& node2idx = csr_graph->node2idx; + + for (int i = 0; i < nodes.size(); ++i) { + node2idx[nodes[i]] = i; + } + + std::vector& V = csr_graph->V; + std::vector& E = csr_graph->E; + + for (int idx = 0; idx < nodes.size(); ++idx) { + V.push_back(E.size()); + + node_t n = nodes[idx]; + + // if n is not in adj, this way can raise an exception + const auto& n_adjs = adj.find(n)->second; + + for (auto adj_it = n_adjs.begin(); adj_it != n_adjs.end(); ++adj_it) { + const edge_attr_dict_factory& edge_attr = adj_it->second; + + E.push_back(node2idx[adj_it->first]); + } + } + + V.push_back(E.size()); + csr_graph->unweighted_W = std::vector(E.size(), 1.0); + } + + return csr_graph; +} + +std::shared_ptr> Graph::gen_CSR_sources(const py::object& py_sources) { + auto sources = std::make_shared>(); + + if (py_sources.is_none()) { + for (int i = 0; i < csr_graph->V.size() - 1; ++i) { + sources->push_back(i); + } + } else { + for (auto it = py_sources.begin(); it != py_sources.end(); ++it) { + sources->push_back(csr_graph->node2idx[node_to_id[*it].cast()]); + } + } + + return sources; +} + +std::shared_ptr Graph::gen_COO() { + if (coo_graph != nullptr) { + if (coo_graph->unweighted_W.size() != coo_graph->row.size()) { + coo_graph->unweighted_W = std::vector(coo_graph->row.size(), 1.0); + } + } else { + + coo_graph = std::make_shared(); + + std::vector& nodes = coo_graph->nodes; + for (auto it = node.begin(); it != node.end(); ++it) { + nodes.push_back(it->first); + } + + std::sort(nodes.begin(), nodes.end()); + + std::unordered_map& node2idx = coo_graph->node2idx; + for (int i = 0; i < nodes.size(); ++i) { + node2idx[nodes[i]] = i; + } + + std::vector& row = coo_graph->row; + std::vector& col = coo_graph->col; + std::vector& unweighted_W = coo_graph->unweighted_W; + + for (int idx = 0; idx < nodes.size(); ++idx) { + node_t n = nodes[idx]; + + const auto& n_adjs = adj.find(n)->second; + + for (auto adj_it = n_adjs.begin(); adj_it != n_adjs.end(); ++adj_it) { + node_t neighbor = adj_it->first; + + row.push_back(idx); + col.push_back(node2idx[neighbor]); + + unweighted_W.push_back(1.0); + } + } + } + + return coo_graph; +} + +std::shared_ptr Graph::gen_COO(const std::string& weight) { + if (coo_graph != nullptr) { + if (coo_graph->W_map.find(weight) == coo_graph->W_map.end()) { + auto W = std::make_shared>(); + + for (node_t n : coo_graph->nodes) { + const auto& n_adjs = adj.find(n)->second; + + for (auto adj_it = n_adjs.begin(); adj_it != n_adjs.end(); ++adj_it) { + const edge_attr_dict_factory& edge_attr = adj_it->second; + auto edge_it = edge_attr.find(weight); + weight_t w = edge_it != edge_attr.end() ? edge_it->second : 1.0; + + W->push_back(w); + } + } + + coo_graph->W_map[weight] = W; + } + } else { + coo_graph = std::make_shared(); + + std::vector& nodes = coo_graph->nodes; + for (auto it = node.begin(); it != node.end(); ++it) { + nodes.push_back(it->first); + } + + std::sort(nodes.begin(), nodes.end()); + + std::unordered_map& node2idx = coo_graph->node2idx; + for (int i = 0; i < nodes.size(); ++i) { + node2idx[nodes[i]] = i; + } + + std::vector& row = coo_graph->row; + std::vector& col = coo_graph->col; + auto W = std::make_shared>(); + + for (int idx = 0; idx < nodes.size(); ++idx) { + node_t n = nodes[idx]; + + const auto& n_adjs = adj.find(n)->second; + + for (auto adj_it = n_adjs.begin(); adj_it != n_adjs.end(); ++adj_it) { + const edge_attr_dict_factory& edge_attr = adj_it->second; + auto edge_it = edge_attr.find(weight); + weight_t w = edge_it != edge_attr.end() ? edge_it->second : 1.0; + + row.push_back(idx); + col.push_back(node2idx[adj_it->first]); + + W->push_back(w); + } + } + + coo_graph->W_map[weight] = W; + } + + return coo_graph; +} + +std::shared_ptr Graph::transfer_csr_to_coo(const std::shared_ptr& csr_graph) { + auto coo_graph = std::make_shared(); + + coo_graph->nodes = csr_graph->nodes; + coo_graph->node2idx = csr_graph->node2idx; + + const std::vector& V = csr_graph->V; + const std::vector& E = csr_graph->E; + int num_edges = E.size(); + + coo_graph->row.reserve(num_edges); + coo_graph->col.reserve(num_edges); + + std::vector& row = coo_graph->row; + std::vector& col = coo_graph->col; + + for (int i = 0; i < V.size() - 1; ++i) { + int start_idx = V[i]; + int end_idx = V[i + 1]; + + for (int j = start_idx; j < end_idx; ++j) { + row.push_back(i); + col.push_back(E[j]); + } + } + + if (!csr_graph->unweighted_W.empty()) { + coo_graph->unweighted_W = csr_graph->unweighted_W; + } else { + coo_graph->W_map = csr_graph->W_map; + } + + return coo_graph; +} \ No newline at end of file diff --git a/cpp_easygraph/classes/graph.h b/cpp_easygraph/classes/graph.h new file mode 100644 index 0000000..a10ecee --- /dev/null +++ b/cpp_easygraph/classes/graph.h @@ -0,0 +1,73 @@ +#pragma once + +#include "../common/common.h" +#include "csr_graph.h" +#include "linkgraph.h" +#include "coo_graph.h" + +// old version +struct Graph { + node_dict_factory node; + adj_dict_factory adj; + Graph_L linkgraph_structure; + std::shared_ptr csr_graph; + py::kwargs node_to_id, id_to_node, graph; + node_t id; + bool dirty_nodes, dirty_adj, linkgraph_dirty; + py::object nodes_cache, adj_cache; + std::shared_ptr coo_graph; + + Graph(); + py::object get_nodes(); + py::object get_name(); + py::object set_name(py::object name); + py::object get_graph(); + py::object get_adj(); + py::object get_edges(); + py::object get_node_index(); + std::vector _get_edges(bool if_directed=true); + bool is_linkgraph_dirty(); + Graph_L _get_linkgraph_structure(); + void drop_cache(); + + std::shared_ptr gen_CSR(const std::string& weight); + std::shared_ptr gen_CSR(); + std::shared_ptr> gen_CSR_sources(const py::object& py_sources); + std::shared_ptr gen_COO(); + std::shared_ptr gen_COO(const std::string& weight); + std::shared_ptr transfer_csr_to_coo(const std::shared_ptr& csr_graph); +}; + +py::object Graph__init__(py::args args, py::kwargs kwargs); +py::object Graph__iter__(py::object self); +py::object Graph__len__(py::object self); +py::object Graph__contains__(py::object self, py::object node); +py::object Graph__getitem__(py::object self, py::object node); +py::object Graph_add_node(py::args args, py::kwargs kwargs); +py::object Graph_add_nodes(Graph& self, py::list nodes_for_adding, py::list nodes_attr); +py::object Graph_add_nodes_from(py::args args, py::kwargs kwargs); +py::object Graph_remove_node(Graph& self, py::object node_to_remove); +py::object Graph_remove_nodes(py::object self, py::list nodes_to_remove); +py::object Graph_number_of_nodes(Graph& self); +py::object Graph_has_node(Graph& self, py::object node); +py::object Graph_nbunch_iter(py::object self, py::object nbunch); +py::object Graph_add_edge(py::args args, py::kwargs kwargs); +py::object Graph_add_edges(Graph& self, py::list edges_for_adding, py::list edges_attr); +py::object Graph_add_edges_from(py::args args, py::kwargs attr); +py::object Graph_add_edges_from_file(Graph& self, py::str file, py::object weighted, py::object is_transform); +py::object Graph_add_weighted_edge(Graph& self, py::object u_of_edge, py::object v_of_edge, weight_t weight); +py::object Graph_remove_edge(Graph& self, py::object u, py::object v); +py::object Graph_remove_edges(py::object self, py::list edges_to_remove); +py::object Graph_number_of_edges(py::object self, py::object u, py::object v); +py::object Graph_has_edge(Graph& self, py::object u, py::object v); +py::object Graph_copy(py::object self); +py::object Graph_degree(py::object self, py::object weight); +py::object Graph_neighbors(py::object self, py::object node); +py::object Graph_nodes_subgraph(py::object self, py::list from_nodes); +py::object Graph_ego_subgraph(py::object self, py::object center); +py::object Graph_size(py::object self, py::object weight); +py::object Graph_is_directed(py::object self); +py::object Graph_is_multigraph(py::object self); +py::object Graph_to_index_node_graph(py::object self, py::object begin_index); +py::object Graph_generate_linkgraph(py::object self, py::object weight); +py::object Graph_py(py::object self); \ No newline at end of file diff --git a/cpp_easygraph/classes/linkgraph.h b/cpp_easygraph/classes/linkgraph.h new file mode 100644 index 0000000..f86a2bf --- /dev/null +++ b/cpp_easygraph/classes/linkgraph.h @@ -0,0 +1,91 @@ +#pragma once +#include "../common/common.h" + +struct LinkEdge{ + // 终点 + node_t to; + // 边权重 + weight_t w; + // 同起点的上一条边的编号 + node_t next; + +}; +struct Graph_L{ + int n; + int e; + bool is_directed; + bool is_deg; + std::vector head; + std::vector edges; + std::vector degree; + int max_deg = -1; + + + Graph_L(int vertex_num = 0, bool directed = true, bool deg = false){ + this->n = vertex_num; + this->e = 0; + this->is_deg = deg; + this->is_directed = directed; + LinkEdge le; + le.to = -1; + le.next = -1; + edges.emplace_back(le); + if(n > 0){ + head.resize(vertex_num + 1); + if(deg){ + degree.resize(vertex_num + 1); + for(int i = 0; i < vertex_num+1; i++){ + head[i] = -1; + degree[i] = 0; + } + } + else{ + for(int i = 0; i < vertex_num+1; i++){ + head[i] = -1; + } + } + } + } + + void add_unweighted_edge(const int &u, const int &v) { + LinkEdge le; + le.to = v; + le.next = head[u]; + this->edges.emplace_back(le); + this->head[u] = this->e; + this->e += 1; + if(this->is_deg){ + this->degree[u]++; + this->max_deg = std::max(this->max_deg, this->degree[u]); + } + } + + void add_weighted_edge(const int &u, const int &v, const double &w) { + // printf("u:%d,v:%d w:%.2f\n",u,v,w); + LinkEdge le; + this->e += 1; + le.to = v; + le.w = w; + le.next = head[u]; + // printf("next:%d\n",this->head[u]); + this->edges.emplace_back(le); + this->head[u] = this->e; + // printf("head:%d\n",this->head[u]); + + if(this->is_deg){ + this->degree[u]++; + this->max_deg = std::max(this->max_deg, this->degree[u]); + } + } +}; + +struct compare_node { + compare_node(){} + compare_node(int _x, int _d) {x = _x; d = _d;} + int x, d; + bool operator < (const compare_node &rhs) const { + return d > rhs.d; + } +}; + +std::vector _dijkstra(const Graph_L& G_l, int source, int target); \ No newline at end of file diff --git a/cpp_easygraph/classes/operation.cpp b/cpp_easygraph/classes/operation.cpp new file mode 100644 index 0000000..024f7aa --- /dev/null +++ b/cpp_easygraph/classes/operation.cpp @@ -0,0 +1,16 @@ +#include "operation.h" +#include "graph.h" + +py::object density(py::object G) { + Graph& G_ = G.cast(); + int n = G_.node.size(); + int m = G.attr("number_of_edges")().cast(); + if (m == 0 || n <= 1) { + return py::cast(0); + } + weight_t d = m * 1.0 / (n * (n - 1)); + if(G.attr("is_directed")().equal(py::cast(false))){ + d*=2; + } + return py::cast(d); +} diff --git a/cpp_easygraph/classes/operation.h b/cpp_easygraph/classes/operation.h new file mode 100644 index 0000000..e922547 --- /dev/null +++ b/cpp_easygraph/classes/operation.h @@ -0,0 +1,5 @@ +#pragma once + +#include "../common/common.h" + +py::object density(py::object G); \ No newline at end of file diff --git a/cpp_easygraph/classes/segment_tree.cpp b/cpp_easygraph/classes/segment_tree.cpp new file mode 100644 index 0000000..326b0bc --- /dev/null +++ b/cpp_easygraph/classes/segment_tree.cpp @@ -0,0 +1,42 @@ +#include "../common/common.h" + +class Segment_tree_zkw { + private: + int tn; + int size; + public: + std::vector t; + std::vector num; + Segment_tree_zkw(int N){ + size = (N+1)<<2; + this->t = std::vector(size+1 , INT_MAX); + this->num = std::vector(size+1 , 0); + } + void init(int N) { + for(int i = 0; i < size; i++){ + this->t[i] = INT_MAX; + this->num[i] = 0; + } + + tn = 1; + while(tn < N) tn <<= 1; + --tn; + for (int i = 1; i <= N; ++i) + this->num[i + tn] = i; + } + + void change(int p, const int &k) { + p += tn; this->t[p] = k; p >>= 1; + while (p) { + if (this->t[p<<1] < this->t[p<<1|1]) { + this->t[p] = this->t[p<<1]; + this->num[p] = this->num[p<<1]; + } + else { + this->t[p] = this->t[p<<1|1]; + this->num[p] = this->num[p<<1|1]; + } + p >>= 1; + } + } +}; \ No newline at end of file diff --git a/cpp_easygraph/common/common.cpp b/cpp_easygraph/common/common.cpp new file mode 100644 index 0000000..dcf7f4f --- /dev/null +++ b/cpp_easygraph/common/common.cpp @@ -0,0 +1,3 @@ +#include "common.h" + +graph_edge::graph_edge(node_t u_, node_t v_, edge_attr_dict_factory attr_) : u(u_), v(v_), attr(attr_) {} \ No newline at end of file diff --git a/cpp_easygraph/common/common.h b/cpp_easygraph/common/common.h new file mode 100644 index 0000000..21e8138 --- /dev/null +++ b/cpp_easygraph/common/common.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace py = pybind11; + +typedef int node_t; +typedef float weight_t; +typedef std::map node_attr_dict_factory; //(weight_key, value) +typedef std::map edge_attr_dict_factory; //(weight_key, value) +typedef std::unordered_map node_dict_factory; //(node, node_attr) +typedef std::unordered_map adj_attr_dict_factory; //(out_node, (weight_key, value)) +typedef std::unordered_map adj_dict_factory; //(node, edge_attr) +struct graph_edge { + node_t u, v; + edge_attr_dict_factory attr; + graph_edge(node_t, node_t, edge_attr_dict_factory); +}; + diff --git a/cpp_easygraph/common/utils.cpp b/cpp_easygraph/common/utils.cpp new file mode 100644 index 0000000..1aa3b95 --- /dev/null +++ b/cpp_easygraph/common/utils.cpp @@ -0,0 +1,48 @@ +#include "utils.h" +#include "../classes/graph.h" +#include "../classes/linkgraph.h" + +py::object attr_to_dict(const node_attr_dict_factory& attr) { + py::dict attr_dict = py::dict(); + for (const auto& kv : attr) { + attr_dict[py::cast(kv.first)] = kv.second; + } + return attr_dict; +} + +std::string weight_to_string(py::object weight) { + py::object warn = py::module_::import("warnings").attr("warn"); + if (!py::isinstance(weight)) { + if (!weight.is_none()) { + warn(py::str(weight) + py::str(" would be transformed into an instance of str.")); + } + weight = py::str(weight); + } + std::string weight_key = weight.cast(); + return weight_key; +} + +py::object py_sum(py::object o) { + py::object sum = py::module_::import("builtins").attr("sum"); + return sum(o); +} + +Graph_L graph_to_linkgraph(Graph &G, bool if_directed, std::string weight_key, bool is_deg, bool is_reverse){ + int node_num = G.node.size(); + const std::vector& edges = G._get_edges(if_directed); + int edges_num = edges.size(); + Graph_L G_l(node_num, if_directed, is_deg); + for(int i = 0; i < edges_num; i++){ + graph_edge e = edges[i]; + edge_attr_dict_factory& edge_attr = e.attr; + weight_t edge_weight = edge_attr.find(weight_key) != edge_attr.end() ? edge_attr[weight_key] : 1; + if(is_reverse){ + std::swap(e.u, e.v); + } + G_l.add_weighted_edge(e.u, e.v, edge_weight); + if (!if_directed){ + G_l.add_weighted_edge(e.v, e.u, edge_weight); + } + } + return G_l; +} diff --git a/cpp_easygraph/common/utils.h b/cpp_easygraph/common/utils.h new file mode 100644 index 0000000..d89a774 --- /dev/null +++ b/cpp_easygraph/common/utils.h @@ -0,0 +1,9 @@ +#pragma once +#include "common.h" +#include "../classes/linkgraph.h" +#include "../classes/graph.h" + +py::object attr_to_dict(const node_attr_dict_factory& attr); +std::string weight_to_string(py::object weight); +py::object py_sum(py::object o); +Graph_L graph_to_linkgraph(Graph &G, bool if_directed=false, std::string weight_key = "weight", bool is_deg = false, bool is_reverse = false); \ No newline at end of file diff --git a/cpp_easygraph/cpp_easygraph.cpp b/cpp_easygraph/cpp_easygraph.cpp new file mode 100644 index 0000000..4ea1ba6 --- /dev/null +++ b/cpp_easygraph/cpp_easygraph.cpp @@ -0,0 +1,120 @@ +#include "classes/__init__.h" +#include "functions/__init__.h" +PYBIND11_MODULE(cpp_easygraph, m) { + + py::class_(m, "Graph") + .def(py::init<>()) + .def("__init__", &Graph__init__) + .def("__iter__", &Graph__iter__) + .def("__len__", &Graph__len__) + .def("__contains__", &Graph__contains__, py::arg("node")) + .def("__getitem__", &Graph__getitem__, py::arg("node")) + .def("add_node", &Graph_add_node) + .def("add_nodes", &Graph_add_nodes, py::arg("nodes_for_adding"), py::arg("nodes_attr") = py::list()) + .def("add_nodes_from", &Graph_add_nodes_from) + .def("remove_node", &Graph_remove_node, py::arg("node_to_remove")) + .def("remove_nodes", &Graph_remove_nodes, py::arg("nodes_to_remove")) + .def("number_of_nodes", &Graph_number_of_nodes) + .def("has_node", &Graph_has_node, py::arg("node")) + .def("nbunch_iter", &Graph_nbunch_iter, py::arg("nbunch") = py::none()) + .def("add_edge", &Graph_add_edge) + .def("add_edges", &Graph_add_edges, py::arg("edges_for_adding"), py::arg("edges_attr") = py::list()) + .def("add_edges_from", &Graph_add_edges_from) + .def("add_edges_from_file", &Graph_add_edges_from_file, py::arg("file"), py::arg("weighted") = false, py::arg("is_transform") = false) + .def("add_weighted_edge", &Graph_add_weighted_edge, py::arg("u_of_edge"), py::arg("v_of_edge"), py::arg("weight")) + .def("remove_edge", &Graph_remove_edge, py::arg("u"), py::arg("v")) + .def("remove_edges", &Graph_remove_edges, py::arg("edges_to_remove")) + .def("number_of_edges", &Graph_number_of_edges, py::arg("u") = py::none(), py::arg("v") = py::none()) + .def("has_edge", &Graph_has_edge, py::arg("u"), py::arg("y")) + .def("copy", &Graph_copy) + .def("degree", &Graph_degree, py::arg("weight") = "weight") + .def("neighbors", &Graph_neighbors, py::arg("node")) + .def("all_neighbors", &Graph_neighbors, py::arg("node")) + .def("nodes_subgraph", &Graph_nodes_subgraph, py::arg("from_nodes")) + .def("ego_subgraph", &Graph_ego_subgraph, py::arg("center")) + .def("size", &Graph_size, py::arg("weight") = py::none()) + .def("is_directed", &Graph_is_directed) + .def("is_multigraph", &Graph_is_multigraph) + .def("to_index_node_graph", &Graph_to_index_node_graph, py::arg("begin_index") = 0) + .def("py", &Graph_py) + .def_property("graph", &Graph::get_graph, nullptr) + .def_property("nodes", &Graph::get_nodes, nullptr) + .def_property("name", &Graph::get_name, &Graph::set_name) + .def_property("adj", &Graph::get_adj, nullptr) + .def_property("edges", &Graph::get_edges, nullptr) + .def_property("node_index", &Graph::get_node_index, nullptr) + .def("generate_linkgraph", &Graph_generate_linkgraph,py::arg("weight") = "weight"); + + py::class_(m, "DiGraph") + .def(py::init<>()) + .def("__init__", &DiGraph__init__) + .def("out_degree", &DiGraph_out_degree, py::arg("weight") = "weight") + .def("in_degree", &DiGraph_in_degree, py::arg("weight") = "weight") + .def("degree", &DiGraph_degree, py::arg("weight") = "weight") + .def("size", &DiGraph_size, py::arg("weight") = py::none()) + .def("number_of_edges", &DiGraph_number_of_edges, py::arg("u") = py::none(), py::arg("v") = py::none()) + .def("successors", &Graph_neighbors, py::arg("node")) + .def("predecessors", &DiGraph_predecessors, py::arg("node")) + .def("add_node", &DiGraph_add_node) + .def("add_nodes", &DiGraph_add_nodes, py::arg("nodes_for_adding"), py::arg("nodes_attr") = py::list()) + .def("add_nodes_from", &DiGraph_add_nodes_from) + .def("remove_node", &DiGraph_remove_node, py::arg("node_to_remove")) + .def("remove_nodes", &DiGraph_remove_nodes, (py::arg("nodes_to_remove"))) + .def("add_edge", &DiGraph_add_edge) + .def("add_edges", &DiGraph_add_edges, py::arg("edges_for_adding"), py::arg("edges_attr") = py::list()) + .def("add_edges_from", &DiGraph_add_edges_from) + .def("add_edges_from_file", &DiGraph_add_edges_from_file, py::arg("file"), py::arg("weighted") = false, py::arg("is_transform") = false) + .def("add_weighted_edge", &DiGraph_add_weighted_edge, py::arg("u_of_edge"), py::arg("v_of_edge"), py::arg("weight")) + .def("remove_edge", &DiGraph_remove_edge, py::arg("u"), py::arg("v")) + .def("remove_edges", &DiGraph_remove_edges, py::arg("edges_to_remove")) + .def("remove_edges_from", &DiGraph_remove_edges_from, py::arg("ebunch")) + .def("nodes_subgraph", &DiGraph_nodes_subgraph, py::arg("from_nodes")) + .def("is_directed", &DiGraph_is_directed) + .def("py", &DiGraph_py) + .def_property("edges", &DiGraph::get_edges, nullptr) + .def_property("pred", &DiGraph::get_pred,nullptr) + .def("generate_linkgraph", &DiGraph_generate_linkgraph,py::arg("weight") = "weight"); + + m.def("cpp_degree_centrality", °ree_centrality, py::arg("G")); + m.def("cpp_in_degree_centrality", &in_degree_centrality, py::arg("G")); + m.def("cpp_out_degree_centrality", &out_degree_centrality, py::arg("G")); + m.def("cpp_closeness_centrality", &closeness_centrality, py::arg("G"), py::arg("weight") = "weight", py::arg("cutoff") = py::none(), py::arg("sources") = py::none()); + m.def("cpp_betweenness_centrality", &betweenness_centrality, py::arg("G"), py::arg("weight") = "weight", py::arg("cutoff") = py::none(),py::arg("sources") = py::none(), py::arg("normalized") = py::bool_(true), py::arg("endpoints") = py::bool_(false)); + m.def("cpp_katz_centrality", &cpp_katz_centrality, py::arg("G"), py::arg("alpha") = 0.1, py::arg("beta") = 1.0, py::arg("max_iter") = 1000, py::arg("tol") = 1e-6, py::arg("normalized") = true); + m.def("cpp_eigenvector_centrality", &cpp_eigenvector_centrality, py::arg("G"), py::arg("max_iter") = 100, py::arg("tol") = 1.0e-6, py::arg("nstart") = py::none(), py::arg("weight") = "weight"); + m.def("cpp_k_core", &core_decomposition, py::arg("G")); + m.def("cpp_density", &density, py::arg("G")); + m.def("cpp_constraint", &constraint, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none(), py::arg("n_workers") = py::none()); + m.def("cpp_effective_size", &effective_size, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none(), py::arg("n_workers") = py::none()); + m.def("cpp_efficiency", &efficiency, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none(), py::arg("n_workers") = py::none()); + m.def("cpp_hierarchy", &hierarchy, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none(), py::arg("n_workers") = py::none()); + m.def("cpp_pagerank", &_pagerank, py::arg("G"), py::arg("alpha") = 0.85, py::arg("max_iterator") = 500, py::arg("threshold") = 1e-6, py::arg("weight") = "weight"); + m.def("cpp_dijkstra_multisource", &_dijkstra_multisource, py::arg("G"), py::arg("sources"), py::arg("weight") = "weight", py::arg("target") = py::none()); + m.def("cpp_spfa", &_spfa, py::arg("G"), py::arg("source"), py::arg("weight") = "weight"); + m.def("cpp_clustering", &clustering, py::arg("G"), py::arg("nodes") = py::none(), py::arg("weight") = py::none()); + m.def("cpp_biconnected_dfs_record_edges", &_biconnected_dfs_record_edges, py::arg("G"), py::arg("need_components") = true); + m.def("cpp_strongly_connected_components",&strongly_connected_components,py::arg("G")); + m.def("cpp_Floyd", &Floyd, py::arg("G"), py::arg("weight") = "weight"); + m.def("cpp_Prim", &Prim, py::arg("G"), py::arg("weight") = "weight"); + m.def("cpp_Kruskal", &Kruskal, py::arg("G"), py::arg("weight") = "weight"); + m.def("cpp_plain_bfs", &plain_bfs, py::arg("G"), py::arg("source")); + m.def("cpp_kruskal_mst_edges", &kruskal_mst_edges, py::arg("G"), py::arg("minimum") = true, py::arg("weight") = "weight", py::arg("data") = true, py::arg("ignore_nan") = false); + m.def("cpp_prim_mst_edges", &prim_mst_edges, py::arg("G"), py::arg("minimum") = true, py::arg("weight") = "weight", py::arg("data") = true, py::arg("ignore_nan") = false); + m.def("cpp_boruvka_mst_edges", &boruvka_mst_edges, py::arg("G"), py::arg("minimum") = true, py::arg("weight") = "weight", py::arg("data") = true, py::arg("ignore_nan") = false); + m.def("cpp_average_shortest_path_length", &average_shortest_path_length, py::arg("G"), py::arg("weight") = py::none(), py::arg("method") = py::none()); + m.def("cpp_eccentricity", &eccentricity, py::arg("G"), py::arg("v") = py::none(), py::arg("sp") = py::none()); + m.def("cpp_connected_components_undirected", &connected_component_undirected, py::arg("G")); + m.def("cpp_connected_components_directed", &connected_component_directed, py::arg("G")); + + // community methods + m.def("cpp_modularity", &cpp_modularity, py::arg("G"), py::arg("communities"), py::arg("weight") = py::str("weight")); + m.def("cpp_greedy_modularity_communities", &cpp_greedy_modularity_communities, py::arg("G"), py::arg("weight") = py::str("weight")); + m.def("cpp_enumerate_subgraph", &cpp_enumerate_subgraph, py::arg("G"), py::arg("k")); + m.def("cpp_random_enumerate_subgraph", &cpp_random_enumerate_subgraph, py::arg("G"), py::arg("k"), py::arg("cut_prob")); + m.def("cpp_louvain_communities", &cpp_louvain_communities, py::arg("G"), py::arg("weight") = py::str("weight"), py::arg("threshold") = py::float_(0.00002), py::arg("resolution") = py::float_(1.0)); + m.def("cpp_louvain_communities_serial", &cpp_louvain_communities_serial, py::arg("G"), py::arg("weight") = py::str("weight"), py::arg("threshold") = py::float_(0.00002), py::arg("resolution") = py::float_(1.0)); + m.def("cpp_LPA", &cpp_LPA, py::arg("G")); + m.def("cpp_ego_graph", &cpp_ego_graph, py::arg("G"), py::arg("n"), py::arg("radius") = py::int_(1), py::arg("center") = py::bool_(true), py::arg("undirected") = py::bool_(false), py::arg("distance") = py::none()); + m.def("cpp_ego_graph_csr", &cpp_ego_graph_csr, py::arg("G"), py::arg("n"), py::arg("radius") = py::int_(1), py::arg("center") = py::bool_(true), py::arg("undirected") = py::bool_(false), py::arg("distance") = py::none()); + m.def("cpp_localsearch", &cpp_localsearch, py::arg("G"), py::arg("center_num") = py::none(), py::arg("auto_choose_centers") = py::bool_(false), py::arg("maximum_tree") = py::bool_(true), py::arg("seed") = py::none(), py::arg("self_loop") = py::bool_(false)); +} \ No newline at end of file diff --git a/cpp_easygraph/functions/__init__.h b/cpp_easygraph/functions/__init__.h new file mode 100644 index 0000000..92a0b44 --- /dev/null +++ b/cpp_easygraph/functions/__init__.h @@ -0,0 +1,10 @@ +#pragma once + +#include "components/__init__.h" +#include "basic/__init__.h" +#include "path/__init__.h" +#include "structural_holes/__init__.h" +#include "cores/__init__.h" +#include "centrality/__init__.h" +#include "pagerank/__init__.h" +#include "community/__init__.h" \ No newline at end of file diff --git a/cpp_easygraph/functions/basic/__init__.h b/cpp_easygraph/functions/basic/__init__.h new file mode 100644 index 0000000..24f8ce2 --- /dev/null +++ b/cpp_easygraph/functions/basic/__init__.h @@ -0,0 +1,4 @@ +#pragma once + +#include "avg_degree.h" +#include "cluster.h" \ No newline at end of file diff --git a/cpp_easygraph/functions/basic/avg_degree.cpp b/cpp_easygraph/functions/basic/avg_degree.cpp new file mode 100644 index 0000000..44d7c25 --- /dev/null +++ b/cpp_easygraph/functions/basic/avg_degree.cpp @@ -0,0 +1,9 @@ +#include "avg_degree.h" + +#include "../../classes/graph.h" +py::object average_degree(py::object G) { + Graph& G_ = G.cast(); + int n = G_.node.size(); + int m = G.attr("number_of_edges")().cast(); + return py::cast(2.0 * m / n); +} \ No newline at end of file diff --git a/cpp_easygraph/functions/basic/avg_degree.h b/cpp_easygraph/functions/basic/avg_degree.h new file mode 100644 index 0000000..360ee6c --- /dev/null +++ b/cpp_easygraph/functions/basic/avg_degree.h @@ -0,0 +1,5 @@ +#pragma once + +#include "../../common/common.h" + +py::object average_degree(py::object G); \ No newline at end of file diff --git a/cpp_easygraph/functions/basic/cluster.cpp b/cpp_easygraph/functions/basic/cluster.cpp new file mode 100644 index 0000000..501815d --- /dev/null +++ b/cpp_easygraph/functions/basic/cluster.cpp @@ -0,0 +1,298 @@ +#include "cluster.h" +#include "../../classes/graph.h" +#include "../../classes/directed_graph.h" +#include "../../common/utils.h" + +inline weight_t wt(adj_dict_factory& adj, node_t u, node_t v, std::string weight, weight_t max_weight = 1) { + auto& attr = adj[u][v]; + return (attr.count(weight) ? attr[weight] : 1) / max_weight; +} + +py::list _weighted_triangles_and_degree(py::object G, py::object nodes, py::object weight) { + std::string weight_key = weight_to_string(weight); + Graph& G_ = G.cast(); + auto& adj = G_.adj; + weight_t max_weight = 1; + if (weight.is_none() || G.attr("number_of_edges")().equal(py::cast(0))) { + max_weight = 1; + } + else { + int assigned = 0; + for (auto& u_info : G_.adj) { + for (auto& v_info : u_info.second) { + auto& d = v_info.second; + if (assigned) { + max_weight = std::max(max_weight, d.count(weight_key) ? d[weight_key] : 1); + } + else { + assigned = 1; + max_weight = d.count(weight_key) ? d[weight_key] : 1; + } + } + } + } + py::list nodes_list = py::list(G.attr("nbunch_iter")(nodes)); + py::list ret = py::list(); + for (int i = 0;i < py::len(nodes_list);i++) { + node_t i_id = (G_.node_to_id[nodes_list[i]]).cast(); + std::unordered_set inbrs, seen; + for (const auto& pair : adj[i_id]) { + inbrs.insert(pair.first); + } + inbrs.erase(i_id); + weight_t weighted_triangles = 0; + for (const auto& j_id : inbrs) { + seen.insert(j_id); + weight_t wij = wt(adj, i_id, j_id, weight_key, max_weight); + for (const auto& k_id : inbrs) { + if (adj[j_id].count(k_id) && !seen.count(k_id)) { + weight_t wjk = wt(adj, j_id, k_id, weight_key, max_weight); + weight_t wki = wt(adj, k_id, i_id, weight_key, max_weight); + weighted_triangles += std::cbrt(wij * wjk * wki); + } + } + } + ret.append(py::make_tuple(G_.id_to_node[py::cast(i_id)], inbrs.size(), 2 * weighted_triangles)); + } + return ret; +} + +py::list _directed_weighted_triangles_and_degree(py::object G, py::object nodes, py::object weight) { + std::string weight_key = weight_to_string(weight); + DiGraph& G_ = G.cast(); + auto& adj = G_.adj; + weight_t max_weight = 1; + if (weight.is_none() || G.attr("number_of_edges")().equal(py::cast(0))) { + max_weight = 1; + } + else { + int assigned = 0; + for (auto& u_info : G_.adj) { + for (auto& v_info : u_info.second) { + auto& d = v_info.second; + if (assigned) { + max_weight = std::max(max_weight, d.count(weight_key) ? d[weight_key] : 1); + } + else { + assigned = 1; + max_weight = d.count(weight_key) ? d[weight_key] : 1; + } + } + } + } + py::list nodes_list = py::list(G.attr("nbunch_iter")(nodes)); + py::list ret = py::list(); + for (int i = 0;i < py::len(nodes_list);i++) { + node_t i_id = (G_.node_to_id[nodes_list[i]]).cast(); + std::unordered_set ipreds, isuccs; + for (const auto& pair : G_.pred[i_id]) { + ipreds.insert(pair.first); + } + ipreds.erase(i_id); + for (const auto& pair : G_.adj[i_id]) { + isuccs.insert(pair.first); + } + isuccs.erase(i_id); + + weight_t directed_triangles = 0; + for (const auto& j_id : ipreds) { + for (const auto& k_pair : G_.pred[j_id]) { + node_t k_id = k_pair.first; + if (k_id == j_id) { + continue; + }// jpreds + if (ipreds.count(k_id)) { // ipreds & jpreds + directed_triangles += std::cbrt(wt(adj, j_id, i_id, weight_key, max_weight) * wt(adj, k_id, i_id, weight_key, max_weight) * wt(adj, k_id, j_id, weight_key, max_weight)); + } + if (isuccs.count(k_id)) { // isuccs & jpreds + directed_triangles += std::cbrt(wt(adj, j_id, i_id, weight_key, max_weight) * wt(adj, i_id, k_id, weight_key, max_weight) * wt(adj, k_id, j_id, weight_key, max_weight)); + } + } + for (const auto& k_pair : G_.adj[j_id]) { + node_t k_id = k_pair.first; + if (k_id == j_id) { + continue; + }// jsuccs + if (ipreds.count(k_id)) { // ipreds & jsuccs + directed_triangles += std::cbrt(wt(adj, j_id, i_id, weight_key, max_weight) * wt(adj, k_id, i_id, weight_key, max_weight) * wt(adj, j_id, k_id, weight_key, max_weight)); + } + if (isuccs.count(k_id)) { // isuccs & jsuccs + directed_triangles += std::cbrt(wt(adj, j_id, i_id, weight_key, max_weight) * wt(adj, i_id, k_id, weight_key, max_weight) * wt(adj, j_id, k_id, weight_key, max_weight)); + } + } + } + for (const auto& j_id : isuccs) { + for (const auto& k_pair : G_.pred[j_id]) { + node_t k_id = k_pair.first; + if (k_id == j_id) { + continue; + }// jpreds + if (ipreds.count(k_id)) { // ipreds & jpreds + directed_triangles += std::cbrt(wt(adj, i_id, j_id, weight_key, max_weight) * wt(adj, k_id, i_id, weight_key, max_weight) * wt(adj, k_id, j_id, weight_key, max_weight)); + } + if (isuccs.count(k_id)) { // isuccs & jpreds + directed_triangles += std::cbrt(wt(adj, i_id, j_id, weight_key, max_weight) * wt(adj, i_id, k_id, weight_key, max_weight) * wt(adj, k_id, j_id, weight_key, max_weight)); + } + } + for (const auto& k_pair : G_.adj[j_id]) { + node_t k_id = k_pair.first; + if (k_id == j_id) { + continue; + }// jsuccs + if (ipreds.count(k_id)) { // ipreds & jsuccs + directed_triangles += std::cbrt(wt(adj, i_id, j_id, weight_key, max_weight) * wt(adj, k_id, i_id, weight_key, max_weight) * wt(adj, j_id, k_id, weight_key, max_weight)); + } + if (isuccs.count(k_id)) { // isuccs & jsuccs + directed_triangles += std::cbrt(wt(adj, i_id, j_id, weight_key, max_weight) * wt(adj, i_id, k_id, weight_key, max_weight) * wt(adj, j_id, k_id, weight_key, max_weight)); + } + } + } + + int dtotal = ipreds.size() + isuccs.size(); + int dbidirectional = 0; + for (const auto& node : ipreds) { + dbidirectional += isuccs.count(node); + } + ret.append(py::make_tuple(nodes_list[i], dtotal, dbidirectional, directed_triangles)); + } + return ret; +} + +py::list _triangles_and_degree(py::object G, py::object nodes = py::none()) { + Graph& G_ = G.cast(); + auto& adj = G_.adj; + py::list nodes_list = py::list(G.attr("nbunch_iter")(nodes)); + py::list ret = py::list(); + for (int i = 0;i < py::len(nodes_list);i++) { + node_t v = (G_.node_to_id[nodes_list[i]]).cast(); + std::unordered_set vs; + for (const auto& pair : adj[v]) { + vs.insert(pair.first); + } + vs.erase(v); + weight_t ntriangles = 0; + for (const auto& w : vs) { + for (const auto& node : vs) { + ntriangles += node != w && adj[w].count(node); + } + } + ret.append(py::make_tuple(G_.id_to_node[py::cast(v)], vs.size(), ntriangles)); + } + return ret; +} + +py::list _directed_triangles_and_degree(py::object G, py::object nodes = py::none()) { + DiGraph& G_ = G.cast(); + auto& adj = G_.adj; + py::list nodes_list = py::list(G.attr("nbunch_iter")(nodes)); + py::list ret = py::list(); + for (int i = 0;i < py::len(nodes_list);i++) { + node_t i_id = (G_.node_to_id[nodes_list[i]]).cast(); + std::unordered_set ipreds, isuccs; + for (const auto& pair : G_.pred[i_id]) { + ipreds.insert(pair.first); + } + ipreds.erase(i_id); + for (const auto& pair : G_.adj[i_id]) { + isuccs.insert(pair.first); + } + isuccs.erase(i_id); + + weight_t directed_triangles = 0; + for (const auto& j_id : ipreds) { + for (const auto& k_pair : G_.pred[j_id]) { + node_t k_id = k_pair.first; + if (k_id == j_id) { + continue; + }// jpreds + directed_triangles += ipreds.count(k_id); // ipreds & jpreds + directed_triangles += isuccs.count(k_id); // isuccs & jpreds + } + for (const auto& k_pair : G_.adj[j_id]) { + node_t k_id = k_pair.first; + if (k_id == j_id) { + continue; + }// jsuccs + directed_triangles += ipreds.count(k_id); // ipreds & jsuccs + directed_triangles += isuccs.count(k_id); // isuccs & jsuccs + } + } + for (const auto& j_id : isuccs) { + for (const auto& k_pair : G_.pred[j_id]) { + node_t k_id = k_pair.first; + if (k_id == j_id) { + continue; + }// jpreds + directed_triangles += ipreds.count(k_id); // ipreds & jpreds + directed_triangles += isuccs.count(k_id); // isuccs & jpreds + } + for (const auto& k_pair : G_.adj[j_id]) { + node_t k_id = k_pair.first; + if (k_id == j_id) { + continue; + }// jsuccs + directed_triangles += ipreds.count(k_id); // ipreds & jsuccs + directed_triangles += isuccs.count(k_id); // isuccs & jsuccs + } + } + + int dtotal = ipreds.size() + isuccs.size(); + int dbidirectional = 0; + for (const auto& node : ipreds) { + dbidirectional += isuccs.count(node); + } + ret.append(py::make_tuple(nodes_list[i], dtotal, dbidirectional, directed_triangles)); + } + return ret; +} + +py::object clustering(py::object G, py::object nodes, py::object weight) { + py::dict clusterc = py::dict(); + if (G.attr("is_directed")().cast()) { + py::list td_list; + if (!weight.is_none()) { + td_list = _directed_weighted_triangles_and_degree(G, nodes, weight); + } + else { + td_list = _directed_triangles_and_degree(G, nodes); + } + for (int i = 0;i < py::len(td_list);i++) { + py::tuple tuple = td_list[i].cast(); + py::object v = tuple[0]; + int dt = tuple[1].cast(), db = tuple[2].cast(); + weight_t t = tuple[3].cast(); + if (t == 0) { + clusterc[v] = 0; + } + else { + clusterc[v] = t / ((dt * (dt - 1) - 2 * db) * 2); + } + } + + } + else { + py::list td_list; + if (!weight.is_none()) { + td_list = _weighted_triangles_and_degree(G, nodes, weight); + } + else { + td_list = _triangles_and_degree(G, nodes); + } + for (int i = 0;i < py::len(td_list);i++) { + py::tuple tuple = td_list[i].cast(); + py::object v = tuple[0]; + int d = tuple[1].cast(); + weight_t t = tuple[2].cast(); + if (t == 0) { + clusterc[v] = 0; + } + else { + clusterc[v] = t / (d * (d - 1)); + } + } + } + if (G.contains(nodes)) { + return clusterc[nodes]; + } + return clusterc; +} \ No newline at end of file diff --git a/cpp_easygraph/functions/basic/cluster.h b/cpp_easygraph/functions/basic/cluster.h new file mode 100644 index 0000000..e2b4ffa --- /dev/null +++ b/cpp_easygraph/functions/basic/cluster.h @@ -0,0 +1,5 @@ +#pragma once + +#include "../../common/common.h" + +py::object clustering(py::object G, py::object nodes, py::object weight); \ No newline at end of file diff --git a/cpp_easygraph/functions/centrality/__init__.h b/cpp_easygraph/functions/centrality/__init__.h new file mode 100644 index 0000000..d7ab9ca --- /dev/null +++ b/cpp_easygraph/functions/centrality/__init__.h @@ -0,0 +1 @@ +#include "centrality.h" \ No newline at end of file diff --git a/cpp_easygraph/functions/centrality/betweenness.cpp b/cpp_easygraph/functions/centrality/betweenness.cpp new file mode 100644 index 0000000..9bef301 --- /dev/null +++ b/cpp_easygraph/functions/centrality/betweenness.cpp @@ -0,0 +1,467 @@ +#ifdef _OPENMP +#include +#endif +#include +#include +#include +#include +#include +#include + +#include "centrality.h" +#ifdef EASYGRAPH_ENABLE_GPU +#include +#endif + +#include "../../classes/graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" + +namespace py = pybind11; + +void betweenness_bfs_worker( + const Graph_L& G_l, const int& S, std::vector& bc, int cutoff, int endpoints_, + std::vector& q, std::vector& dis, std::vector& head_path, std::vector& St, + std::vector& count_path, std::vector& delta, std::vector& E_path, + std::vector& stamp, int& cur_stamp +) { + int N = G_l.n; + int edge_number_path = 0; + int cnt_St = 0; + ++cur_stamp; + if ((int)q.size() < N + 1) + q.resize(N + 1); + int front = 0, back = 0; + int cutoff_int = (cutoff < 0) ? -1 : cutoff; + + stamp[S] = cur_stamp; + dis[S] = 0; + count_path[S] = 1; + delta[S] = 0.0; + head_path[S] = 0; + q[back++] = S; + + const std::vector& head = G_l.head; + const std::vector& E = G_l.edges; + + while (front < back) { + int u = q[front++]; + int du = dis[u]; + if (cutoff_int >= 0 && du > cutoff_int) + break; + St[cnt_St++] = u; + + for (int p = head[u]; p != -1; p = E[p].next) { + int v = E[p].to; + int new_dis = du + 1; + if (cutoff_int >= 0 && new_dis > cutoff_int) + continue; + + if (stamp[v] != cur_stamp) { + stamp[v] = cur_stamp; + dis[v] = new_dis; + count_path[v] = count_path[u]; + delta[v] = 0.0; + head_path[v] = 0; + q[back++] = v; + E_path[++edge_number_path].next = head_path[v]; + E_path[edge_number_path].to = u; + head_path[v] = edge_number_path; + } else if (dis[v] == new_dis) { + count_path[v] += count_path[u]; + E_path[++edge_number_path].next = head_path[v]; + E_path[edge_number_path].to = u; + head_path[v] = edge_number_path; + } + } + } + + if (endpoints_) + bc[S] += cnt_St - 1; + + while (cnt_St > 0) { + int u = St[--cnt_St]; + double cu = count_path[u]; + if (cu != 0) { + double coeff = (1.0 + delta[u]) / cu; + for (int p = head_path[u]; p; p = E_path[p].next) { + int w = E_path[p].to; + delta[w] += count_path[w] * coeff; + } + } + if (u != S) + bc[u] += delta[u] + endpoints_; + } +} + +void betweenness_dijkstra_worker( + const Graph_L& G_l, const int& S, std::vector& bc, double cutoff, + std::vector& dis, std::vector& head_path, + std::vector& St, std::vector& count_path, std::vector& delta, + std::vector& E_path, int endpoints_, + std::vector& stamp, int& cur_stamp +) { + const int dis_inf = 0x3f3f3f3f; + + int N = G_l.n; + int edge_number_path = 0; + int cnt_St = 0; + ++cur_stamp; + + stamp[S] = cur_stamp; + dis[S] = 0; + count_path[S] = 1; + delta[S] = 0.0; + head_path[S] = 0; + + std::priority_queue, std::vector>, std::greater>> pq; + pq.push({0, S}); + + const std::vector& head = G_l.head; + const std::vector& E = G_l.edges; + + while (!pq.empty()) { + std::pair top = pq.top(); + pq.pop(); + int d = top.first; + int u = top.second; + + if (d > dis[u]) continue; + + if (cutoff >= 0 && d > cutoff) continue; + + St[cnt_St++] = u; + + for (int p = head[u]; p != -1; p = E[p].next) { + int v = E[p].to; + int w = E[p].w; + int nd = dis[u] + w; + + if (cutoff >= 0 && nd > cutoff) continue; + + bool first_visit = (stamp[v] != cur_stamp); + + if (first_visit || dis[v] > nd) { + if (first_visit) { + stamp[v] = cur_stamp; + delta[v] = 0.0; + } + dis[v] = nd; + count_path[v] = count_path[u]; + head_path[v] = 0; + E_path[++edge_number_path].next = head_path[v]; + E_path[edge_number_path].to = u; + head_path[v] = edge_number_path; + + pq.push({nd, v}); + } else if (dis[v] == nd) { + count_path[v] += count_path[u]; + E_path[++edge_number_path].next = head_path[v]; + E_path[edge_number_path].to = u; + head_path[v] = edge_number_path; + } + } + } + + if (endpoints_) + bc[S] += cnt_St - 1; + + while (cnt_St > 0) { + int u = St[--cnt_St]; + double cu = count_path[u]; + if (cu != 0) { + double coeff = (1.0 + delta[u]) / cu; + for (int p = head_path[u]; p; p = E_path[p].next) { + int w = E_path[p].to; + delta[w] += count_path[w] * coeff; + } + } + if (u != S) + bc[u] += delta[u] + endpoints_; + } +} + +static double calc_scale(int len_V, int is_directed, int normalized, int endpoints) { + double scale = 1.0; + if (normalized) { + if (endpoints) { + if (len_V < 2) { + scale = 1.0; + } else { + scale = 1.0 / (double(len_V) * (len_V - 1)); + } + } else { + if (len_V <= 2) { + scale = 1.0; + } else { + scale = 1.0 / ((double(len_V) - 1) * (len_V - 2)); + } + } + } else { + if (!is_directed) { + scale = 0.5; + } else { + scale = 1.0; + } + } + return scale; +} + +static py::object invoke_cpp_betweenness_centrality( + py::object G, py::object weight, py::object cutoff, py::object sources, + py::object normalized, py::object endpoints +) { + Graph& G_ = G.cast(); + int cutoff_ = -1; + if (!cutoff.is_none()) { + cutoff_ = cutoff.cast(); + } + int N = G_.node.size(); + bool is_directed = G.attr("is_directed")().cast(); + int normalized_ = normalized.cast(); + int endpoints_ = endpoints.cast(); + double scale = calc_scale(N, is_directed, normalized_, endpoints_); + bool use_weights = !weight.is_none(); + std::string weight_key = ""; + if (use_weights) { + weight_key = weight_to_string(weight); + } + + Graph_L G_l; + if (G_.linkgraph_dirty) { + G_l = graph_to_linkgraph(G_, is_directed, weight_key, false, false); + G_.linkgraph_structure = G_l; + } else { + G_l = G_.linkgraph_structure; + } + + int edges_num = G_l.edges.size(); + std::vector bc(N + 1, 0.0); + std::vector BC; + int num_threads = 1; +#ifdef _OPENMP + num_threads = omp_get_max_threads(); +#endif + + std::vector> dis_all(num_threads, std::vector(N + 1)); + std::vector> head_path_all(num_threads, std::vector(N + 1)); + std::vector> St_all(num_threads, std::vector(N + 1)); + std::vector> count_path_all(num_threads, std::vector(N + 1)); + std::vector> delta_all(num_threads, std::vector(N + 1)); + std::vector> E_path_all(num_threads, std::vector(edges_num + 1)); + + std::vector> queue_all(num_threads, std::vector(N + 1)); + std::vector> stamp_all(num_threads, std::vector(N + 1, 0)); + std::vector cur_stamp_all(num_threads, 0); + + std::vector> bc_local_all(num_threads, std::vector(N + 1, 0.0)); + + if (!sources.is_none()) { + py::list sources_list = py::list(sources); + int sources_list_len = py::len(sources_list); + std::vector sources_vec; + sources_vec.reserve(sources_list_len); + for (int i = 0; i < sources_list_len; i++) { + if (G_.node_to_id.attr("get")(sources_list[i], py::none()).is_none()) { + printf("The node should exist in the graph!"); + return py::none(); + } + sources_vec.push_back(G_.node_to_id.attr("get")(sources_list[i]).cast()); + } + +#ifdef _OPENMP +#pragma omp parallel for schedule(dynamic) +#endif + for (int i = 0; i < sources_list_len; i++) { + node_t source_id = sources_vec[i]; +#ifdef _OPENMP + int tid = omp_get_thread_num(); +#else + int tid = 0; +#endif + auto& bc_local = bc_local_all[tid]; + auto& dis = dis_all[tid]; + auto& head_path = head_path_all[tid]; + auto& St = St_all[tid]; + auto& count_path = count_path_all[tid]; + auto& delta = delta_all[tid]; + auto& E_path = E_path_all[tid]; + auto& q = queue_all[tid]; + auto& stamp = stamp_all[tid]; + int& cur_stamp = cur_stamp_all[tid]; + + if (use_weights) { + betweenness_dijkstra_worker( + G_l, source_id, bc_local, cutoff_, dis, head_path, + St, count_path, delta, E_path, endpoints_, stamp, cur_stamp + ); + } else { + betweenness_bfs_worker( + G_l, source_id, bc_local, cutoff_, endpoints_, q, dis, head_path, + St, count_path, delta, E_path, stamp, cur_stamp + ); + } + } + } else { +#ifdef _OPENMP +#pragma omp parallel for schedule(dynamic) +#endif + for (int i = 1; i <= N; ++i) { +#ifdef _OPENMP + int tid = omp_get_thread_num(); +#else + int tid = 0; +#endif + auto& bc_local = bc_local_all[tid]; + auto& dis = dis_all[tid]; + auto& head_path = head_path_all[tid]; + auto& St = St_all[tid]; + auto& count_path = count_path_all[tid]; + auto& delta = delta_all[tid]; + auto& E_path = E_path_all[tid]; + auto& q = queue_all[tid]; + auto& stamp = stamp_all[tid]; + int& cur_stamp = cur_stamp_all[tid]; + + if (use_weights) { + betweenness_dijkstra_worker( + G_l, i, bc_local, cutoff_, dis, head_path, + St, count_path, delta, E_path, endpoints_, stamp, cur_stamp + ); + } else { + betweenness_bfs_worker( + G_l, i, bc_local, cutoff_, endpoints_, q, dis, head_path, + St, count_path, delta, E_path, stamp, cur_stamp + ); + } + } + } + +#ifdef _OPENMP +#pragma omp parallel for schedule(static) + for (int j = 1; j <= N; ++j) { + double s = 0.0; + for (int tid = 0; tid < num_threads; ++tid) + s += bc_local_all[tid][j]; + bc[j] += s; + } +#else + for (int j = 1; j <= N; ++j) { + bc[j] += bc_local_all[0][j]; + } +#endif + + BC.reserve(N); + for (int i = 1; i <= N; i++) { + BC.push_back(scale * bc[i]); + } + + py::array::ShapeContainer ret_shape{(int)BC.size()}; + py::array_t ret(ret_shape, BC.data()); + return ret; +} + +#ifdef EASYGRAPH_ENABLE_GPU +static py::object invoke_gpu_betweenness_centrality(py::object G, py::object weight, +py::object py_sources, py::object normalized, py::object endpoints) { + Graph& G_ = G.cast(); + if (weight.is_none()) { + G_.gen_CSR(); + } else { + G_.gen_CSR(weight_to_string(weight)); + } + auto csr_graph = G_.csr_graph; + std::vector& E = csr_graph->E; + std::vector& V = csr_graph->V; + std::vector *W_p = weight.is_none() ? &(csr_graph->unweighted_W) + : csr_graph->W_map.find(weight_to_string(weight))->second.get(); + auto sources = G_.gen_CSR_sources(py_sources); + std::vector BC; + bool is_directed = G.attr("is_directed")().cast(); + int gpu_r = gpu_easygraph::betweenness_centrality(V, E, *W_p, *sources, + is_directed, normalized.cast(), + endpoints.cast(), BC); + if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { + // the code below will throw an exception + py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); + } + py::array::ShapeContainer ret_shape{(int)BC.size()}; + py::array_t ret(ret_shape, BC.data()); + return ret; +} +#endif + + +py::object betweenness_centrality(py::object G, py::object weight, py::object cutoff, py::object sources, +py::object normalized, py::object endpoints) { +#ifdef EASYGRAPH_ENABLE_GPU + return invoke_gpu_betweenness_centrality(G, weight, sources, normalized, endpoints); +#else + return invoke_cpp_betweenness_centrality(G, weight, cutoff, sources, normalized, endpoints); +#endif +} + +// void betweenness_dijkstra(const Graph_L& G_l, const int &S, std::vector& bc, double cutoff) { +// int N = G_l.n; +// int edge_number_path = 0; +// __gnu_pbds::priority_queue q; +// std::vector dis(N+1, INFINITY); +// std::vector vis(N+1, false); +// std::vector head_path(N+1, 0); +// const std::vector& head = G_l.head; +// const std::vector& E = G_l.edges; +// int edges_num = E.size(); +// std::vector St(N+1, 0); +// std::vector count_path(N+1, 0); +// std::vector delta(N+1, 0); +// std::vector E_path(edges_num+1); +// head_path[S] = 0; +// dis[S] = 0; +// count_path[S] = 1; +// dis[S] = 0; +// count_path[S] = 1; +// q.push(compare_node(S, 0)); +// int cnt_St = 0; +// while(!q.empty()) { +// int u = q.top().x; +// q.pop(); +// if (vis[u]){ +// continue; +// } +// if (cutoff >= 0 && dis[u] > cutoff){ +// continue; +// } +// St[cnt_St++] = u; +// vis[u] = true; +// for(int p = head[u]; p != -1; p = E[p].next) { +// int v = E[p].to; +// if(cutoff >= 0 && (dis[u] + E[p].w) > cutoff){ +// continue; +// } +// if (dis[v] > dis[u] + E[p].w) { +// dis[v] = dis[u] + E[p].w; +// q.push(compare_node(v, dis[v])); +// count_path[v] = count_path[u]; +// head_path[v] = 0; +// E_path[++edge_number_path].next = head_path[v]; +// E_path[edge_number_path].to = u; +// head_path[v] = edge_number_path; +// } +// else if (dis[v] == dis[u] + E[p].w) { +// count_path[v] += count_path[u]; +// E_path[++edge_number_path].next = head_path[v]; +// E_path[edge_number_path].to = u; +// head_path[v] = edge_number_path; +// } +// } +// } +// while (cnt_St > 0) { +// int u = St[--cnt_St]; +// float coeff = (1.0 + delta[u]) / count_path[u]; +// for(int p = head_path[u]; p; p = E_path[p].next){ +// delta[E_path[p].to] += count_path[E_path[p].to] * coeff; +// } +// if (u != S) +// bc[u] += delta[u]; +// } +// } diff --git a/cpp_easygraph/functions/centrality/centrality.h b/cpp_easygraph/functions/centrality/centrality.h new file mode 100644 index 0000000..6a204cc --- /dev/null +++ b/cpp_easygraph/functions/centrality/centrality.h @@ -0,0 +1,27 @@ +#pragma once + +#include "../../common/common.h" + +py::object closeness_centrality(py::object G, py::object weight, py::object cutoff, py::object sources); +py::object betweenness_centrality(py::object G, py::object weight, py::object cutoff, py::object sources, + py::object normalized, py::object endpoints); +py::object cpp_katz_centrality( + py::object G, + py::object py_alpha, + py::object py_beta, + py::object py_max_iter, + py::object py_tol, + py::object py_normalized +); + +py::object degree_centrality(py::object G); +py::object in_degree_centrality(py::object G); +py::object out_degree_centrality(py::object G); + +py::object cpp_eigenvector_centrality( + py::object G, + py::object py_max_iter, + py::object py_tol, + py::object py_nstart, + py::object py_weight +); \ No newline at end of file diff --git a/cpp_easygraph/functions/centrality/closeness.cpp b/cpp_easygraph/functions/centrality/closeness.cpp new file mode 100644 index 0000000..63eea70 --- /dev/null +++ b/cpp_easygraph/functions/centrality/closeness.cpp @@ -0,0 +1,353 @@ +#include "centrality.h" + +#ifdef EASYGRAPH_ENABLE_GPU +#include +#endif + +#include "../../classes/graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" + +#include +#include +#include +#include + +#ifdef _OPENMP +#include +#endif + +// Heap node: use negative value + max heap to implement min heap +typedef std::pair HeapNode; + +// Optimized adjacency list cache +struct FastAdjCache { + std::vector neighbor_ptrs; + std::vector neighbor_counts; + std::vector weight_ptrs; + std::vector> neighbor_storage; + std::vector> weight_storage; + + void init(int N) { + neighbor_ptrs.resize(N + 1, nullptr); + neighbor_counts.resize(N + 1, 0); + neighbor_storage.resize(N + 1); + } + + void init_with_weights(int N) { + init(N); + weight_ptrs.resize(N + 1, nullptr); + weight_storage.resize(N + 1); + } + + inline void build_if_needed(int u, const std::vector& head, + const std::vector& edges, bool store_weights) { + if (neighbor_ptrs[u] != nullptr) return; + + std::vector& neis = neighbor_storage[u]; + for (int p = head[u]; p != -1; p = edges[p].next) { + neis.push_back(edges[p].to); + if (store_weights) { + weight_storage[u].push_back(edges[p].w); + } + } + + neighbor_counts[u] = neis.size(); + neighbor_ptrs[u] = neis.data(); + if (store_weights) { + weight_ptrs[u] = weight_storage[u].data(); + } + } + + inline int* get_neighbors_ptr(int u) const { return neighbor_ptrs[u]; } + inline int get_neighbor_count(int u) const { return neighbor_counts[u]; } + inline float* get_weights_ptr(int u) const { return weight_ptrs[u]; } +}; + +// BFS implementation - directly use raw adjacency list +double closeness_bfs_direct(const Graph_L& G_l, const int &S, int cutoff, + std::vector& already_counted, + std::vector& queue_storage, + int timestamp) { + int N = G_l.n; + const std::vector& E = G_l.edges; + const std::vector& head = G_l.head; + + int nodes_reached = 0; + long long sum_dis = 0; + + queue_storage.clear(); + + int queue_front = 0; + already_counted[S] = timestamp; + queue_storage.push_back(S); + queue_storage.push_back(0); + + while (queue_front < static_cast(queue_storage.size())) { + int u = queue_storage[queue_front++]; + int actdist = queue_storage[queue_front++]; + + if (cutoff >= 0 && actdist > cutoff) { + continue; + } + + sum_dis += actdist; + nodes_reached++; + + for (int p = head[u]; p != -1; p = E[p].next) { + int v = E[p].to; + + if (already_counted[v] == timestamp) { + continue; + } + + already_counted[v] = timestamp; + queue_storage.push_back(v); + queue_storage.push_back(actdist + 1); + } + } + + if (nodes_reached == 1) + return 0.0; + else + return 1.0 * (nodes_reached - 1) * (nodes_reached - 1) / ((N - 1) * sum_dis); +} + +// Check if the graph is unweighted +inline bool is_unweighted_graph(const Graph_L& G_l) { + const std::vector& E = G_l.edges; + for (const auto& edge : E) { + if (std::abs(edge.w - 1.0f) > 1e-9) { + return false; + } + } + return true; +} + +// Dijkstra implementation - use on-demand adjacency cache +double closeness_dijkstra_cached(const Graph_L& G_l, const int &S, int cutoff, + std::vector& dist, + std::vector& which, + FastAdjCache& cache, + int timestamp) { + int N = G_l.n; + const std::vector& E = G_l.edges; + const std::vector& head = G_l.head; + + int nodes_reached = 0; + double sum_dis = 0.0; + + std::priority_queue heap; + + dist[S] = 1.0f; + which[S] = timestamp; + heap.push({-1.0f, S}); + + while (!heap.empty()) { + HeapNode top = heap.top(); + heap.pop(); + float mindist = -top.first; + int minnei = top.second; + + if (mindist > dist[minnei]) { + continue; + } + + float actual_dist = mindist - 1.0f; + if (cutoff >= 0 && actual_dist > cutoff) { + continue; + } + + sum_dis += actual_dist; + nodes_reached++; + + cache.build_if_needed(minnei, head, E, true); + + int* neis = cache.get_neighbors_ptr(minnei); + float* ws = cache.get_weights_ptr(minnei); + int nlen = cache.get_neighbor_count(minnei); + + for (int j = 0; j < nlen; j++) { + int to = neis[j]; + float altdist = mindist + ws[j]; + float curdist = dist[to]; + + if (which[to] != timestamp) { + which[to] = timestamp; + dist[to] = altdist; + heap.push({-altdist, to}); + } else if (curdist == 0.0f || altdist < curdist) { + dist[to] = altdist; + heap.push({-altdist, to}); + } + } + } + + if (nodes_reached == 1) + return 0.0; + else + return 1.0 * (nodes_reached - 1) * (nodes_reached - 1) / ((N - 1) * sum_dis); +} + +static py::object invoke_cpp_closeness_centrality(py::object G, py::object weight, + py::object cutoff, py::object sources) { + Graph& G_ = G.cast(); + int N = G_.node.size(); + bool is_directed = G.attr("is_directed")().cast(); + std::string weight_key = weight_to_string(weight); + const Graph_L& G_l = graph_to_linkgraph(G_, is_directed, weight_key, false, false); + int cutoff_ = -1; + if (!cutoff.is_none()){ + cutoff_ = cutoff.cast(); + } + + // Auto algorithm selection + bool use_bfs = (weight.is_none() || is_unweighted_graph(G_l)); + + std::vector CC; + + if(!sources.is_none()){ + py::list sources_list = py::list(sources); + int sources_list_len = py::len(sources_list); + CC.resize(sources_list_len); + + // Collect all source node IDs + std::vector source_ids(sources_list_len); + for(int i = 0; i < sources_list_len; i++){ + if(G_.node_to_id.attr("get")(sources_list[i],py::none()).is_none()){ + printf("The node should exist in the graph!"); + return py::none(); + } + source_ids[i] = G_.node_to_id.attr("get")(sources_list[i]).cast(); + } + + // OpenMP parallel computation + // Only enable parallelism when sources are many to avoid overhead + #pragma omp parallel if(sources_list_len > 100) + { + // Per-thread data structures (avoid race conditions) + std::vector already_counted(N + 1, 0); + std::vector queue_storage; + queue_storage.reserve(N * 2); + + std::vector dist(N + 1, 0.0f); + std::vector which(N + 1, 0); + + FastAdjCache cache; + if (!use_bfs) { + cache.init_with_weights(N); + } + + // Assign unique timestamp start for each thread + #ifdef _OPENMP + int thread_id = omp_get_thread_num(); + int num_threads = omp_get_num_threads(); + int timestamp = thread_id * sources_list_len; + #else + int timestamp = 0; + #endif + + // Parallel loop: each thread handles different source node + #pragma omp for schedule(dynamic, 1) + for(int i = 0; i < sources_list_len; i++){ + timestamp++; + double res; + if (use_bfs) { + res = closeness_bfs_direct(G_l, source_ids[i], cutoff_, + already_counted, queue_storage, timestamp); + } else { + res = closeness_dijkstra_cached(G_l, source_ids[i], cutoff_, + dist, which, cache, timestamp); + } + CC[i] = res; + } + } + } + else{ + CC.resize(N); + + // OpenMP parallel computation for all nodes + // Only enable parallelism when node count is large + #pragma omp parallel if(N > 100) + { + // Per-thread data structures + std::vector already_counted(N + 1, 0); + std::vector queue_storage; + queue_storage.reserve(N * 2); + + std::vector dist(N + 1, 0.0f); + std::vector which(N + 1, 0); + + FastAdjCache cache; + if (!use_bfs) { + cache.init_with_weights(N); + } + + // Assign unique timestamp start for each thread + #ifdef _OPENMP + int thread_id = omp_get_thread_num(); + int num_threads = omp_get_num_threads(); + int timestamp = thread_id * N; + #else + int timestamp = 0; + #endif + + // Parallel loop: dynamic scheduling for load balancing + #pragma omp for schedule(dynamic, 10) + for(int i = 1; i <= N; i++){ + timestamp++; + double res; + if (use_bfs) { + res = closeness_bfs_direct(G_l, i, cutoff_, + already_counted, queue_storage, timestamp); + } else { + res = closeness_dijkstra_cached(G_l, i, cutoff_, + dist, which, cache, timestamp); + } + CC[i - 1] = res; + } + } + } + + py::array::ShapeContainer ret_shape{(int)CC.size()}; + py::array_t ret(ret_shape, CC.data()); + + return ret; +} + +#ifdef EASYGRAPH_ENABLE_GPU +static py::object invoke_gpu_closeness_centrality(py::object G, py::object weight, py::object py_sources) { + Graph& G_ = G.cast(); + if (weight.is_none()) { + G_.gen_CSR(); + } else { + G_.gen_CSR(weight_to_string(weight)); + } + auto csr_graph = G_.csr_graph; + std::vector& E = csr_graph->E; + std::vector& V = csr_graph->V; + std::vector *W_p = weight.is_none() ? &(csr_graph->unweighted_W) + : csr_graph->W_map.find(weight_to_string(weight))->second.get(); + auto sources = G_.gen_CSR_sources(py_sources); + std::vector CC; + int gpu_r = gpu_easygraph::closeness_centrality(V, E, *W_p, *sources, CC); + + if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { + // the code below will throw an exception + py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); + } + + py::array::ShapeContainer ret_shape{(int)CC.size()}; + py::array_t ret(ret_shape, CC.data()); + + return ret; +} +#endif + +py::object closeness_centrality(py::object G, py::object weight, py::object cutoff, py::object sources) { +#ifdef EASYGRAPH_ENABLE_GPU + return invoke_gpu_closeness_centrality(G, weight, sources); +#else + return invoke_cpp_closeness_centrality(G, weight, cutoff, sources); +#endif +} \ No newline at end of file diff --git a/cpp_easygraph/functions/centrality/degree.cpp b/cpp_easygraph/functions/centrality/degree.cpp new file mode 100644 index 0000000..83a8fdc --- /dev/null +++ b/cpp_easygraph/functions/centrality/degree.cpp @@ -0,0 +1,96 @@ +#include "centrality.h" + +#include "../../classes/graph.h" +#include "../../classes/directed_graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" + +namespace py = pybind11; + +py::object degree_centrality( + py::object G +) { + Graph* graph = G.cast(); + py::dict centrality_map = py::dict(); + py::object nodes = graph->get_nodes(); + int n = py::len(nodes); + if (n <= 1) { + for (const auto& node_handle : nodes) { + centrality_map[node_handle] = 0.0; + } + return centrality_map; + } + + double scale = 1.0 / (n - 1); + + std::string class_name = G.attr("__class__").attr("__name__").cast(); + + if (class_name == "DiGraphC") { + // 有向图 (DiGraph) + DiGraph* digraph = G.cast(); + py::object adj = digraph->get_adj(); + py::object pred = digraph->get_pred(); + + for (const auto& node_handle : nodes) { + int out_deg = py::len(adj[node_handle]); + int in_deg = py::len(pred[node_handle]); + centrality_map[node_handle] = (double)(out_deg + in_deg) * scale; + } + } else { + py::object adj = graph->get_adj(); + for (const auto& node_handle : nodes) { + int degree = py::len(adj[node_handle]); + centrality_map[node_handle] = (double)degree * scale; + } + } + return centrality_map; +} + + +py::object in_degree_centrality( + py::object G +) { + DiGraph* graph = G.cast(); + py::dict centrality_map = py::dict(); + + py::object nodes = graph->get_nodes(); + int n = py::len(nodes); + + if (n <= 1) { + return centrality_map; + } + + double scale = 1.0 / (n - 1); + + py::object pred = graph->get_pred(); + + for (const auto& node_handle : nodes) { + int in_degree = py::len(pred[node_handle]); + centrality_map[node_handle] = in_degree * scale; + } + return centrality_map; +} + + +py::object out_degree_centrality( + py::object G +) { + Graph* graph = G.cast(); + py::dict centrality_map = py::dict(); + + py::object nodes = graph->get_nodes(); + int n = py::len(nodes); + + if (n <= 1) { + return centrality_map; + } + double scale = 1.0 / (n - 1); + + py::object adj = graph->get_adj(); + + for (const auto& node_handle : nodes) { + int out_degree = py::len(adj[node_handle]); + centrality_map[node_handle] = out_degree * scale; + } + return centrality_map; +} \ No newline at end of file diff --git a/cpp_easygraph/functions/centrality/eigenvector.cpp b/cpp_easygraph/functions/centrality/eigenvector.cpp new file mode 100644 index 0000000..a5f6172 --- /dev/null +++ b/cpp_easygraph/functions/centrality/eigenvector.cpp @@ -0,0 +1,198 @@ +#include +#include +#include +#include +#include +#include +#include + +#ifdef _OPENMP +#include +#endif + +#include "../../classes/graph.h" +#include "../../common/utils.h" + +namespace py = pybind11; + +class CSRMatrix { +public: + std::vector indptr; + std::vector indices; + std::vector data; // Empty if unweighted (all 1.0) + int rows, cols; + bool is_weighted; + + CSRMatrix(int r, int c) : rows(r), cols(c), is_weighted(false) { + indptr.assign(r + 1, 0); + } +}; + +// Power iteration with branch optimization for weighted/unweighted paths +std::vector power_iteration_optimized( + const CSRMatrix& A, + int max_iter, + double tol, + std::vector& x +) { + const int n = A.rows; + std::vector x_next(n); + bool use_weight = A.is_weighted && !A.data.empty(); + + // Initial normalization + double norm = 0.0; + #pragma omp parallel for reduction(+:norm) + for (int i = 0; i < n; ++i) norm += x[i] * x[i]; + norm = std::sqrt(norm); + + if (norm < 1e-12) { + std::fill(x.begin(), x.end(), 1.0 / std::sqrt(n)); + } else { + double inv_norm = 1.0 / norm; + #pragma omp parallel for + for (int i = 0; i < n; ++i) x[i] *= inv_norm; + } + + double delta = tol + 1.0; + for (int iter = 0; iter < max_iter && delta >= tol; ++iter) { + double next_norm_sq = 0.0; + + #pragma omp parallel for reduction(+:next_norm_sq) schedule(dynamic, 64) + for (int i = 0; i < n; ++i) { + double sum = 0.0; + const int start = A.indptr[i]; + const int end = A.indptr[i+1]; + + if (use_weight) { + for (int j = start; j < end; ++j) { + sum += A.data[j] * x[A.indices[j]]; + } + } else { + for (int j = start; j < end; ++j) { + sum += x[A.indices[j]]; + } + } + + x_next[i] = sum; + next_norm_sq += sum * sum; + } + + double next_norm = std::sqrt(next_norm_sq); + if (next_norm < 1e-12) break; + + double inv_next_norm = 1.0 / next_norm; + delta = 0.0; + + #pragma omp parallel for reduction(+:delta) schedule(static) + for (int i = 0; i < n; ++i) { + double val = x_next[i] * inv_next_norm; + delta += std::abs(val - x[i]); + x_next[i] = val; + } + x.swap(x_next); + } + return x; +} + +// Build transpose CSR with fallback logic for missing weight keys +CSRMatrix build_transpose_matrix_smart(Graph& graph, const std::vector& nodes, const std::string& weight_key) { + std::shared_ptr csr_ptr = weight_key.empty() ? graph.gen_CSR() : graph.gen_CSR(weight_key); + + int n = static_cast(nodes.size()); + CSRMatrix At(n, n); + if (!csr_ptr) return At; + + const auto& src_indptr = csr_ptr->V; + const auto& src_indices = csr_ptr->E; + std::vector src_data; + bool actually_weighted = false; + + // Detect if weighted calculation is required + if (!weight_key.empty()) { + auto it = csr_ptr->W_map.find(weight_key); + if (it != csr_ptr->W_map.end() && it->second) { + src_data = *(it->second); + for (double w : src_data) { + if (std::abs(w - 1.0) > 1e-9) { + actually_weighted = true; + break; + } + } + } + } + + At.is_weighted = actually_weighted; + + // Calculate row counts for transpose + for (int x_idx : src_indices) { + if (x_idx >= 0 && x_idx < n) At.indptr[x_idx + 1]++; + } + for (int i = 0; i < n; ++i) At.indptr[i + 1] += At.indptr[i]; + + At.indices.resize(src_indices.size()); + if (actually_weighted) At.data.resize(src_indices.size()); + + std::vector cur_pos(At.indptr.begin(), At.indptr.end()); + + // Populate transpose CSR data + for (int r = 0; r < n; ++r) { + for (int p = src_indptr[r]; p < src_indptr[r+1]; ++p) { + int c = src_indices[p]; + if (c < 0 || c >= n) continue; + int dest = cur_pos[c]++; + At.indices[dest] = r; + if (actually_weighted) At.data[dest] = src_data[p]; + } + } + return At; +} + +py::object cpp_eigenvector_centrality( + py::object G, + py::object py_max_iter, + py::object py_tol, + py::object py_nstart, + py::object py_weight +) { + try { + Graph& graph = G.cast(); + int max_iter = py_max_iter.cast(); + double tol = py_tol.cast(); + std::string weight_key = py_weight.is_none() ? "" : py_weight.cast(); + + if (graph.node.empty()) return py::dict(); + + std::vector nodes; + for (auto& pair : graph.node) nodes.push_back(pair.first); + int n = nodes.size(); + + CSRMatrix A_transpose = build_transpose_matrix_smart(graph, nodes, weight_key); + + // Initialize x vector (prefer degree-based or uniform) + std::vector x(n, 1.0 / n); + if (!py_nstart.is_none()) { + py::dict nstart = py_nstart.cast(); + for (int i = 0; i < n; i++) { + py::object node_obj = graph.id_to_node[py::cast(nodes[i])]; + if (nstart.contains(node_obj)) x[i] = nstart[node_obj].cast(); + } + } else { + for (int i = 0; i < n; i++) { + int degree = A_transpose.indptr[i+1] - A_transpose.indptr[i]; + x[i] = (degree > 0) ? (double)degree : 1.0/n; + } + } + + std::vector res = power_iteration_optimized(A_transpose, max_iter, tol, x); + + py::dict result; + for (int i = 0; i < n; i++) { + py::object node_obj = graph.id_to_node[py::cast(nodes[i])]; + result[node_obj] = res[i]; + } + return result; + + } catch (const std::exception& e) { + throw std::runtime_error(std::string("C++ Eigenvector Error: ") + e.what()); + } +} \ No newline at end of file diff --git a/cpp_easygraph/functions/centrality/katz_centrality.cpp b/cpp_easygraph/functions/centrality/katz_centrality.cpp new file mode 100644 index 0000000..367d439 --- /dev/null +++ b/cpp_easygraph/functions/centrality/katz_centrality.cpp @@ -0,0 +1,194 @@ +#ifdef _OPENMP +#include +#endif +#include +#include +#include +#include +#include +#include + +#include "centrality.h" +#include "../../classes/graph.h" + +namespace py = pybind11; + +class CSRMatrix { +public: + std::vector indptr; // size rows+1 + std::vector indices; // size nnz + std::vector data; // size nnz + int rows = 0; + int cols = 0; + + CSRMatrix() = default; + CSRMatrix(int r, int c) : rows(r), cols(c) { + indptr.assign(r + 1, 0); + } +}; + +// Build transpose CSR from EasyGraph CSR so that row i contains in-neighbors of i. +static CSRMatrix build_transpose_matrix_from_csr(const std::shared_ptr& csr_ptr) { + if (!csr_ptr) return CSRMatrix(); + + const int n = static_cast(csr_ptr->nodes.size()); + if (n == 0) return CSRMatrix(0, 0); + + const auto& src_indptr = csr_ptr->V; + const auto& src_indices = csr_ptr->E; + + // Unweighted: all ones. + std::vector src_data(src_indices.size(), 1.0); + + CSRMatrix At(n, n); + + // Count nnz per column in the source (becomes nnz per row in transpose). + for (int c : src_indices) { + if (c >= 0 && c < n) At.indptr[c + 1]++; + } + + // Prefix sum. + for (int i = 0; i < n; ++i) { + At.indptr[i + 1] += At.indptr[i]; + } + + const int nnz = static_cast(src_indices.size()); + At.indices.resize(nnz); + At.data.resize(nnz); + + std::vector cur_pos(At.indptr.begin(), At.indptr.end()); + + // Fill transpose. + for (int r = 0; r < n; ++r) { + const int start = src_indptr[r]; + const int end = src_indptr[r + 1]; + for (int p = start; p < end; ++p) { + const int c = src_indices[p]; + if (c < 0 || c >= n) continue; + const int dest = cur_pos[c]++; + At.indices[dest] = r; + At.data[dest] = src_data[p]; + } + } + + return At; +} + +static std::vector katz_centrality_omp(const CSRMatrix& A, + double alpha, + const std::vector& beta, + int max_iters, + double tol, + bool normalize) { + const int n = A.rows; + std::vector x(n, 1.0); // initial guess + std::vector x_next(n, 0.0); // next iterate + if (n == 0) return x; + + for (int iter = 0; iter < max_iters; ++iter) { + double err_sq = 0.0; + double norm_sq = 0.0; + + // SpMV + Katz update + error and norm in ONE pass + #pragma omp parallel for reduction(+ : err_sq, norm_sq) schedule(static) + for (int i = 0; i < n; ++i) { + double sum = 0.0; + const int row_start = A.indptr[i]; + const int row_end = A.indptr[i + 1]; + + for (int e = row_start; e < row_end; ++e) { + sum += A.data[e] * x[A.indices[e]]; + } + + const double new_val = alpha * sum + beta[i]; + const double diff = new_val - x[i]; + + x_next[i] = new_val; + err_sq += diff * diff; + norm_sq += new_val * new_val; + } + + const double err = std::sqrt(err_sq); + const double norm = std::sqrt(norm_sq); + + x.swap(x_next); + + if (norm > 0.0 && (err / norm) < tol) { + break; + } + } + + if (normalize) { + double norm_sq2 = 0.0; + #pragma omp parallel for reduction(+ : norm_sq2) schedule(static) + for (int i = 0; i < n; ++i) { + norm_sq2 += x[i] * x[i]; + } + const double norm = std::sqrt(norm_sq2); + if (norm > 0.0) { + #pragma omp parallel for schedule(static) + for (int i = 0; i < n; ++i) { + x[i] /= norm; + } + } + } + + return x; +} + +py::object cpp_katz_centrality(py::object G, + py::object py_alpha, + py::object py_beta, + py::object py_max_iter, + py::object py_tol, + py::object py_normalized) { + Graph& graph = G.cast(); + + const double alpha = py_alpha.cast(); + const int max_iter = py_max_iter.cast(); + const double tol = py_tol.cast(); + const bool normalized = py_normalized.cast(); + + std::shared_ptr csr_ptr = graph.gen_CSR(); + if (!csr_ptr || csr_ptr->nodes.empty()) { + return py::dict(); + } + + const int n = static_cast(csr_ptr->nodes.size()); + + // Build transpose CSR so that we accumulate from in-neighbors. + CSRMatrix A = build_transpose_matrix_from_csr(csr_ptr); + + // Process beta parameter: scalar or dict(node->beta). + std::vector beta(n, 1.0); + if (py::isinstance(py_beta) || py::isinstance(py_beta)) { + const double beta_val = py_beta.cast(); + #pragma omp parallel for schedule(static) + for (int i = 0; i < n; ++i) { + beta[i] = beta_val; + } + } else if (py::isinstance(py_beta)) { + py::dict beta_dict = py_beta.cast(); + for (int i = 0; i < n; ++i) { + node_t internal_id = csr_ptr->nodes[i]; + py::object node_obj = graph.id_to_node[py::cast(internal_id)]; + if (beta_dict.contains(node_obj)) { + beta[i] = beta_dict[node_obj].cast(); + } + } + } else { + throw py::type_error("beta must be a float/int or a dict"); + } + + std::vector scores = katz_centrality_omp(A, alpha, beta, max_iter, tol, normalized); + + // Prepare results + py::dict result; + for (int i = 0; i < n; ++i) { + node_t internal_id = csr_ptr->nodes[i]; + py::object node_obj = graph.id_to_node[py::cast(internal_id)]; + result[node_obj] = scores[i]; + } + + return result; +} diff --git a/cpp_easygraph/functions/community/LPA.cpp b/cpp_easygraph/functions/community/LPA.cpp new file mode 100644 index 0000000..30991f9 --- /dev/null +++ b/cpp_easygraph/functions/community/LPA.cpp @@ -0,0 +1,146 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "../../classes/graph.h" +#include "../../classes/linkgraph.h" +#include "../../common/utils.h" + +namespace py = pybind11; +using namespace std; + +py::object cpp_LPA(py::object G) { + Graph& G_ = G.cast(); + Graph_L linkgraph = G_._get_linkgraph_structure(); + int n = linkgraph.n; + + py::dict id_to_node = G_.id_to_node; + + if (n <= 1) { + py::dict result; + if (n == 1) { + py::list node_list; + node_list.append(id_to_node[py::cast(1)]); + result[py::cast(1)] = node_list; + } + return result; + } + + vector> adj(n + 1); + for (int u = 1; u <= n; ++u) { + for (int e = linkgraph.head[u]; e != -1; e = linkgraph.edges[e].next) { + int v = linkgraph.edges[e].to; + if (u != v) { + adj[u].push_back(v); + } + } + } + + for (int u = 1; u <= n; ++u) { + if (adj[u].size() > 1) { + sort(adj[u].begin(), adj[u].end()); + adj[u].erase(unique(adj[u].begin(), adj[u].end()), adj[u].end()); + } + } + + vector labels(n); + vector nodes(n); + for (int i = 0; i < n; ++i) { + labels[i] = i; + nodes[i] = i + 1; + } + + random_device rd; + mt19937 gen(rd()); + + const int MAX_ITERATIONS = 1000; + int iteration_count = 0; + + vector label_count(n, 0); + vector active_labels; + active_labels.reserve(128); + + while (iteration_count < MAX_ITERATIONS) { + iteration_count++; + + shuffle(nodes.begin(), nodes.end(), gen); + + bool changed = false; + for (int node : nodes) { + int max_count = 0; + + for (int neighbor : adj[node]) { + int neighbor_label = labels[neighbor - 1]; + if (label_count[neighbor_label] == 0) { + active_labels.push_back(neighbor_label); + } + label_count[neighbor_label]++; + if (label_count[neighbor_label] > max_count) { + max_count = label_count[neighbor_label]; + } + } + + if (max_count > 0) { + int current_label = labels[node - 1]; + + vector best_labels; + for (int label : active_labels) { + if (label_count[label] == max_count) { + best_labels.push_back(label); + } + } + + bool current_is_best = false; + for (int label : best_labels) { + if (label == current_label) { + current_is_best = true; + break; + } + } + + if (!current_is_best) { + changed = true; + + if (best_labels.size() == 1) { + labels[node - 1] = best_labels[0]; + } else { + uniform_int_distribution<> dis(0, best_labels.size() - 1); + labels[node - 1] = best_labels[dis(gen)]; + } + } + } + + for (int label : active_labels) { + label_count[label] = 0; + } + active_labels.clear(); + } + + if (!changed) { + break; + } + } + + unordered_map> label_to_nodes; + for (int i = 0; i < n; ++i) { + int label = labels[i]; + label_to_nodes[label].push_back(i + 1); + } + + py::dict result; + int community_id = 1; + for (const auto& pair : label_to_nodes) { + py::list node_list; + for (int internal_id : pair.second) { + node_list.append(id_to_node[py::cast(internal_id)]); + } + result[py::cast(community_id)] = node_list; + community_id++; + } + + return result; +} \ No newline at end of file diff --git a/cpp_easygraph/functions/community/LPA.h b/cpp_easygraph/functions/community/LPA.h new file mode 100644 index 0000000..2cecea6 --- /dev/null +++ b/cpp_easygraph/functions/community/LPA.h @@ -0,0 +1,7 @@ +#pragma once + +#include + +namespace py = pybind11; + +py::object cpp_LPA(py::object G); diff --git a/cpp_easygraph/functions/community/__init__.h b/cpp_easygraph/functions/community/__init__.h new file mode 100644 index 0000000..7a72252 --- /dev/null +++ b/cpp_easygraph/functions/community/__init__.h @@ -0,0 +1,10 @@ +#pragma once + +#include "modularity.h" +#include "greedy_modularity.h" +#include "motif.h" +#include "louvain.h" +#include "LPA.h" +#include "ego_graph.h" +#include "graph_coloring.h" +#include "localsearch.h" diff --git a/cpp_easygraph/functions/community/ego_graph.cpp b/cpp_easygraph/functions/community/ego_graph.cpp new file mode 100644 index 0000000..31f6fce --- /dev/null +++ b/cpp_easygraph/functions/community/ego_graph.cpp @@ -0,0 +1,155 @@ +#include "ego_graph.h" +#include "subgraph.h" +#include "../../classes/graph.h" +#include "../../classes/directed_graph.h" +#include "../../classes/csr_graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" +#include "../path/path.h" +#include + + +struct _EgoGraphCore { + Graph_L G_l; + std::vector node_ids; + std::unordered_map node_to_idx; + py::dict id_to_node_py; + bool has_error = false; +}; + + +static _EgoGraphCore _cpp_ego_graph_compute_impl( + Graph& G_, + py::object n, + double radius_val, + bool center_val, + bool undirected_val, + bool is_directed, + const std::string& weight_key) { + + _EgoGraphCore out; + + if (G_.node_to_id.contains(n) == 0) { + PyErr_Format(PyExc_KeyError, "Node %R is not in the graph.", n.ptr()); + out.has_error = true; + return out; + } + + node_t center_id = G_.node_to_id[n].cast(); + out.id_to_node_py = G_.id_to_node; + + if (G_.linkgraph_dirty || G_.linkgraph_structure.max_deg == -1) { + if (undirected_val) { + out.G_l = graph_to_linkgraph(G_, false, weight_key, true, false); + } else { + out.G_l = graph_to_linkgraph(G_, is_directed, weight_key, true, false); + } + G_.linkgraph_dirty = false; + G_.linkgraph_structure = out.G_l; // also cache the freshly built linkgraph + } else { + out.G_l = G_.linkgraph_structure; + } + + std::vector dist = _dijkstra(out.G_l, center_id, -1); + int N = out.G_l.n; + for (int i = 1; i <= N; i++) { + if (dist[i] > radius_val) continue; + if (!center_val && i == (int)center_id) continue; + out.node_to_idx[i] = (int)out.node_ids.size(); + out.node_ids.push_back(i); + } + return out; +} + + +static _EgoGraphCore _cpp_ego_graph_compute( + py::object G, + py::object n, + py::object radius, + py::object center, + py::object undirected, + py::object distance) { + + bool is_directed = G.attr("is_directed")().cast(); + bool center_val = center.cast(); + bool undirected_val = undirected.cast(); + double radius_val = radius.cast(); + std::string weight_key = weight_to_string(distance); + + if (is_directed) { + DiGraph& G_ = G.cast(); + return _cpp_ego_graph_compute_impl( + G_, n, radius_val, center_val, undirected_val, is_directed, weight_key); + } else { + Graph& G_ = G.cast(); + return _cpp_ego_graph_compute_impl( + G_, n, radius_val, center_val, undirected_val, is_directed, weight_key); + } +} + + +py::object cpp_ego_graph( + py::object G, + py::object n, + py::object radius, + py::object center, + py::object undirected, + py::object distance) { + + _EgoGraphCore core = _cpp_ego_graph_compute(G, n, radius, center, undirected, distance); + if (core.has_error) return py::none(); + + return nodes_subgraph_cpp(G, core.node_ids); +} + + +py::object cpp_ego_graph_csr( + py::object G, + py::object n, + py::object radius, + py::object center, + py::object undirected, + py::object distance) { + + _EgoGraphCore core = _cpp_ego_graph_compute(G, n, radius, center, undirected, distance); + if (core.has_error) return py::none(); + + int n_nodes = (int)core.node_ids.size(); + if (n_nodes == 0) { + return py::dict(); + } + + std::vector V(n_nodes + 1, 0); + std::vector E; + std::vector W; + + for (int new_u = 0; new_u < n_nodes; new_u++) { + node_t u = core.node_ids[new_u]; + V[new_u + 1] = V[new_u]; + + int edge_idx = core.G_l.head[u]; + while (edge_idx != -1 && edge_idx < core.G_l.e) { + node_t v = core.G_l.edges[edge_idx].to; + auto it = core.node_to_idx.find(v); + if (it != core.node_to_idx.end()) { + E.push_back(it->second); + W.push_back(core.G_l.edges[edge_idx].w); + V[new_u + 1]++; + } + edge_idx = core.G_l.edges[edge_idx].next; + } + } + + py::list original_nodes; + for (node_t internal_id : core.node_ids) { + original_nodes.append(core.id_to_node_py[py::cast(internal_id)]); + } + + py::dict result; + result["nodes"] = original_nodes; + result["V"] = py::cast(V); + result["E"] = py::cast(E); + result["W"] = py::cast(W); + + return result; +} diff --git a/cpp_easygraph/functions/community/ego_graph.h b/cpp_easygraph/functions/community/ego_graph.h new file mode 100644 index 0000000..61464a7 --- /dev/null +++ b/cpp_easygraph/functions/community/ego_graph.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace py = pybind11; + +py::object cpp_ego_graph( + py::object G, + py::object n, + py::object radius = py::int_(1), + py::object center = py::bool_(true), + py::object undirected = py::bool_(false), + py::object distance = py::none() +); + +py::object cpp_ego_graph_csr( + py::object G, + py::object n, + py::object radius = py::int_(1), + py::object center = py::bool_(true), + py::object undirected = py::bool_(false), + py::object distance = py::none() +); diff --git a/cpp_easygraph/functions/community/graph_coloring.cpp b/cpp_easygraph/functions/community/graph_coloring.cpp new file mode 100644 index 0000000..47c1f53 --- /dev/null +++ b/cpp_easygraph/functions/community/graph_coloring.cpp @@ -0,0 +1,135 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _OPENMP +#include +#else +#warning "OpenMP is not available: omp_graph_coloring will fall back to single-threaded execution." +#endif + +#include "../../classes/linkgraph.h" + +using namespace std; + +vector greedy_graph_coloring(const Graph_L& G) { + int n = G.n; + if (n == 0) return vector(); + + vector degree(n + 1, 0); + for (int v = 1; v <= n; ++v) { + for (int e = G.head[v]; e != -1; e = G.edges[e].next) { + degree[v]++; + } + } + + vector nodes(n); + iota(nodes.begin(), nodes.end(), 1); + sort(nodes.begin(), nodes.end(), [°ree](int a, int b) { + return degree[a] > degree[b]; + }); + + vector colors(n, -1); + vector used(n + 1, -1); + + for (int u : nodes) { + for (int e = G.head[u]; e != -1; e = G.edges[e].next) { + int v = G.edges[e].to; + if (v == u) continue; + int neighbor_color = colors[v - 1]; + if (neighbor_color != -1) { + used[neighbor_color] = u; + } + } + + int cr = 0; + while (cr <= n && used[cr] == u) { + cr++; + } + colors[u - 1] = cr; + } + + return colors; +} + +vector omp_graph_coloring(const Graph_L& G) { + int n = G.n; + if (n == 0) return vector(); + + vector degree(n + 1, 0); + for (int v = 1; v <= n; ++v) { + for (int e = G.head[v]; e != -1; e = G.edges[e].next) { + degree[v]++; + } + } + + vector nodes(n); + iota(nodes.begin(), nodes.end(), 1); + sort(nodes.begin(), nodes.end(), [°ree](int a, int b) { + return degree[a] > degree[b]; + }); + + vector colors(n, -1); + + #pragma omp parallel + { + vector local_used(n + 1, -1); + + #pragma omp for schedule(guided) + for (int i = 0; i < n; ++i) { + int u = nodes[i]; + + fill(local_used.begin(), local_used.end(), -1); + + for (int e = G.head[u]; e != -1; e = G.edges[e].next) { + int v = G.edges[e].to; + if (v == u) continue; + int neighbor_color = colors[v - 1]; + if (neighbor_color != -1 && neighbor_color <= n) { + local_used[neighbor_color] = u; + } + } + + int cr = 0; + while (cr <= n && local_used[cr] == u) { + cr++; + } + colors[u - 1] = cr; + } + } + + return colors; +} + +bool verify_coloring(const Graph_L& g, const vector& colors) { + int n = g.n; + for (int v = 1; v <= n; v++) { + int v_color = colors[v - 1]; + for (int e = g.head[v]; e != -1; e = g.edges[e].next) { + int u = g.edges[e].to; + if (u == v) continue; + int u_color = colors[u - 1]; + if (v_color == u_color) { + return false; + } + } + } + return true; +} + +int count_colors(const vector& colors) { + int max_color = -1; + for (int c : colors) { + if (c > max_color) { + max_color = c; + } + } + return max_color + 1; +} \ No newline at end of file diff --git a/cpp_easygraph/functions/community/graph_coloring.h b/cpp_easygraph/functions/community/graph_coloring.h new file mode 100644 index 0000000..37aec87 --- /dev/null +++ b/cpp_easygraph/functions/community/graph_coloring.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include "../../classes/linkgraph.h" + +std::vector greedy_graph_coloring(const Graph_L& G); + +std::vector omp_graph_coloring(const Graph_L& G); + +bool verify_coloring(const Graph_L& g, const std::vector& colors); + +int count_colors(const std::vector& colors); \ No newline at end of file diff --git a/cpp_easygraph/functions/community/greedy_modularity.cpp b/cpp_easygraph/functions/community/greedy_modularity.cpp new file mode 100644 index 0000000..6bec2d2 --- /dev/null +++ b/cpp_easygraph/functions/community/greedy_modularity.cpp @@ -0,0 +1,289 @@ +#include "indexed_heap.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "../../classes/graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" +#include "greedy_modularity.h" + +namespace py = pybind11; +using namespace std; + +py::object cpp_greedy_modularity_communities(py::object G, py::object weight) { + Graph& G_ = G.cast(); + + bool is_directed = G.attr("is_directed")().cast(); + if (is_directed) { + throw py::value_error("greedy_modularity_communities currently only supports undirected graphs (Graph class). " + "For directed graphs, please use other community detection algorithms."); + } + + string weight_key = weight.is_none() ? "weight" : weight.cast(); + + Graph_L GL; + bool used_cached_linkgraph = false; + + if (G_.linkgraph_dirty || G_.linkgraph_structure.max_deg == -1) { + GL = graph_to_linkgraph(G_, false, weight_key, true, false); + G_.linkgraph_dirty = false; + } else { + GL = G_.linkgraph_structure; + used_cached_linkgraph = true; + } + + int N = GL.n; + + if (N == 0) { + return py::list(); + } + + double m = 0.0; + vector k(N + 1, 0.0); + + for (int u = 1; u <= N; ++u) { + for (int e = GL.head[u]; e != -1; e = GL.edges[e].next) { + int v = GL.edges[e].to; + double w = GL.edges[e].w; + if (v > u) { + m += w; + } + k[u] += w; + } + } + + if (m == 0) { + py::list result; + py::dict id_to_node = G_.id_to_node; + for (int i = 1; i <= N; ++i) { + py::set comm; + comm.add(id_to_node[py::cast(i)]); + result.append(comm); + } + return result; + } + + double q0 = 1.0 / (2.0 * m); + + vector a(N); + for (int i = 0; i < N; ++i) { + a[i] = k[i + 1] * q0; + } + + vector parent(N); + vector> nodes(N); + for (int i = 0; i < N; ++i) { + parent[i] = i; + nodes[i].push_back(i + 1); + } + + vector> neigh(N); + int max_neigh_size = 0; + for (int i = 0; i < N; ++i) { + int u = i + 1; + for (int e = GL.head[u]; e != -1; e = GL.edges[e].next) { + int v = GL.edges[e].to; + if (v > 0 && v <= N && v != u) { + neigh[i].push_back(v - 1); + } + } + sort(neigh[i].begin(), neigh[i].end()); + neigh[i].erase(unique(neigh[i].begin(), neigh[i].end()), neigh[i].end()); + if ((int)neigh[i].size() > max_neigh_size) { + max_neigh_size = neigh[i].size(); + } + } + + unordered_map edge_weights; + for (int u = 1; u <= N; ++u) { + for (int e = GL.head[u]; e != -1; e = GL.edges[e].next) { + int v = GL.edges[e].to; + double w = GL.edges[e].w; + if (v > u) { + long long key = ((long long)(u - 1) << 32) | (unsigned int)(v - 1); + edge_weights[key] = w; + } + } + } + + unordered_map dq; + dq.reserve(N * 4); + + for (int i = 0; i < N; ++i) { + int u = i + 1; + for (int j : neigh[i]) { + if (j > i) { + int v = j + 1; + long long edge_key = ((long long)i << 32) | (unsigned int)j; + double w = edge_weights.count(edge_key) ? edge_weights[edge_key] : 0.0; + double dq_val = 2.0 * w * q0 - 2.0 * k[u] * k[v] * q0 * q0; + long long key = ((long long)i << 32) | (unsigned int)j; + dq[key] = dq_val; + } + } + } + + IndexedMaxHeap H(N); + for (const auto& kv : dq) { + int i = (int)(kv.first >> 32); + int j = (int)(kv.first & 0xFFFFFFFF); + H.push(kv.second, i, j); + } + + vector merged(N, 0); + int merge_count = 0; + vector combined; + combined.reserve(max_neigh_size * 2); + + while (!H.empty()) { + auto best = H.pop(); + + int i = best.i; + int j = best.j; + + if (merged[i] || merged[j]) { + continue; + } + if (parent[i] != i || parent[j] != j) { + continue; + } + + long long key = ((long long)i << 32) | (unsigned int)j; + auto it = dq.find(key); + if (it == dq.end() || it->second != best.dq) { + continue; + } + if (best.dq <= 0) { + break; + } + + nodes[i].insert(nodes[i].end(), nodes[j].begin(), nodes[j].end()); + nodes[j].clear(); + parent[j] = i; + merged[j] = 1; + merge_count++; + + combined.clear(); + + const vector& list_i = neigh[i]; + const vector& list_j = neigh[j]; + size_t p1 = 0, p2 = 0; + + while (p1 < list_i.size() && p2 < list_j.size()) { + int val1 = list_i[p1]; + int val2 = list_j[p2]; + if (val1 < val2) { + if (val1 != i && val1 != j && !merged[val1]) { + combined.push_back(val1); + } + p1++; + } else if (val1 > val2) { + if (val2 != i && val2 != j && !merged[val2]) { + combined.push_back(val2); + } + p2++; + } else { + if (val1 != i && val1 != j && !merged[val1]) { + combined.push_back(val1); + } + p1++; + p2++; + } + } + while (p1 < list_i.size()) { + int val = list_i[p1]; + if (val != i && val != j && !merged[val]) { + combined.push_back(val); + } + p1++; + } + while (p2 < list_j.size()) { + int val = list_j[p2]; + if (val != i && val != j && !merged[val]) { + combined.push_back(val); + } + p2++; + } + + neigh[i].swap(combined); + + for (int k_node : neigh[i]) { + if (k_node == i) continue; + if (parent[k_node] != k_node) continue; + + long long key_ik = ((long long)min(i, k_node) << 32) | (unsigned int)max(i, k_node); + long long key_jk = ((long long)min(j, k_node) << 32) | (unsigned int)max(j, k_node); + + bool has_ik = dq.find(key_ik) != dq.end(); + bool has_jk = dq.find(key_jk) != dq.end(); + + double new_dq; + if (has_ik && has_jk) { + new_dq = dq[key_ik] + dq[key_jk]; + } else if (has_jk) { + new_dq = dq[key_jk] - 2.0 * a[i] * a[k_node]; + } else { + new_dq = dq[key_ik] - 2.0 * a[j] * a[k_node]; + } + + dq[key_ik] = new_dq; + if (has_jk) { + dq.erase(key_jk); + } + + if (H.get_index(i, k_node) >= 0) { + H.update(new_dq, i, k_node); + } else { + H.push(new_dq, i, k_node); + } + } + + const vector& old_j_neigh = neigh[j]; + for (int k_node : old_j_neigh) { + if (k_node == i || k_node == j) continue; + if (parent[k_node] != k_node) continue; + H.remove(j, k_node); + } + + neigh[j].clear(); + + a[i] += a[j]; + a[j] = 0; + } + + py::dict id_to_node = G_.id_to_node; + py::list result; + + for (int i = 0; i < N; ++i) { + if (parent[i] == i && !nodes[i].empty()) { + py::set comm; + for (int node_id : nodes[i]) { + comm.add(id_to_node[py::cast(node_id)]); + } + result.append(comm); + } + } + + py::list sorted_result; + vector sizes; + for (size_t i = 0; i < result.size(); ++i) { + sizes.push_back(py::len(result[i])); + } + + vector indices(result.size()); + iota(indices.begin(), indices.end(), 0); + sort(indices.begin(), indices.end(), [&](int a_idx, int b_idx) { + return sizes[a_idx] > sizes[b_idx]; + }); + + for (int idx : indices) { + sorted_result.append(result[idx]); + } + + return sorted_result; +} \ No newline at end of file diff --git a/cpp_easygraph/functions/community/greedy_modularity.h b/cpp_easygraph/functions/community/greedy_modularity.h new file mode 100644 index 0000000..dc3b1c4 --- /dev/null +++ b/cpp_easygraph/functions/community/greedy_modularity.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace py = pybind11; + + +py::object cpp_greedy_modularity_communities( + py::object G, + py::object weight = py::str("weight") +); diff --git a/cpp_easygraph/functions/community/indexed_heap.h b/cpp_easygraph/functions/community/indexed_heap.h new file mode 100644 index 0000000..32eb20a --- /dev/null +++ b/cpp_easygraph/functions/community/indexed_heap.h @@ -0,0 +1,191 @@ +#pragma once + +#include +#include + +class IndexedMaxHeap { +public: + struct HeapEntry { + double dq; + int i; + int j; + }; + +private: + std::vector heap; + std::vector> pair_to_idx; + size_t heap_size; + int capacity_n; + + static inline long long make_key(int i, int j) { + int a = std::min(i, j); + int b = std::max(i, j); + return ((long long)a << 32) | (unsigned int)b; + } + +public: + IndexedMaxHeap(size_t capacity = 0) : heap_size(0), capacity_n((int)capacity) { + heap.reserve(capacity); + if (capacity > 0) { + pair_to_idx.assign(capacity, std::vector(capacity, -1)); + } + } + + bool empty() const { return heap_size == 0; } + size_t size() const { return heap_size; } + + int get_index(int i, int j) const { + int a = std::min(i, j); + int b = std::max(i, j); + if (a < 0 || b < 0 || a >= capacity_n || b >= capacity_n) return -1; + size_t idx = (size_t)pair_to_idx[a][b]; + if (idx >= heap.size()) return -1; + if (heap[idx].i == a && heap[idx].j == b) { + return (int)idx; + } + return -1; + } + + void push(double dq, int i, int j) { + int a = std::min(i, j); + int b = std::max(i, j); + if (a < 0 || b < 0 || a >= capacity_n || b >= capacity_n) return; + + size_t existing = (size_t)pair_to_idx[a][b]; + if (existing < heap.size() && heap[existing].i == a && heap[existing].j == b) { + update(dq, i, j); + return; + } + + HeapEntry entry = {dq, a, b}; + heap.push_back(entry); + size_t idx = heap.size() - 1; + pair_to_idx[a][b] = (int)idx; + sift_up(idx); + heap_size++; + } + + void update(double dq, int i, int j) { + int a = std::min(i, j); + int b = std::max(i, j); + if (a < 0 || b < 0 || a >= capacity_n || b >= capacity_n) return; + + size_t idx = (size_t)pair_to_idx[a][b]; + if (idx >= heap.size()) return; + if (heap[idx].i != a || heap[idx].j != b) return; + if (heap[idx].dq == dq) return; + + bool is_increase = dq > heap[idx].dq; + heap[idx].dq = dq; + + if (is_increase) { + sift_up(idx); + } else { + sift_down(idx); + } + } + + void remove(int i, int j) { + int a = std::min(i, j); + int b = std::max(i, j); + if (a < 0 || b < 0 || a >= capacity_n || b >= capacity_n) return; + + size_t idx = (size_t)pair_to_idx[a][b]; + if (idx >= heap.size()) return; + if (heap[idx].i != a || heap[idx].j != b) return; + + pair_to_idx[a][b] = -1; + + if (idx == heap.size() - 1) { + heap.pop_back(); + } else { + heap[idx] = heap.back(); + int last_a = heap[idx].i; + int last_b = heap[idx].j; + if (last_a >= 0 && last_a < capacity_n && last_b >= 0 && last_b < capacity_n) { + pair_to_idx[last_a][last_b] = (int)idx; + } + heap.pop_back(); + sift_up(idx); + sift_down(idx); + } + if (heap_size > 0) heap_size--; + } + + HeapEntry pop() { + HeapEntry result = heap[0]; + pair_to_idx[result.i][result.j] = -1; + + if (heap.size() > 1) { + heap[0] = heap.back(); + int new_a = heap[0].i; + int new_b = heap[0].j; + if (new_a >= 0 && new_a < capacity_n && new_b >= 0 && new_b < capacity_n) { + pair_to_idx[new_a][new_b] = 0; + } + heap.pop_back(); + sift_down(0); + } else { + heap.pop_back(); + } + if (heap_size > 0) heap_size--; + + return result; + } + + const HeapEntry& top() const { return heap[0]; } + +private: + void sift_up(size_t idx) { + while (idx > 0) { + size_t parent = (idx - 1) >> 1; + if (heap[parent].dq >= heap[idx].dq) break; + + std::swap(heap[parent], heap[idx]); + + int p_i = heap[parent].i, p_j = heap[parent].j; + int c_i = heap[idx].i, c_j = heap[idx].j; + + if (p_i >= 0 && p_i < capacity_n && p_j >= 0 && p_j < capacity_n) { + pair_to_idx[p_i][p_j] = (int)parent; + } + if (c_i >= 0 && c_i < capacity_n && c_j >= 0 && c_j < capacity_n) { + pair_to_idx[c_i][c_j] = (int)idx; + } + + idx = parent; + } + } + + void sift_down(size_t idx) { + size_t n = heap.size(); + while (true) { + size_t largest = idx; + size_t left = idx * 2 + 1; + size_t right = idx * 2 + 2; + + if (left < n && heap[left].dq > heap[largest].dq) { + largest = left; + } + if (right < n && heap[right].dq > heap[largest].dq) { + largest = right; + } + + if (largest == idx) break; + + std::swap(heap[largest], heap[idx]); + + int l_i = heap[largest].i, l_j = heap[largest].j; + int c_i = heap[idx].i, c_j = heap[idx].j; + + if (l_i >= 0 && l_i < capacity_n && l_j >= 0 && l_j < capacity_n) { + pair_to_idx[l_i][l_j] = (int)largest; + } + if (c_i >= 0 && c_i < capacity_n && c_j >= 0 && c_j < capacity_n) { + pair_to_idx[c_i][c_j] = (int)idx; + } + + idx = largest; + } + } +}; diff --git a/cpp_easygraph/functions/community/localsearch.cpp b/cpp_easygraph/functions/community/localsearch.cpp new file mode 100644 index 0000000..dc996ec --- /dev/null +++ b/cpp_easygraph/functions/community/localsearch.cpp @@ -0,0 +1,613 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../../classes/graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" +#include "localsearch.h" + +namespace py = pybind11; +using namespace std; + +struct RootDecision { + int superior; + int path_length; + int degree; +}; + +int choose_center(const vector>& sorted_multi) { + if (sorted_multi.size() < 2) { + return 1; + } + + vector y; + for (const auto& p : sorted_multi) { + y.push_back(p.second); + } + + vector delta; + for (size_t i = 1; i < y.size(); ++i) { + delta.push_back(fabs(y[i] - y[i-1])); + } + + if (delta.empty()) { + return 1; + } + + vector delta_nozero; + for (double d : delta) { + if (d != 0) { + delta_nozero.push_back(d); + } + } + + if (delta_nozero.empty()) { + return 1; + } + + double mean = 0; + for (double d : delta_nozero) { + mean += d; + } + mean /= delta_nozero.size(); + + double variance = 0; + for (double d : delta_nozero) { + variance += (d - mean) * (d - mean); + } + variance /= delta_nozero.size(); + double std_dev = sqrt(variance); + + double threshold = std_dev + mean; + + for (size_t i = 0; i < delta.size(); ++i) { + if (delta[i] > threshold) { + return i + 1; + } + } + + return 0; +} + +py::object cpp_localsearch( + py::object G, + py::object center_num, + py::object auto_choose_centers, + py::object maximum_tree, + py::object seed, + py::object self_loop +) { + Graph_generate_linkgraph(G, py::str("weight")); + Graph& G_ = G.cast(); + Graph_L GL = G_._get_linkgraph_structure(); + py::dict id_to_node = G_.id_to_node; + + int n = GL.n; + if (n == 0) { + return py::make_tuple(py::none(), py::list(), py::list(), py::dict(), py::dict(), py::none()); + } + + bool has_edges = false; + for (int u = 1; u <= n; ++u) { + if (GL.head[u] != -1) { + has_edges = true; + break; + } + } + if (!has_edges) { + py::dict result_grouped; + py::list result_center_dcd; + py::list result_y_dcd; + py::dict result_y_partition; + for (int u = 1; u <= n; ++u) { + py::object node = id_to_node[py::cast(u)]; + py::list members; + members.append(node); + result_grouped[node] = members; + result_center_dcd.append(node); + result_y_dcd.append(node); + result_y_partition[node] = node; + } + return py::make_tuple(py::none(), result_center_dcd, result_y_dcd, result_y_partition, result_grouped, py::none()); + } + + std::mt19937 rng(42); + if (!seed.is_none()) { + try { + int seed_value = seed.cast(); + rng.seed(seed_value); + } catch (...) { + } + } + std::uniform_real_distribution random_dist(0.0, 1.0); + + unordered_set selfloop_nodes; + bool has_selfloop = self_loop.cast(); + if (!has_selfloop) { + selfloop_nodes.clear(); + } + + vector degree(n + 1, 0); + for (int u = 1; u <= n; ++u) { + int deg = 0; + for (int e = GL.head[u]; e != -1; e = GL.edges[e].next) { + deg++; + } + if (selfloop_nodes.count(u)) { + degree[u] = deg + 1; + } else { + degree[u] = deg; + } + } + + unordered_map> dag_adj; + unordered_map> dag_pred; + + for (int v = 1; v <= n; ++v) { + if (degree[v] == 0) continue; + int kv = degree[v]; + vector> neighbors; + for (int e = GL.head[v]; e != -1; e = GL.edges[e].next) { + int nn = GL.edges[e].to; + if (nn == v && selfloop_nodes.count(v)) continue; + int deg_nn = degree[nn]; + neighbors.push_back({nn, deg_nn}); + } + + if (!neighbors.empty()) { + int knnmax = -1; + for (auto& p : neighbors) { + if (p.second > knnmax) knnmax = p.second; + } + + if (knnmax >= kv) { + for (auto& p : neighbors) { + if (p.second == knnmax) { + int nn = p.first; + + bool already_has = false; + bool has_reverse = false; + for (int existing : dag_adj[v]) { + if (existing == nn) { + already_has = true; + break; + } + } + + for (int existing : dag_adj[nn]) { + if (existing == v) { + has_reverse = true; + break; + } + } + if (!already_has && !has_reverse) { + dag_adj[v].push_back(nn); + dag_pred[nn].push_back(v); + } + } + } + } + } + } + + vector out_degree_dag(n + 1, 0); + for (int u = 1; u <= n; ++u) { + out_degree_dag[u] = (int)dag_adj[u].size(); + } + + vector roots; + for (int u = 1; u <= n; ++u) { + if (out_degree_dag[u] == 0 && degree[u] > 0) { + roots.push_back(u); + } + } + + if (roots.empty()) { + for (int u = 1; u <= n; ++u) { + if (degree[u] > 0) { + roots.push_back(u); + } + } + } + + if (roots.size() > 1) { + bool all_same_degree = true; + int first_degree = -1; + for (int root : roots) { + if (first_degree == -1) { + first_degree = degree[root]; + } else if (degree[root] != first_degree) { + all_same_degree = false; + break; + } + } + + if (all_same_degree) { + int max_root = -1; + for (int root : roots) { + if (root > max_root) { + max_root = root; + } + } + roots.clear(); + roots.push_back(max_root); + } + } + + unordered_map tree_rootnode; + unordered_map tree_parentnode; + unordered_map tree_distancetoroot; + + for (int i = 1; i <= n; ++i) { + tree_rootnode[i] = -1; + tree_parentnode[i] = -1; + tree_distancetoroot[i] = -1; + } + + queue> bfs_queue; + for (int root : roots) { + bfs_queue.push({root, 0}); + tree_rootnode[root] = root; + tree_parentnode[root] = -1; + tree_distancetoroot[root] = 0; + } + + vector visited(n + 1, 0); + for (int root : roots) { + visited[root] = 1; + } + + while (!bfs_queue.empty()) { + int parent, dist; + std::tie(parent, dist) = bfs_queue.front(); + bfs_queue.pop(); + + for (int pred : dag_pred[parent]) { + + if (tree_distancetoroot[pred] != -1 && tree_distancetoroot[pred] < dist + 1) { + continue; + } + + if (tree_distancetoroot[pred] == -1) { + + tree_rootnode[pred] = tree_rootnode[parent]; + tree_parentnode[pred] = parent; + tree_distancetoroot[pred] = dist + 1; + bfs_queue.push({pred, dist + 1}); + } else if (tree_distancetoroot[pred] == dist + 1) { + + if (random_dist(rng) < 0.5) { + continue; + } + + tree_rootnode[pred] = tree_rootnode[parent]; + tree_parentnode[pred] = parent; + } + } + } + + unordered_map> root_to_node; + for (int node = 1; node <= n; ++node) { + int root = tree_rootnode[node]; + if (root != -1) { + root_to_node[root].push_back(node); + } + } + + vector valid_roots; + for (auto& kv : root_to_node) { + if ((int)kv.second.size() > 1) { + valid_roots.push_back(kv.first); + } + } + + unordered_set root_set(valid_roots.begin(), valid_roots.end()); + unordered_map root_decision; + + auto BFS_from_s = [&](int s) -> pair { + queue search_queue; + unordered_map path_dict; + unordered_set seen; + + search_queue.push(s); + seen.insert(s); + path_dict[s] = 0; + + while (!search_queue.empty()) { + int vertex = search_queue.front(); + search_queue.pop(); + int current_dist = path_dict[vertex]; + + vector> neighbors; + for (int e = GL.head[vertex]; e != -1; e = GL.edges[e].next) { + int nn = GL.edges[e].to; + int deg_nn = degree[nn]; + neighbors.push_back({nn, deg_nn}); + } + + sort(neighbors.begin(), neighbors.end(), + [](const pair& a, const pair& b) { + return a.second > b.second; + }); + + for (auto& p : neighbors) { + int w = p.first; + if (!seen.count(w)) { + path_dict[w] = current_dist + 1; + seen.insert(w); + search_queue.push(w); + } + + if (root_set.count(w) && degree[w] > degree[s]) { + return {w, path_dict[w]}; + } + } + } + return {s, -1}; + }; + + for (int root : valid_roots) { + auto result = BFS_from_s(root); + root_decision[root] = {result.first, result.second, degree[root]}; + } + + int max_path = -1; + for (auto& kv : root_decision) { + if (kv.second.path_length > max_path) { + max_path = kv.second.path_length; + } + } + if (max_path < 0) max_path = 2; + + for (auto& kv : root_decision) { + if (kv.second.path_length == -1) { + kv.second.path_length = max_path; + } + } + + unordered_map node_plot = root_decision; + for (int node = 1; node <= n; ++node) { + if (node_plot.find(node) == node_plot.end() && degree[node] > 0) { + int parent = tree_parentnode[node]; + node_plot[node] = {parent, 1, degree[node]}; + } + } + + vector node_ids; + vector degrees; + vector path_lens; + for (auto& kv : node_plot) { + node_ids.push_back(kv.first); + degrees.push_back(kv.second.degree); + path_lens.push_back(kv.second.path_length); + } + + for (size_t i = 0; i < path_lens.size(); ++i) { + if (degrees[i] <= 1) { + path_lens[i] = 1; + } + } + + unordered_map degree_rank; + vector> sorted_by_deg; + for (size_t i = 0; i < node_ids.size(); ++i) { + sorted_by_deg.push_back({degrees[i], node_ids[i]}); + } + sort(sorted_by_deg.begin(), sorted_by_deg.end(), + [](const pair& a, const pair& b) { + return a.first < b.first; + }); + + int rank = 1; + int last_deg = -1; + for (auto& p : sorted_by_deg) { + if (p.first != last_deg) { + degree_rank[p.second] = rank; + rank++; + last_deg = p.first; + } else { + degree_rank[p.second] = rank - 1; + } + } + + int min_rank = 1; + int max_rank = rank - 1; + double rank_range = (max_rank - min_rank); + if (rank_range == 0) rank_range = 1; + + vector square_path(node_ids.size()); + double max_sq_path = 0; + double min_sq_path = 1e9; + for (size_t i = 0; i < node_ids.size(); ++i) { + square_path[i] = (double)path_lens[i] * (double)path_lens[i]; + if (square_path[i] > max_sq_path) max_sq_path = square_path[i]; + if (square_path[i] < min_sq_path) min_sq_path = square_path[i]; + } + double sq_range = max_sq_path - min_sq_path; + if (sq_range == 0) sq_range = 1; + + unordered_map multi_dict; + for (size_t i = 0; i < node_ids.size(); ++i) { + int node = node_ids[i]; + double norm_deg, norm_sq_path; + + if (max_rank == min_rank) { + norm_deg = 1.0 / (double)node_ids.size(); + } else { + norm_deg = (double)(degree_rank[node] - min_rank) / rank_range; + } + + if (max_sq_path == min_sq_path) { + norm_sq_path = 1.0 / (double)node_ids.size(); + } else { + norm_sq_path = (square_path[i] - min_sq_path) / sq_range; + } + + multi_dict[node] = norm_deg * norm_sq_path; + } + + vector> sorted_multi; + for (auto& kv : multi_dict) { + sorted_multi.push_back(kv); + } + sort(sorted_multi.begin(), sorted_multi.end(), + [](const pair& a, const pair& b) { + if (fabs(a.second - b.second) > 1e-9) return a.second > b.second; + return a.first > b.first; + }); + + int num_centers = (int)valid_roots.size(); + + bool auto_choose = auto_choose_centers.cast(); + if (auto_choose && sorted_multi.size() > 0) { + int auto_centernum = choose_center(sorted_multi); + if (!center_num.is_none()) { + int user_center_num = center_num.cast(); + num_centers = (auto_centernum < user_center_num) ? auto_centernum : user_center_num; + } else { + num_centers = auto_centernum; + } + } else if (!center_num.is_none()) { + num_centers = center_num.cast(); + } + + if (num_centers <= 0) { + num_centers = (int)valid_roots.size(); + } + + vector center_dcd; + int local_cnt = 0; + for (size_t i = 0; i < sorted_multi.size() && local_cnt < num_centers; ++i) { + if (sorted_multi[i].second > 0) { + local_cnt++; + center_dcd.push_back(sorted_multi[i].first); + } + } + + if (center_dcd.empty() && !sorted_multi.empty()) { + center_dcd.push_back(sorted_multi[0].first); + } + + bool all_same_degree = true; + int first_deg = -1; + for (int i = 1; i <= n && all_same_degree; ++i) { + if (degree[i] > 0) { + if (first_deg == -1) { + first_deg = degree[i]; + } else if (degree[i] != first_deg) { + all_same_degree = false; + } + } + } + + if (all_same_degree && n > 0) { + center_dcd.clear(); + center_dcd.push_back(n); + } + + unordered_set center_set(center_dcd.begin(), center_dcd.end()); + + for (int node : valid_roots) { + int superior = root_decision[node].superior; + tree_parentnode[node] = superior; + tree_rootnode[node] = superior; + } + + for (int node = 0; node < n; ++node) { + if (degree[node] > 0 && center_set.count(node)) { + tree_rootnode[node] = node; + } + } + + for (int node = 0; node < n; ++node) { + if (degree[node] == 0) continue; + vector recent; + recent.push_back(node); + bool flag = false; + + while (center_set.find(tree_rootnode[node]) == center_set.end() && !flag) { + int j = tree_rootnode[node]; + if (j == -1 || find(recent.begin(), recent.end(), j) != recent.end()) { + tree_rootnode[node] = -1; + flag = true; + break; + } + recent.push_back(j); + tree_rootnode[node] = tree_rootnode[j]; + } + } + + unordered_map y_partition; + vector y_dcd; + + for (int node = 1; node <= n; ++node) { + if (degree[node] == 0) continue; + int root = tree_rootnode[node]; + if (root == -1 && !center_dcd.empty()) { + root = center_dcd[0]; + tree_rootnode[node] = root; + } + y_partition[node] = root; + if (root == -1) { + y_dcd.push_back(-1); + } else { + y_dcd.push_back(root); + } + } + + unordered_map> grouped; + for (auto& kv : y_partition) { + int center = kv.second; + if (center != -1) { + grouped[center].push_back(kv.first); + } + } + + py::dict result_grouped; + for (auto& kv : grouped) { + py::list members; + for (int node_id : kv.second) { + members.append(id_to_node[py::cast(node_id)]); + } + result_grouped[id_to_node[py::cast(kv.first)]] = members; + } + + py::list result_center_dcd; + for (int center : center_dcd) { + result_center_dcd.append(id_to_node[py::cast(center)]); + } + + py::list result_y_dcd; + for (int label : y_dcd) { + if (label == -1) { + result_y_dcd.append(py::cast(-1)); + } else { + result_y_dcd.append(id_to_node[py::cast(label)]); + } + } + + py::dict result_y_partition; + for (auto& kv : y_partition) { + if (kv.second == -1) { + result_y_partition[id_to_node[py::cast(kv.first)]] = py::cast(-1); + } else { + result_y_partition[id_to_node[py::cast(kv.first)]] = id_to_node[py::cast(kv.second)]; + } + } + + return py::make_tuple( + py::none(), + result_center_dcd, + result_y_dcd, + result_y_partition, + result_grouped, + py::none() + ); +} diff --git a/cpp_easygraph/functions/community/localsearch.h b/cpp_easygraph/functions/community/localsearch.h new file mode 100644 index 0000000..38a3a76 --- /dev/null +++ b/cpp_easygraph/functions/community/localsearch.h @@ -0,0 +1,13 @@ +#pragma once +#include + +namespace py = pybind11; + +py::object cpp_localsearch( + py::object G, + py::object center_num = py::none(), + py::object auto_choose_centers = py::bool_(false), + py::object maximum_tree = py::bool_(true), + py::object seed = py::none(), + py::object self_loop = py::bool_(false) +); diff --git a/cpp_easygraph/functions/community/louvain.cpp b/cpp_easygraph/functions/community/louvain.cpp new file mode 100644 index 0000000..bff5625 --- /dev/null +++ b/cpp_easygraph/functions/community/louvain.cpp @@ -0,0 +1,544 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _OPENMP +#include +#else +#warning "OpenMP is not available: cpp_louvain_communities will fall back to single-threaded execution." +#endif +#include "../../classes/graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" +#include "../../functions/community/graph_coloring.h" + +namespace py = pybind11; +using namespace std; + +const double epsilon = 1e-10; +const int MAX_ITERATIONS = 50; +const double CONVERGENCE_THRESHOLD = 1e-7; + +struct CommunityState { + int size = 0; + double weight_inside = 0; + double weight_all = 0; +}; + +double calculate_norm(const Graph_L& G) { + double total_weight = 0.0; + for (int u = 1; u < G.n + 1; ++u) { + for (int i = G.head[u]; i != -1; i = G.edges[i].next) { + total_weight += G.edges[i].w; + } + } + return total_weight; +} + +double calculate_actual_modularity(const Graph_L& G, const vector& membership, + double norm, double resolution) { + unordered_map comm_weight_inside; + unordered_map comm_weight_total; + + for (int u = 1; u <= G.n; ++u) { + int comm_u = membership[u - 1]; + for (int e_idx = G.head[u]; e_idx != -1; e_idx = G.edges[e_idx].next) { + int v = G.edges[e_idx].to; + double w = G.edges[e_idx].w; + int comm_v = membership[v - 1]; + comm_weight_total[comm_u] += w; + if (comm_u == comm_v) { + comm_weight_inside[comm_u] += w; + } + } + } + + double modularity = 0.0; + for (const auto& kv : comm_weight_inside) { + int comm = kv.first; + double inside = kv.second; + double total = comm_weight_total[comm]; + modularity += (inside / 2.0) - resolution * (total * total) / (4.0 * norm * norm); + } + + return modularity; +} + +inline double calculate_modularity_gain(double comm_weight_without_u, double norm, + double node_weight_all, double weight_to_comm, double resolution) { + double gain = weight_to_comm - resolution * (comm_weight_without_u * node_weight_all) / norm; + return 2 * gain / norm; +} + +double modularity_optimization_serial(const Graph_L& G, vector& membership, double resolution_val, double norm) { + int n = G.n; + double total_modularity_gain = 0.0; + vector comms(n); + + for (int i = 0; i < n; ++i) { + membership[i] = i; + comms[i].size = 1; + comms[i].weight_all = 0.0; + comms[i].weight_inside = 0.0; + for (int j = G.head[i + 1]; j != -1; j = G.edges[j].next) { + int v = G.edges[j].to; + double w = G.edges[j].w; + comms[i].weight_all += w; + if (v == i + 1) comms[i].weight_inside += w; + } + } + + vector node_weight(n + 1, 0.0); + vector node_loop(n + 1, 0.0); + for (int i = 0; i < n; ++i) { + node_weight[i + 1] = comms[i].weight_all; + node_loop[i + 1] = comms[i].weight_inside; + } + + vector neigh_weight(n, 0.0); + vector timestamp(n, 0); + vector touched; + touched.reserve(512); + int version = 0; + + std::random_device rd; + std::mt19937 rng(rd()); + vector node_order(n); + for (int i = 0; i < n; ++i) node_order[i] = i + 1; + + int num_moved_nodes = 1; + int iteration = 0; + double previous_modularity = -1e100; + + do { + num_moved_nodes = 0; + iteration++; + + std::shuffle(node_order.begin(), node_order.end(), rng); + + for (int idx = 0; idx < n; ++idx) { + int u = node_order[idx]; + + touched.clear(); + version++; + if (version == 0) version = 1; + + double weight_all = node_weight[u]; + double weight_loop = node_loop[u]; + double weight_inside = 0.0; + int my_comm = membership[u - 1]; + + for (int e = G.head[u]; e != -1; e = G.edges[e].next) { + int v = G.edges[e].to; + double w = G.edges[e].w; + + if (u == v) continue; + + int v_comm = membership[v - 1]; + + if (timestamp[v_comm] != version) { + timestamp[v_comm] = version; + neigh_weight[v_comm] = 0.0; + touched.push_back(v_comm); + } + neigh_weight[v_comm] += w; + + if (v_comm == my_comm) { + weight_inside += w; + } + } + + if (weight_all < epsilon) continue; + + int old_comm = my_comm; + double comm_old_w = comms[old_comm].weight_all - weight_all; + double old_gain = calculate_modularity_gain(comm_old_w, norm, weight_all, weight_inside, resolution_val); + + double max_gain = std::max(old_gain, 0.0); + int best_comm = old_comm; + double best_weight_to_comm = weight_inside; + + for (int target_comm : touched) { + if (target_comm == old_comm) continue; + + double w_to_target = neigh_weight[target_comm]; + double comm_target_w = comms[target_comm].weight_all; + + double gain = calculate_modularity_gain(comm_target_w, norm, weight_all, w_to_target, resolution_val); + + if (gain > max_gain + epsilon) { + max_gain = gain; + best_comm = target_comm; + best_weight_to_comm = w_to_target; + } + } + + if (best_comm != old_comm) { + comms[old_comm].size--; + comms[old_comm].weight_all -= weight_all; + comms[old_comm].weight_inside -= (2.0 * weight_inside + weight_loop); + + comms[best_comm].size++; + comms[best_comm].weight_all += weight_all; + comms[best_comm].weight_inside += (2.0 * best_weight_to_comm + weight_loop); + + membership[u - 1] = best_comm; + num_moved_nodes++; + } + } + + if (num_moved_nodes == 0) { + return calculate_actual_modularity(G, membership, norm, resolution_val); + } + + if (iteration >= MAX_ITERATIONS) { + return calculate_actual_modularity(G, membership, norm, resolution_val); + } + + } while (true); + + for (int i = 0; i < n; ++i) { + comms[i].weight_all = node_weight[i + 1]; + } + + return calculate_actual_modularity(G, membership, norm, resolution_val); +} + +double modularity_optimization_parallel_simplified(const Graph_L& G, vector& membership, double resolution_val, double norm) { + int n = G.n; + vector comms(n); + + for (int i = 0; i < n; ++i) { + membership[i] = i; + comms[i].size = 1; + comms[i].weight_all = 0.0; + comms[i].weight_inside = 0.0; + for (int j = G.head[i + 1]; j != -1; j = G.edges[j].next) { + int v = G.edges[j].to; + double w = G.edges[j].w; + comms[i].weight_all += w; + if (v == i + 1) comms[i].weight_inside += w; + } + } + + vector node_weight(n + 1, 0.0); + vector node_loop(n + 1, 0.0); + for (int i = 0; i < n; ++i) { + node_weight[i + 1] = comms[i].weight_all; + node_loop[i + 1] = comms[i].weight_inside; + } + + std::random_device rd; + std::mt19937 rng(rd()); + vector node_order(n); + iota(node_order.begin(), node_order.end(), 0); + + int num_moved_nodes = 1; + int iteration = 0; + + vector comm_weights(n, 0.0); + for (int i = 0; i < n; ++i) { + comm_weights[i] = comms[i].weight_all; + } + + do { + num_moved_nodes = 0; + iteration++; + + std::shuffle(node_order.begin(), node_order.end(), rng); + + #pragma omp parallel + { + #pragma omp for reduction(+:num_moved_nodes) schedule(guided) + for (int idx = 0; idx < n; ++idx) { + int u = node_order[idx]; + + double weight_all = node_weight[u]; + int my_comm = membership[u - 1]; + + if (weight_all < epsilon) continue; + + unordered_map neigh_weight; + neigh_weight.reserve(32); + vector touched; + touched.reserve(32); + + double weight_inside = 0.0; + + for (int e = G.head[u]; e != -1; e = G.edges[e].next) { + int v = G.edges[e].to; + double w = G.edges[e].w; + + if (u == v) continue; + + int v_comm = membership[v - 1]; + + auto it = neigh_weight.find(v_comm); + if (it == neigh_weight.end()) { + touched.push_back(v_comm); + neigh_weight[v_comm] = w; + } else { + it->second += w; + } + + if (v_comm == my_comm) { + weight_inside += w; + } + } + + int old_comm = my_comm; + double comm_old_w = comm_weights[old_comm] - weight_all; + double old_gain = calculate_modularity_gain(comm_old_w, norm, weight_all, weight_inside, resolution_val); + + double max_gain = std::max(old_gain, 0.0); + int best_comm = old_comm; + + for (int target_comm : touched) { + if (target_comm == old_comm) continue; + + double w_to_target = neigh_weight.at(target_comm); + double comm_target_w = comm_weights[target_comm]; + + double gain = calculate_modularity_gain(comm_target_w, norm, weight_all, w_to_target, resolution_val); + + if (gain > max_gain + epsilon) { + max_gain = gain; + best_comm = target_comm; + } + } + + if (best_comm != old_comm) { + #pragma omp atomic + comm_weights[old_comm] -= weight_all; + + #pragma omp atomic + comm_weights[best_comm] += weight_all; + + membership[u - 1] = best_comm; + num_moved_nodes++; + } + } + } + + if (num_moved_nodes == 0) break; + if (iteration >= MAX_ITERATIONS) break; + + } while (true); + + for (int i = 0; i < n; ++i) { + comms[i].weight_all = comm_weights[i]; + } + + return calculate_actual_modularity(G, membership, norm, resolution_val); +} + +struct SuperEdge { + int u, v; + double w; +}; + +Graph_L* graph_compress(const Graph_L* G, vector& membership) { + unordered_map new_comm_id; + int new_vcount = 0; + for (int i = 0; i < G->n; i++) { + int c = membership[i]; + if (new_comm_id.find(c) == new_comm_id.end()) { + new_comm_id[c] = new_vcount++; + } + membership[i] = new_comm_id[c]; + } + + vector edges; + + for (int u = 1; u < G->n + 1; ++u) { + int su = membership[u-1] + 1; + for (int e = G->head[u]; e != -1; e = G->edges[e].next) { + int v = G->edges[e].to; + double w = G->edges[e].w; + int sv = membership[v-1] + 1; + edges.push_back({su, sv, w}); + } + } + + sort(edges.begin(), edges.end(), [](const SuperEdge& a, const SuperEdge& b) { + if (a.u != b.u) return a.u < b.u; + return a.v < b.v; + }); + + Graph_L* super_g = new Graph_L(new_vcount, G->is_directed, G->is_deg); + if (edges.empty()) return super_g; + + int curr_u = edges[0].u; + int curr_v = edges[0].v; + double curr_w = edges[0].w; + + for (size_t i = 1; i < edges.size(); ++i) { + if (edges[i].u == curr_u && edges[i].v == curr_v) { + curr_w += edges[i].w; + } else { + super_g->add_weighted_edge(curr_u, curr_v, curr_w); + curr_u = edges[i].u; + curr_v = edges[i].v; + curr_w = edges[i].w; + } + } + super_g->add_weighted_edge(curr_u, curr_v, curr_w); + return super_g; +} + +py::object cpp_louvain_communities_serial(py::object G, py::object weight, py::object threshold, py::object resolution) { + Graph& G_ = G.cast(); + + { + if (G_.is_linkgraph_dirty()) { + G_._get_linkgraph_structure(); + } + } + + Graph_L original_GL = G_._get_linkgraph_structure(); + + Graph_L* current_GL = &original_GL; + double threshold_val = threshold.cast(); + double resolution_val = resolution.cast(); + int vcount = current_GL->n; + if (vcount == 0) return py::list(); + + double norm = calculate_norm(*current_GL); + vector membership(vcount); + iota(membership.begin(), membership.end(), 0); + + vector allocated_graphs; + int iteration = 0; + double previous_modularity = -1e100; + + if (norm > 0.0) { + while (true) { + iteration++; + int curr_vcount = current_GL->n; + vector curr_membership(curr_vcount); + + double current_modularity; + current_modularity = modularity_optimization_serial(*current_GL, curr_membership, resolution_val, norm); + + double modularity_gain = current_modularity - previous_modularity; + + if (modularity_gain <= threshold_val || iteration > 100) { + break; + } + + Graph_L* next_GL = graph_compress(current_GL, curr_membership); + allocated_graphs.push_back(next_GL); + current_GL = next_GL; + + for (int i = 0; i < vcount; i++) { + membership[i] = curr_membership[membership[i]]; + } + + previous_modularity = current_modularity; + } + } + + unordered_map> cpp_groups; + for (int i = 0; i < vcount; ++i) { + int comm_id = membership[i]; + node_t node_id = i + 1; + cpp_groups[comm_id].push_back(node_id); + } + + py::dict id_to_node = G_.id_to_node; + + py::list final_result; + for (auto& kv : cpp_groups) { + py::set py_comm; + for (node_t node_id : kv.second) { + py::object node_obj = id_to_node[py::cast(node_id)]; + py_comm.add(node_obj); + } + final_result.append(py_comm); + } + + for (auto g : allocated_graphs) { + delete g; + } + + return final_result; +} + +py::object cpp_louvain_communities(py::object G, py::object weight, py::object threshold, py::object resolution) { + Graph& G_ = G.cast(); + + #ifdef _OPENMP + omp_set_num_threads(8); + #endif + + Graph_L original_GL = G_._get_linkgraph_structure(); + + Graph_L* current_GL = &original_GL; + double threshold_val = threshold.cast(); + double resolution_val = resolution.cast(); + int vcount = current_GL->n; + if (vcount == 0) return py::list(); + + double norm = calculate_norm(*current_GL); + vector membership(vcount); + iota(membership.begin(), membership.end(), 0); + + vector allocated_graphs; + int iteration = 0; + double previous_modularity = 0.0; + + if (norm > 0.0) { + while (true) { + iteration++; + int curr_vcount = current_GL->n; + vector curr_membership(curr_vcount); + double current_modularity; + + current_modularity = modularity_optimization_parallel_simplified(*current_GL, curr_membership, resolution_val, norm); + + double modularity_gain = current_modularity - previous_modularity; + previous_modularity = current_modularity; + + if (modularity_gain <= threshold_val || iteration > 100) { + break; + } + + Graph_L* next_GL = graph_compress(current_GL, curr_membership); + allocated_graphs.push_back(next_GL); + current_GL = next_GL; + + for (int i = 0; i < vcount; i++) { + membership[i] = curr_membership[membership[i]]; + } + } + } + + unordered_map> cpp_groups; + for (int i = 0; i < vcount; ++i) { + int comm_id = membership[i]; + node_t node_id = i + 1; + cpp_groups[comm_id].push_back(node_id); + } + + py::dict id_to_node = G_.id_to_node; + + py::list final_result; + for (auto& kv : cpp_groups) { + py::set py_comm; + for (node_t node_id : kv.second) { + py::object node_obj = id_to_node[py::cast(node_id)]; + py_comm.add(node_obj); + } + final_result.append(py_comm); + } + + for (auto g : allocated_graphs) { + delete g; + } + + return final_result; +} diff --git a/cpp_easygraph/functions/community/louvain.h b/cpp_easygraph/functions/community/louvain.h new file mode 100644 index 0000000..2ae443c --- /dev/null +++ b/cpp_easygraph/functions/community/louvain.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace py = pybind11; + + +py::object cpp_louvain_communities( + py::object G, + py::object weight = py::str("weight"), + py::object threshold = py::float_(0.0), + py::object resolution = py::float_(1.0) +); + +py::object cpp_louvain_communities_serial( + py::object G, + py::object weight = py::str("weight"), + py::object threshold = py::float_(0.0), + py::object resolution = py::float_(1.0) +); \ No newline at end of file diff --git a/cpp_easygraph/functions/community/modularity.cpp b/cpp_easygraph/functions/community/modularity.cpp new file mode 100644 index 0000000..e822b02 --- /dev/null +++ b/cpp_easygraph/functions/community/modularity.cpp @@ -0,0 +1,213 @@ +#include +#include +#include +#include +#include +#ifdef _OPENMP +#include +#else +#warning "OpenMP is not available: modularity utility functions will fall back to single-threaded execution." +#endif + +#include "../../classes/graph.h" +#include "../../common/utils.h" + +using namespace std; + +void addVectorsInPlace(std::vector& v1, std::vector& v2) { + if (v1.size() != v2.size()) { + throw std::invalid_argument("Vectors must have the same size for element-wise addition."); + } + + const std::ptrdiff_t n = static_cast(v1.size()); + + #pragma omp parallel for + for (std::ptrdiff_t i = 0; i < n; ++i) { + double sum = v1[i] + v2[i]; + v1[i] = sum; + v2[i] = sum; + } +} + +double dotProduct(const std::vector& v1, const std::vector& v2) { + if (v1.size() != v2.size()) { + throw std::invalid_argument("Vectors must have the same size for dot product."); + } + + double result = 0.0; + const std::ptrdiff_t n = static_cast(v1.size()); + + #pragma omp parallel for reduction(+:result) + for (std::ptrdiff_t i = 0; i < n; ++i) { + result += v1[i] * v2[i]; + } + return result; +} + +void calculate_degrees_and_edges_adj_parallel( + const adj_dict_factory& adj, + const std::vector& membership, + bool directed, + int num_communities, + double& e, + double& m, + std::vector& k_out, + std::vector& k_in + ) +{ + const int N = membership.size(); + + #pragma omp parallel + { + double local_e = 0.0; + double local_m = 0.0; + std::vector local_k_out(num_communities, 0.0); + std::vector local_k_in(num_communities, 0.0); + double directed_factor = directed ? 1.0 : 2.0; + + #pragma omp for + for (int i = 0; i < N; i++) { + node_t u = i + 1; + int c1 = membership[i]; + + auto adj_it = adj.find(u); + if (adj_it == adj.end()) continue; + auto& u_neighbors = adj_it->second; + + for (auto& v_pair : u_neighbors) { + node_t v = v_pair.first; + + if (!directed && u > v) continue; + + int c2 = membership[v - 1]; + + double w = 1.0; + if (!v_pair.second.empty()) { + auto it = v_pair.second.begin(); + w = it->second; + } + + if (c1 == c2) { + local_e += directed_factor * w; + } + + local_k_out[c1] += w; + local_k_in[c2] += w; + local_m += w; + } + } + + #pragma omp critical + { + e += local_e; + m += local_m; + for (int i = 0; i < num_communities; i++) { + k_out[i] += local_k_out[i]; + k_in[i] += local_k_in[i]; + } + } + } +} + + // The input `communities` may be either: + // (a) a membership list: a flat sequence of ints, membership[i] = community id of node (i+1); or + // (b) a community list: a sequence of iterables of node ids + +py::object cpp_modularity(py::object G, py::object communities, py::object weight=py::str("weight")) { + Graph& G_ = G.cast(); + bool directed = G.attr("is_directed")().cast(); + adj_dict_factory& adj = G_.adj; + const int N = G_.node.size(); + + + { + bool is_empty_seq = false; + try { + py::sequence seq = communities.cast(); + is_empty_seq = (seq.size() == 0); + } catch (const py::cast_error&) { + is_empty_seq = false; + } + if (is_empty_seq) { + py::module warnings = py::module::import("warnings"); + warnings.attr("warn")( + "cpp_modularity: received an empty community list; returning Q = 0.0." + ); + return py::float_(0.0); + } + } + + + std::vector membership_vec; + + bool is_membership = false; + py::sequence outer_seq; + try { + outer_seq = communities.cast(); + if (outer_seq.size() > 0) { + try { + outer_seq[0].cast(); + is_membership = true; + } catch (const py::cast_error&) { + is_membership = false; + } + } + } catch (const py::cast_error&) { + is_membership = true; + } + + if (is_membership) { + // Already a membership vector: membership[i] is the community id of the (i+1)-th node. + membership_vec = communities.cast>(); + } else { + membership_vec = std::vector(N, -1); + + int comm_id = 0; + for (auto community_handle : py::iter(communities)) { + for (auto node_handle : py::iter(community_handle)) { + py::object node = py::reinterpret_borrow(node_handle.ptr()); + if (G_.node_to_id.contains(node)) { + int node_id = G_.node_to_id[node].cast() - 1; + if (node_id >= 0 && node_id < N) { + membership_vec[node_id] = comm_id; + } + } + } + comm_id++; + } + } + + int num_communities = membership_vec.size(); + + double e = 0.0; + double m = 0.0; + std::vector k_out(num_communities, 0.0); + std::vector k_in(num_communities, 0.0); + + calculate_degrees_and_edges_adj_parallel(adj, membership_vec, directed, num_communities, e, m, k_out, k_in); + + if (!directed) addVectorsInPlace(k_out, k_in); + + // Handle empty graph / zero total edge weight: m == 0 makes + // `norm = 1.0 / (directed_factor * m)` divide by zero and produces + // inf / nan. Define Q = 0.0 in this degenerate case (no edges -> no + // community structure to measure), with a Python warning. + if (m == 0.0) { + py::module warnings = py::module::import("warnings"); + warnings.attr("warn")( + "cpp_modularity: graph has no edges (m == 0); returning Q = 0.0." + ); + return py::float_(0.0); + } + + double directed_factor = directed ? 1.0 : 2.0; + double norm = 1.0 / (directed_factor * m); + e *= norm; + + double sum_products = dotProduct(k_out, k_in); + sum_products *= norm * norm; + + double Q = e - sum_products; + + return py::float_(Q); +} diff --git a/cpp_easygraph/functions/community/modularity.h b/cpp_easygraph/functions/community/modularity.h new file mode 100644 index 0000000..799e31a --- /dev/null +++ b/cpp_easygraph/functions/community/modularity.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include +#include + +#include "../../classes/graph.h" + +using namespace std; + +py::object cpp_modularity(py::object G, py::object communities, py::object weight = py::str("weight")); diff --git a/cpp_easygraph/functions/community/motif.cpp b/cpp_easygraph/functions/community/motif.cpp new file mode 100644 index 0000000..8042d39 --- /dev/null +++ b/cpp_easygraph/functions/community/motif.cpp @@ -0,0 +1,553 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef _OPENMP +#include +#else +#warning "OpenMP is not available: motif counting functions will fall back to single-threaded execution." +#endif +#include "../../classes/graph.h" +#include "../../classes/linkgraph.h" +#include "motif.h" + +namespace py = pybind11; +using namespace std; + +const int MAX_NODES = 65536; + +struct MotifResult { + vector nodes; +}; + +class MotifEnumerator { +private: + Graph_L GL; + int k; + int v_start; + vector& id_to_node; + mt19937 rng; + uniform_real_distribution dist; + vector cut_prob; + + vector temp_nexcl; + vector results; + bitset nvp_bitset; + +public: + vector node_list; + vector neighbor_offsets; + vector neighbor_data; + int n_edges; + + void build_neighbor_structure() { + neighbor_offsets.resize(GL.n + 2, 0); + for (int i = 1; i <= GL.n; ++i) { + int count = 0; + for (int e = GL.head[i]; e != -1; e = GL.edges[e].next) { + count++; + } + neighbor_offsets[i + 1] = neighbor_offsets[i] + count; + } + + n_edges = neighbor_offsets[GL.n + 1]; + neighbor_data.resize(n_edges); + + for (int i = 1; i <= GL.n; ++i) { + int idx = neighbor_offsets[i]; + for (int e = GL.head[i]; e != -1; e = GL.edges[e].next) { + neighbor_data[idx++] = GL.edges[e].to; + } + } + + node_list.reserve(GL.n); + for (int i = 1; i <= GL.n; ++i) { + if (GL.head[i] != -1) { + node_list.push_back(i); + } + } + } + + int get_neighbor_count(int v) const { + return neighbor_offsets[v + 1] - neighbor_offsets[v]; + } + + const int* get_neighbors_ptr(int v) const { + return neighbor_data.data() + neighbor_offsets[v]; + } + + void exclusive_neighborhood(int v, const vector& vp, vector& result) { + result.clear(); + + const int* nbr_ptr = get_neighbors_ptr(v); + int nbr_count = get_neighbor_count(v); + + nvp_bitset.reset(); + for (int node : vp) { + nvp_bitset.set(node); + const int* node_nbr_ptr = get_neighbors_ptr(node); + int node_nbr_count = get_neighbor_count(node); + for (int i = 0; i < node_nbr_count; ++i) { + nvp_bitset.set(node_nbr_ptr[i]); + } + } + + for (int i = 0; i < nbr_count; ++i) { + int neighbor = nbr_ptr[i]; + if (!nvp_bitset.test(neighbor)) { + result.push_back(neighbor); + } + } + } + + void extend_subgraph_local(const vector& Vsubgraph, const vector& Vextension, int local_v_start, + vector& local_temp_nexcl, bitset& local_nvp_bitset, + vector& local_results) { + if ((int)Vsubgraph.size() == k) { + local_results.push_back({Vsubgraph}); + return; + } + + int original_size = Vextension.size(); + for (int i = original_size - 1; i >= 0; --i) { + int w = Vextension[i]; + + if (!cut_prob.empty() && (int)cut_prob.size() > (int)Vsubgraph.size() && dist(rng) > cut_prob[Vsubgraph.size()]) { + continue; + } + + vector new_subgraph; + new_subgraph.reserve(Vsubgraph.size() + 1); + new_subgraph.assign(Vsubgraph.begin(), Vsubgraph.end()); + new_subgraph.push_back(w); + + exclusive_neighborhood_local(w, Vsubgraph, local_temp_nexcl, local_nvp_bitset); + + vector next_ext; + next_ext.reserve(original_size + local_temp_nexcl.size()); + for (int j = 0; j < original_size; ++j) { + if (j != i) { + int u = Vextension[j]; + if (u > local_v_start) { + next_ext.push_back(u); + } + } + } + for (int u : local_temp_nexcl) { + if (u > local_v_start) { + next_ext.push_back(u); + } + } + + extend_subgraph_local(new_subgraph, next_ext, local_v_start, local_temp_nexcl, local_nvp_bitset, local_results); + } + } + + int extend_subgraph_count_local(const vector& Vsubgraph, const vector& Vextension, int local_v_start, vector& local_temp_nexcl, bitset& local_nvp_bitset) { + if ((int)Vsubgraph.size() == k) { + return 1; + } + + int count = 0; + int original_size = Vextension.size(); + for (int i = original_size - 1; i >= 0; --i) { + int w = Vextension[i]; + + vector new_subgraph; + new_subgraph.reserve(Vsubgraph.size() + 1); + new_subgraph.assign(Vsubgraph.begin(), Vsubgraph.end()); + new_subgraph.push_back(w); + + exclusive_neighborhood_local(w, Vsubgraph, local_temp_nexcl, local_nvp_bitset); + + vector next_ext; + next_ext.reserve(original_size + local_temp_nexcl.size()); + for (int j = 0; j < original_size; ++j) { + if (j != i) { + int u = Vextension[j]; + if (u > local_v_start) { + next_ext.push_back(u); + } + } + } + for (int u : local_temp_nexcl) { + if (u > local_v_start) { + next_ext.push_back(u); + } + } + + count += extend_subgraph_count_local(new_subgraph, next_ext, local_v_start, local_temp_nexcl, local_nvp_bitset); + } + return count; + } + + int extend_subgraph_count(const vector& Vsubgraph, const vector& Vextension) { + return extend_subgraph_count_local(Vsubgraph, Vextension, v_start, temp_nexcl, nvp_bitset); + } + + void exclusive_neighborhood_local(int v, const vector& vp, vector& result, bitset& local_nvp_bitset) { + result.clear(); + + const int* nbr_ptr = get_neighbors_ptr(v); + int nbr_count = get_neighbor_count(v); + + local_nvp_bitset.reset(); + for (int node : vp) { + local_nvp_bitset.set(node); + const int* node_nbr_ptr = get_neighbors_ptr(node); + int node_nbr_count = get_neighbor_count(node); + for (int i = 0; i < node_nbr_count; ++i) { + local_nvp_bitset.set(node_nbr_ptr[i]); + } + } + + for (int i = 0; i < nbr_count; ++i) { + int neighbor = nbr_ptr[i]; + if (!local_nvp_bitset.test(neighbor)) { + result.push_back(neighbor); + } + } + } + +public: + MotifEnumerator(Graph_L gl, int k, vector& id_to_node) + : GL(gl), k(k), v_start(0), id_to_node(id_to_node), rng(42), dist(0.0, 1.0) { + build_neighbor_structure(); + temp_nexcl.reserve(gl.n); + } + + MotifEnumerator(Graph_L gl, int k, vector& id_to_node, const vector& cut_prob_vec, int seed) + : GL(gl), k(k), v_start(0), id_to_node(id_to_node), cut_prob(cut_prob_vec), rng(seed), dist(0.0, 1.0) { + build_neighbor_structure(); + temp_nexcl.reserve(gl.n); + } + + py::array_t enumerate() { + results.clear(); + + if (k == 2) { + for (int v : node_list) { + if (!cut_prob.empty() && dist(rng) > cut_prob[0]) { + continue; + } + const int* nbr_ptr = get_neighbors_ptr(v); + int nbr_count = get_neighbor_count(v); + for (int j = 0; j < nbr_count; ++j) { + int u = nbr_ptr[j]; + if (u > v) { + results.push_back({vector{v, u}}); + } + } + } + return convert_results_to_numpy(); + } + + vector> thread_results(8); + + #ifdef _OPENMP + omp_set_num_threads(8); + #endif + #pragma omp parallel + { + int thread_id = 0; + #ifdef _OPENMP + thread_id = omp_get_thread_num(); + #endif + vector& local_results = thread_results[thread_id]; + local_results.reserve(10000); + + #pragma omp for schedule(dynamic) + for (int i = 0; i < (int)node_list.size(); ++i) { + int v = node_list[i]; + + if (!cut_prob.empty() && dist(rng) > cut_prob[0]) { + continue; + } + + int local_v_start = v; + vector Vsubgraph; + Vsubgraph.reserve(k); + Vsubgraph.push_back(v); + + vector Vextension; + Vextension.reserve(GL.n); + const int* nbr_ptr = get_neighbors_ptr(v); + int nbr_count = get_neighbor_count(v); + for (int j = 0; j < nbr_count; ++j) { + int u = nbr_ptr[j]; + if (u > v) { + Vextension.push_back(u); + } + } + + vector local_temp_nexcl; + local_temp_nexcl.reserve(GL.n); + bitset local_nvp_bitset; + + extend_subgraph_local(Vsubgraph, Vextension, local_v_start, local_temp_nexcl, local_nvp_bitset, local_results); + } + } + + for (const auto& tr : thread_results) { + results.insert(results.end(), tr.begin(), tr.end()); + } + + return convert_results_to_numpy(); + } + + void extend_subgraph(const vector& Vsubgraph, const vector& Vextension) { + if ((int)Vsubgraph.size() == k) { + results.push_back({Vsubgraph}); + return; + } + + int original_size = Vextension.size(); + for (int i = original_size - 1; i >= 0; --i) { + int w = Vextension[i]; + + if (w <= v_start) { + continue; + } + + vector new_subgraph; + new_subgraph.reserve(Vsubgraph.size() + 1); + new_subgraph.assign(Vsubgraph.begin(), Vsubgraph.end()); + new_subgraph.push_back(w); + + exclusive_neighborhood(w, Vsubgraph, temp_nexcl); + + vector next_ext; + next_ext.reserve(original_size + temp_nexcl.size()); + for (int j = 0; j < original_size; ++j) { + if (j != i) { + int u = Vextension[j]; + if (u > v_start) { + next_ext.push_back(u); + } + } + } + for (int u : temp_nexcl) { + if (u > v_start) { + next_ext.push_back(u); + } + } + + extend_subgraph(new_subgraph, next_ext); + } + } + + int count_enumerate() { + int count = 0; + + if (k == 2) { + for (int v : node_list) { + const int* nbr_ptr = get_neighbors_ptr(v); + int nbr_count = get_neighbor_count(v); + for (int j = 0; j < nbr_count; ++j) { + int u = nbr_ptr[j]; + if (u > v) { + count++; + } + } + } + return count; + } + + #ifdef _OPENMP + omp_set_num_threads(8); + #endif + #pragma omp parallel reduction(+:count) + { + #pragma omp for schedule(dynamic) + for (int i = 0; i < (int)node_list.size(); ++i) { + int v = node_list[i]; + int local_v_start = v; + vector Vsubgraph; + Vsubgraph.reserve(k); + Vsubgraph.push_back(v); + + vector Vextension; + Vextension.reserve(GL.n); + const int* nbr_ptr = get_neighbors_ptr(v); + int nbr_count = get_neighbor_count(v); + for (int j = 0; j < nbr_count; ++j) { + int u = nbr_ptr[j]; + if (u > v) { + Vextension.push_back(u); + } + } + + vector local_temp_nexcl; + local_temp_nexcl.reserve(GL.n); + bitset local_nvp_bitset; + + count += extend_subgraph_count_local(Vsubgraph, Vextension, local_v_start, local_temp_nexcl, local_nvp_bitset); + } + } + + return count; + } + + py::list convert_results_to_python() { + py::list py_results; + for (const auto& result : results) { + py::set py_set; + for (int node_id : result.nodes) { + if (node_id > 0 && node_id < (int)id_to_node.size()) { + py_set.add(py::cast(id_to_node[node_id])); + } + } + py_results.append(py_set); + } + return py_results; + } + + py::array_t convert_results_to_numpy() { + size_t num_results = results.size(); + size_t k_size = k; + + int* data = new int[num_results * k_size]; + + size_t idx = 0; + for (const auto& result : results) { + for (size_t i = 0; i < k_size; ++i) { + if (i < result.nodes.size() && result.nodes[i] > 0 && result.nodes[i] < (int)id_to_node.size()) { + data[idx++] = id_to_node[result.nodes[i]]; + } else { + data[idx++] = -1; + } + } + } + + // Create a capsule to manage the memory + py::capsule capsule(data, [](void* ptr) { + delete[] static_cast(ptr); + }); + + // Create numpy array with the capsule + py::array_t result_array = py::array_t( + {num_results, k_size}, // shape + {k_size * sizeof(int), sizeof(int)}, // strides + data, + capsule + ); + + return result_array; + } + + const vector& get_results() const { + return results; + } + + size_t get_k() const { + return k; + } +}; + +py::list cpp_enumerate_subgraph(py::object G, int k) { + Graph& G_ = G.cast(); + Graph_L GL = G_.linkgraph_structure; + + py::dict id_to_node_py = G_.id_to_node; + vector id_to_node_vec; + id_to_node_vec.reserve(id_to_node_py.size() + 1); + id_to_node_vec.push_back(0); + for (int i = 1; i <= (int)id_to_node_py.size(); ++i) { + py::object node_obj = id_to_node_py[py::cast(i)]; + id_to_node_vec.push_back(node_obj.cast()); + } + + MotifEnumerator enumerator(GL, k, id_to_node_vec); + enumerator.enumerate(); + return enumerator.convert_results_to_python(); +} + +py::int_ cpp_count_enumerate_subgraph(py::object G, int k) { + Graph& G_ = G.cast(); + Graph_L GL = G_.linkgraph_structure; + + py::dict id_to_node_py = G_.id_to_node; + vector id_to_node_vec; + id_to_node_vec.reserve(id_to_node_py.size() + 1); + id_to_node_vec.push_back(0); + for (int i = 1; i <= (int)id_to_node_py.size(); ++i) { + py::object node_obj = id_to_node_py[py::cast(i)]; + id_to_node_vec.push_back(node_obj.cast()); + } + + MotifEnumerator enumerator(GL, k, id_to_node_vec); + int count = enumerator.count_enumerate(); + return count; +} + +py::list cpp_random_enumerate_subgraph(py::object G, int k, py::object cut_prob) { + Graph& G_ = G.cast(); + Graph_L GL = G_.linkgraph_structure; + + py::dict id_to_node_py = G_.id_to_node; + vector id_to_node_vec; + id_to_node_vec.reserve(id_to_node_py.size() + 1); + id_to_node_vec.push_back(0); + for (int i = 1; i <= (int)id_to_node_py.size(); ++i) { + py::object node_obj = id_to_node_py[py::cast(i)]; + id_to_node_vec.push_back(node_obj.cast()); + } + + vector cut_prob_vec; + if (py::isinstance(cut_prob)) { + py::list cut_prob_list = cut_prob.cast(); + cut_prob_vec.reserve(cut_prob_list.size()); + for (size_t i = 0; i < cut_prob_list.size(); ++i) { + cut_prob_vec.push_back(cut_prob_list[i].cast()); + } + } else { + throw std::runtime_error("cut_prob must be a list"); + } + + if (cut_prob_vec.size() != k) { + throw py::value_error("length of cut_prob invalid, should equal to k"); + } + + MotifEnumerator enumerator(GL, k, id_to_node_vec, cut_prob_vec, 42); + enumerator.enumerate(); + return enumerator.convert_results_to_python(); +} + +py::list cpp_random_enumerate_subgraph_with_seed(py::object G, int k, py::object cut_prob, int seed) { + Graph& G_ = G.cast(); + Graph_L GL = G_.linkgraph_structure; + + py::dict id_to_node_py = G_.id_to_node; + vector id_to_node_vec; + id_to_node_vec.reserve(id_to_node_py.size() + 1); + id_to_node_vec.push_back(0); + for (int i = 1; i <= (int)id_to_node_py.size(); ++i) { + py::object node_obj = id_to_node_py[py::cast(i)]; + id_to_node_vec.push_back(node_obj.cast()); + } + + vector cut_prob_vec; + if (py::isinstance(cut_prob)) { + py::list cut_prob_list = cut_prob.cast(); + cut_prob_vec.reserve(cut_prob_list.size()); + for (size_t i = 0; i < cut_prob_list.size(); ++i) { + cut_prob_vec.push_back(cut_prob_list[i].cast()); + } + } else { + throw std::runtime_error("cut_prob must be a list"); + } + + if (cut_prob_vec.size() != k) { + throw py::value_error("length of cut_prob invalid, should equal to k"); + } + + MotifEnumerator enumerator(GL, k, id_to_node_vec, cut_prob_vec, seed); + enumerator.enumerate(); + return enumerator.convert_results_to_python(); +} diff --git a/cpp_easygraph/functions/community/motif.h b/cpp_easygraph/functions/community/motif.h new file mode 100644 index 0000000..e32d88c --- /dev/null +++ b/cpp_easygraph/functions/community/motif.h @@ -0,0 +1,11 @@ +#pragma once +#include +#include + +namespace py = pybind11; + +py::list cpp_enumerate_subgraph(py::object G, int k); + +py::int_ cpp_count_enumerate_subgraph(py::object G, int k); + +py::list cpp_random_enumerate_subgraph(py::object G, int k, py::object cut_prob); diff --git a/cpp_easygraph/functions/community/subgraph.cpp b/cpp_easygraph/functions/community/subgraph.cpp new file mode 100644 index 0000000..fc32503 --- /dev/null +++ b/cpp_easygraph/functions/community/subgraph.cpp @@ -0,0 +1,179 @@ +#include "subgraph.h" +#include "../../classes/graph.h" +#include "../../classes/directed_graph.h" +#include "../../common/utils.h" + +static node_t _add_one_node_for_subgraph(Graph& self, py::object one_node_for_adding, py::object node_attr = py::dict()) { + node_t id; + if (self.node_to_id.contains(one_node_for_adding)) { + id = self.node_to_id[one_node_for_adding].cast(); + } else { + id = ++(self.id); + self.id_to_node[py::cast(id)] = one_node_for_adding; + self.node_to_id[one_node_for_adding] = id; + } + py::list items = py::list(node_attr.attr("items")()); + self.node[id] = node_attr_dict_factory(); + for (int i = 0; i < len(items); i++) { + py::tuple kv = items[i].cast(); + py::object pkey = kv[0]; + std::string weight_key = weight_to_string(pkey); + weight_t value = kv[1].cast(); + self.node[id].insert(std::make_pair(weight_key, value)); + } + return id; +} + +static void _add_one_edge_for_subgraph(Graph& self, node_t u, node_t v, py::object edge_attr) { + py::list items = py::list(edge_attr.attr("items")()); + self.adj[u][v] = node_attr_dict_factory(); + self.adj[v][u] = node_attr_dict_factory(); + for (int i = 0; i < len(items); i++) { + py::tuple kv = items[i].cast(); + py::object pkey = kv[0]; + std::string weight_key = weight_to_string(pkey); + weight_t value = kv[1].cast(); + self.adj[u][v].insert(std::make_pair(weight_key, value)); + self.adj[v][u].insert(std::make_pair(weight_key, value)); + } +} + +static py::object _nodes_subgraph_cpp_graph(py::object self, std::vector& node_ids) { + Graph& self_ = self.cast(); + py::object G = self.attr("__class__")(); + Graph& G_ = G.cast(); + G_.graph.attr("update")(self_.graph); + + py::object nodes = self.attr("nodes"); + py::object adj = self.attr("adj"); + + // 修复:维护原图内部索引到新图内部索引的映射 + std::unordered_map old_id_to_new_id; + + // 第一遍:添加所有节点并建立映射 + for (node_t node_id : node_ids) { + py::object node = self_.id_to_node[py::cast(node_id)]; + py::object node_attr = nodes[node]; + node_t new_id = _add_one_node_for_subgraph(G_, node, node_attr); + old_id_to_new_id[node_id] = new_id; + } + + // 第二遍:添加边(使用映射后的索引) + for (node_t node_id : node_ids) { + py::object node = self_.id_to_node[py::cast(node_id)]; + py::object out_edges = adj[node]; + py::list edge_items = py::list(out_edges.attr("items")()); + + // 获取当前节点的新索引 + node_t new_u = old_id_to_new_id[node_id]; + + for (int j = 0; j < py::len(edge_items); j++) { + py::tuple item = edge_items[j].cast(); + py::object v = item[0]; + py::object edge_attr = item[1]; + + if (self_.node_to_id.contains(v)) { + node_t v_id = self_.node_to_id[v].cast(); + bool v_in_subgraph = std::find(node_ids.begin(), node_ids.end(), v_id) != node_ids.end(); + if (v_in_subgraph) { + // 使用映射后的索引 + node_t new_v = old_id_to_new_id[v_id]; + _add_one_edge_for_subgraph(G_, new_u, new_v, edge_attr); + } + } + } + } + + return G; +} + +static node_t DiGraph_add_one_node_for_subgraph(DiGraph& self, py::object one_node_for_adding, py::object node_attr = py::dict()) { + node_t id; + if (self.node_to_id.contains(one_node_for_adding)) { + id = self.node_to_id[one_node_for_adding].cast(); + } else { + id = ++(self.id); + self.id_to_node[py::cast(id)] = one_node_for_adding; + self.node_to_id[one_node_for_adding] = id; + } + py::list items = py::list(node_attr.attr("items")()); + self.node[id] = node_attr_dict_factory(); + for (int i = 0; i < len(items); i++) { + py::tuple kv = items[i].cast(); + py::object pkey = kv[0]; + std::string weight_key = weight_to_string(pkey); + weight_t value = kv[1].cast(); + self.node[id].insert(std::make_pair(weight_key, value)); + } + return id; +} + +static void DiGraph_add_one_edge_for_subgraph(DiGraph& self, node_t u, node_t v, py::object edge_attr) { + py::list items = py::list(edge_attr.attr("items")()); + self.adj[u][v] = node_attr_dict_factory(); + for (int i = 0; i < len(items); i++) { + py::tuple kv = items[i].cast(); + py::object pkey = kv[0]; + std::string weight_key = weight_to_string(pkey); + weight_t value = kv[1].cast(); + self.adj[u][v].insert(std::make_pair(weight_key, value)); + } +} + +static py::object _nodes_subgraph_cpp_digraph(py::object self, std::vector& node_ids) { + DiGraph& self_ = self.cast(); + py::object G = self.attr("__class__")(); + DiGraph& G_ = G.cast(); + G_.graph.attr("update")(self_.graph); + + py::object nodes = self.attr("nodes"); + py::object adj = self.attr("adj"); + + // 修复:维护原图内部索引到新图内部索引的映射 + std::unordered_map old_id_to_new_id; + + // 第一遍:添加所有节点并建立映射 + for (node_t node_id : node_ids) { + py::object node = self_.id_to_node[py::cast(node_id)]; + py::object node_attr = nodes[node]; + node_t new_id = DiGraph_add_one_node_for_subgraph(G_, node, node_attr); + old_id_to_new_id[node_id] = new_id; + } + + // 第二遍:添加边(使用映射后的索引) + for (node_t node_id : node_ids) { + py::object node = self_.id_to_node[py::cast(node_id)]; + py::object out_edges = adj[node]; + py::list edge_items = py::list(out_edges.attr("items")()); + + // 获取当前节点的新索引 + node_t new_u = old_id_to_new_id[node_id]; + + for (int j = 0; j < py::len(edge_items); j++) { + py::tuple item = edge_items[j].cast(); + py::object v = item[0]; + py::object edge_attr = item[1]; + + if (self_.node_to_id.contains(v)) { + node_t v_id = self_.node_to_id[v].cast(); + bool v_in_subgraph = std::find(node_ids.begin(), node_ids.end(), v_id) != node_ids.end(); + if (v_in_subgraph) { + // 使用映射后的索引 + node_t new_v = old_id_to_new_id[v_id]; + DiGraph_add_one_edge_for_subgraph(G_, new_u, new_v, edge_attr); + } + } + } + } + + return G; +} + +py::object nodes_subgraph_cpp(py::object self, std::vector& node_ids) { + bool is_directed = self.attr("is_directed")().cast(); + if (is_directed) { + return _nodes_subgraph_cpp_digraph(self, node_ids); + } else { + return _nodes_subgraph_cpp_graph(self, node_ids); + } +} diff --git a/cpp_easygraph/functions/community/subgraph.h b/cpp_easygraph/functions/community/subgraph.h new file mode 100644 index 0000000..07941f5 --- /dev/null +++ b/cpp_easygraph/functions/community/subgraph.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include +#include "../../common/common.h" +#include "../../classes/graph.h" +#include "../../classes/directed_graph.h" +#include "../../classes/csr_graph.h" + +py::object nodes_subgraph_cpp(py::object self, std::vector& node_ids); \ No newline at end of file diff --git a/cpp_easygraph/functions/community/tests/__init__.py b/cpp_easygraph/functions/community/tests/__init__.py new file mode 100644 index 0000000..ea1f57b --- /dev/null +++ b/cpp_easygraph/functions/community/tests/__init__.py @@ -0,0 +1 @@ +# TEST package init \ No newline at end of file diff --git a/cpp_easygraph/functions/community/tests/cpp_LPA_test.py b/cpp_easygraph/functions/community/tests/cpp_LPA_test.py new file mode 100644 index 0000000..57e8ff3 --- /dev/null +++ b/cpp_easygraph/functions/community/tests/cpp_LPA_test.py @@ -0,0 +1,139 @@ +import unittest + +import easygraph as eg + + +def _get_cpp_module(): + try: + import cpp_easygraph + return cpp_easygraph + except ImportError: + return None + + +def _to_undirected_cpp(G_cpp_digraph): + import cpp_easygraph + + G_cpp = cpp_easygraph.Graph() + G_cpp.graph.update(G_cpp_digraph.graph) + for node, node_attr in G_cpp_digraph.nodes.items(): + G_cpp.add_node(node, **node_attr) + + seen_edges = set() + for u, v, edge_data in G_cpp_digraph.edges: + edge = (min(u, v), max(u, v)) + if edge not in seen_edges: + seen_edges.add(edge) + G_cpp.add_edge(u, v, **edge_data) + + return G_cpp + + +class TestLPA(unittest.TestCase): + def setUp(self): + self.G_simple = eg.Graph() + self.G_simple.add_edges_from([(0, 1), (1, 2), (3, 4)]) + + self.G_weighted = eg.Graph() + self.G_weighted.add_edges_from([ + (0, 1, {"weight": 3}), + (1, 2, {"weight": 2}), + (2, 0, {"weight": 4}), + (3, 4, {"weight": 1}), + ]) + + self.G_disconnected = eg.Graph() + self.G_disconnected.add_edges_from([(0, 1), (2, 3), (4, 5)]) + + self.G_single = eg.Graph() + self.G_single.add_node(42) + + self.G_empty = eg.Graph() + + def _get_cpp_module(self): + return _get_cpp_module() + + def test_lpa_simple(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + result = cpeg.cpp_LPA(self.G_simple.cpp()) + self.assertIsInstance(result, dict) + + all_nodes = set() + for community in result.values(): + all_nodes.update(community) + self.assertEqual(all_nodes, set(self.G_simple.nodes)) + + def test_lpa_weighted(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + result = cpeg.cpp_LPA(self.G_weighted.cpp()) + self.assertIsInstance(result, dict) + + all_nodes = set(self.G_weighted.nodes) + result_nodes = set() + for community in result.values(): + result_nodes.update(community) + self.assertEqual(all_nodes, result_nodes) + + def test_lpa_disconnected(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + result = cpeg.cpp_LPA(self.G_disconnected.cpp()) + self.assertIsInstance(result, dict) + + all_nodes = set(self.G_disconnected.nodes) + result_nodes = set() + for community in result.values(): + result_nodes.update(community) + self.assertEqual(all_nodes, result_nodes) + + def test_lpa_single_node(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + result = cpeg.cpp_LPA(self.G_single.cpp()) + self.assertIsInstance(result, dict) + self.assertEqual(len(result), 1) + for community in result.values(): + self.assertIn(42, community) + + def test_lpa_empty_graph(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + result = cpeg.cpp_LPA(self.G_empty.cpp()) + self.assertIsInstance(result, dict) + self.assertEqual(result, {}) + + def test_python_cpp_consistency(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + result_cpp = cpeg.cpp_LPA(self.G_simple.cpp()) + result_py = eg.functions.community.LPA(self.G_simple) + + self.assertEqual(len(result_cpp), len(result_py)) + + cpp_nodes = set() + for community in result_cpp.values(): + cpp_nodes.update(community) + + py_nodes = set() + for community in result_py.values(): + py_nodes.update(community) + + self.assertEqual(cpp_nodes, py_nodes) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/cpp_easygraph/functions/community/tests/cpp_ego_graph_test.py b/cpp_easygraph/functions/community/tests/cpp_ego_graph_test.py new file mode 100644 index 0000000..4c14e43 --- /dev/null +++ b/cpp_easygraph/functions/community/tests/cpp_ego_graph_test.py @@ -0,0 +1,254 @@ +import unittest + +import easygraph as eg + + +def _get_cpp_module(): + """获取cpp_easygraph模块""" + try: + import cpp_easygraph + return cpp_easygraph + except ImportError: + return None + + +def _to_undirected_cpp(G_cpp_digraph): + """将C++ DiGraph转换为C++ Graph无向图""" + import cpp_easygraph + + G_cpp = cpp_easygraph.Graph() + G_cpp.graph.update(G_cpp_digraph.graph) + for node, node_attr in G_cpp_digraph.nodes.items(): + G_cpp.add_node(node, **node_attr) + + seen_edges = set() + for u, v, edge_data in G_cpp_digraph.edges: + edge = (min(u, v), max(u, v)) + if edge not in seen_edges: + seen_edges.add(edge) + G_cpp.add_edge(u, v, **edge_data) + + return G_cpp + + +def _csr_dict_to_graph(csr_dict): + import cpp_easygraph + + if not csr_dict or 'nodes' not in csr_dict: + return cpp_easygraph.Graph() + + G = cpp_easygraph.Graph() + + nodes = csr_dict['nodes'] + for node in nodes: + G.add_node(node) + + V = csr_dict['V'] + E = csr_dict['E'] + W = csr_dict.get('W', [1.0] * len(E)) + + for u in range(len(V) - 1): + start = V[u] + end = V[u + 1] + for i in range(start, end): + v_idx = E[i] + weight = W[i] if i < len(W) else 1.0 + G.add_edge(nodes[u], nodes[v_idx], weight=weight) + + return G + + +class TestEgoGraph(unittest.TestCase): + def setUp(self): + self.simple_graph = eg.Graph() + self.simple_graph.add_edges_from([(0, 1), (1, 2), (2, 3), (3, 4)]) + + self.directed_graph = eg.DiGraph() + self.directed_graph.add_edges_from([(0, 1), (1, 2), (2, 3)]) + + self.weighted_graph = eg.Graph() + self.weighted_graph.add_edges_from( + [(0, 1, {"weight": 1}), (1, 2, {"weight": 2}), (2, 3, {"weight": 3})] + ) + + self.disconnected_graph = eg.Graph() + self.disconnected_graph.add_edges_from([(0, 1), (2, 3)]) + + self.single_node_graph = eg.Graph() + self.single_node_graph.add_node(42) + + def _get_cpp_module(self): + return _get_cpp_module() + + def test_ego_graph_simple_radius_1(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.simple_graph.cpp() + ego = cpeg.cpp_ego_graph(G_cpp, 2, radius=1) + self.assertIsInstance(ego, type(G_cpp)) + self.assertIn(2, ego.nodes) + self.assertIn(1, ego.nodes) + self.assertIn(3, ego.nodes) + + def test_ego_graph_simple_radius_2(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.simple_graph.cpp() + ego = cpeg.cpp_ego_graph(G_cpp, 2, radius=2) + self.assertIsInstance(ego, type(G_cpp)) + self.assertIn(0, ego.nodes) + self.assertIn(1, ego.nodes) + self.assertIn(2, ego.nodes) + self.assertIn(3, ego.nodes) + self.assertIn(4, ego.nodes) + + def test_ego_graph_directed(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.directed_graph.cpp() + ego = cpeg.cpp_ego_graph(G_cpp, 1, radius=1) + self.assertIsInstance(ego, type(G_cpp)) + self.assertIn(1, ego.nodes) + self.assertIn(2, ego.nodes) + + def test_ego_graph_weighted_with_distance(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.weighted_graph.cpp() + G_cpp.generate_linkgraph('weight') + ego = cpeg.cpp_ego_graph(G_cpp, 0, radius=2, distance="weight") + self.assertIsInstance(ego, type(G_cpp)) + self.assertIn(0, ego.nodes) + self.assertIn(1, ego.nodes) + + def test_ego_graph_disconnected(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.disconnected_graph.cpp() + ego = cpeg.cpp_ego_graph(G_cpp, 0, radius=1) + self.assertIsInstance(ego, type(G_cpp)) + self.assertIn(0, ego.nodes) + self.assertIn(1, ego.nodes) + + def test_ego_graph_single_node(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.single_node_graph.cpp() + ego = cpeg.cpp_ego_graph(G_cpp, 42, radius=1) + self.assertIsInstance(ego, type(G_cpp)) + self.assertIn(42, ego.nodes) + + def test_ego_graph_center_false(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.simple_graph.cpp() + ego = cpeg.cpp_ego_graph(G_cpp, 2, radius=1, center=False) + self.assertIsInstance(ego, type(G_cpp)) + self.assertNotIn(2, ego.nodes) + self.assertIn(1, ego.nodes) + self.assertIn(3, ego.nodes) + + def test_python_cpp_consistency(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.simple_graph.cpp() + ego_py = eg.functions.community.ego_graph(self.simple_graph, 2, radius=1) + ego_cpp = cpeg.cpp_ego_graph(G_cpp, 2, radius=1) + + self.assertEqual(set(ego_py.nodes), set(ego_cpp.nodes)) + + +class TestEgoGraphCSR(unittest.TestCase): + def setUp(self): + self.simple_graph = eg.Graph() + self.simple_graph.add_edges_from([(0, 1), (1, 2), (2, 3), (3, 4)]) + + self.disconnected_graph = eg.Graph() + self.disconnected_graph.add_edges_from([(0, 1), (2, 3)]) + + self.single_node_graph = eg.Graph() + self.single_node_graph.add_node(42) + + def _get_cpp_module(self): + return _get_cpp_module() + + def test_ego_graph_csr_simple(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.simple_graph.cpp() + G_cpp.generate_linkgraph('weight') + ego_csr_dict = cpeg.cpp_ego_graph_csr(G_cpp, 2, radius=1) + + ego = _csr_dict_to_graph(ego_csr_dict) + + self.assertTrue(hasattr(ego, 'nodes')) + self.assertIn(2, ego.nodes) + + def test_ego_graph_csr_disconnected(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.disconnected_graph.cpp() + G_cpp.generate_linkgraph('weight') + ego_csr_dict = cpeg.cpp_ego_graph_csr(G_cpp, 0, radius=1) + + ego = _csr_dict_to_graph(ego_csr_dict) + + self.assertTrue(hasattr(ego, 'nodes')) + self.assertIn(0, ego.nodes) + self.assertIn(1, ego.nodes) + + def test_ego_graph_csr_single_node(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.single_node_graph.cpp() + G_cpp.generate_linkgraph('weight') + ego_csr_dict = cpeg.cpp_ego_graph_csr(G_cpp, 42, radius=1) + + ego = _csr_dict_to_graph(ego_csr_dict) + + self.assertTrue(hasattr(ego, 'nodes')) + self.assertIn(42, ego.nodes) + + def test_csr_vs_original_consistency(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.simple_graph.cpp() + G_cpp.generate_linkgraph('weight') + + ego_original = cpeg.cpp_ego_graph(G_cpp, 2, radius=1, undirected=False) + ego_csr_dict = cpeg.cpp_ego_graph_csr(G_cpp, 2, radius=1) + + ego_csr = _csr_dict_to_graph(ego_csr_dict) + + self.assertIn(2, ego_original.nodes) + self.assertIn(2, ego_csr.nodes) + + self.assertTrue(set(ego_csr.nodes).issubset(set(ego_original.nodes) | {4})) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/cpp_easygraph/functions/community/tests/cpp_enumerate_subgraph_test.py b/cpp_easygraph/functions/community/tests/cpp_enumerate_subgraph_test.py new file mode 100644 index 0000000..69f3fc7 --- /dev/null +++ b/cpp_easygraph/functions/community/tests/cpp_enumerate_subgraph_test.py @@ -0,0 +1,143 @@ +import random +import unittest + +import easygraph as eg + + +def _get_cpp_module(): + try: + import cpp_easygraph + return cpp_easygraph + except ImportError: + return None + + +def _to_undirected_cpp(G_cpp_digraph): + import cpp_easygraph + + G_cpp = cpp_easygraph.Graph() + G_cpp.graph.update(G_cpp_digraph.graph) + for node, node_attr in G_cpp_digraph.nodes.items(): + G_cpp.add_node(node, **node_attr) + + seen_edges = set() + for u, v, edge_data in G_cpp_digraph.edges: + edge = (min(u, v), max(u, v)) + if edge not in seen_edges: + seen_edges.add(edge) + G_cpp.add_edge(u, v, **edge_data) + + return G_cpp + + +class TestEnumerateSubgraph(unittest.TestCase): + def setUp(self): + self.G_triangle = eg.Graph() + self.G_triangle.add_edges_from([(1, 2), (2, 3), (3, 1)]) + + self.G_complex = eg.Graph() + self.G_complex.add_edges_from([ + (1, 3), (2, 3), (3, 4), (4, 5), (3, 5) + ]) + + self.G_empty = eg.Graph() + + self.G_small = eg.Graph() + self.G_small.add_edges_from([(1, 2)]) + + def _get_cpp_module(self): + return _get_cpp_module() + + def test_enumerate_subgraph_k3(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + motifs = cpeg.cpp_enumerate_subgraph(self.G_complex.cpp(), 3) + self.assertIsInstance(motifs, list) + for m in motifs: + self.assertEqual(len(m), 3) + + def test_enumerate_subgraph_k4(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + motifs = cpeg.cpp_enumerate_subgraph(self.G_complex.cpp(), 4) + for m in motifs: + self.assertEqual(len(m), 4) + + def test_enumerate_subgraph_empty_graph(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + motifs = cpeg.cpp_enumerate_subgraph(self.G_empty.cpp(), 3) + self.assertEqual(motifs, []) + + def test_enumerate_subgraph_k_larger_than_graph(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + motifs = cpeg.cpp_enumerate_subgraph(self.G_small.cpp(), 3) + self.assertEqual(motifs, []) + + def test_python_cpp_consistency(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + motifs_py = eg.enumerate_subgraph(self.G_complex, 3) + motifs_py = [frozenset(m) for m in motifs_py] + motifs_cpp = cpeg.cpp_enumerate_subgraph(self.G_complex.cpp(), 3) + motifs_cpp = [frozenset(m) for m in motifs_cpp] + + self.assertEqual(set(motifs_py), set(motifs_cpp)) + + +class TestRandomEnumerateSubgraph(unittest.TestCase): + def setUp(self): + self.G_complex = eg.Graph() + self.G_complex.add_edges_from([ + (1, 3), (2, 3), (3, 4), (4, 5), (3, 5) + ]) + + self.G_empty = eg.Graph() + + def _get_cpp_module(self): + return _get_cpp_module() + + def test_random_enumerate_valid_cut_prob(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + cut_prob = [1.0, 1.0, 1.0] + motifs = cpeg.cpp_random_enumerate_subgraph(self.G_complex.cpp(), 3, cut_prob) + self.assertIsInstance(motifs, list) + + def test_random_enumerate_zero_cut_prob(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + cut_prob = [0.0, 0.0, 0.0] + motifs = cpeg.cpp_random_enumerate_subgraph(self.G_complex.cpp(), 3, cut_prob) + self.assertEqual(motifs, []) + + def test_random_enumerate_different_results(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + cut_prob = [1.0, 1.0, 1.0] + motifs1 = cpeg.cpp_random_enumerate_subgraph(self.G_complex.cpp(), 3, cut_prob) + motifs2 = cpeg.cpp_random_enumerate_subgraph(self.G_complex.cpp(), 3, cut_prob) + + self.assertIsInstance(motifs1, list) + self.assertIsInstance(motifs2, list) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/cpp_easygraph/functions/community/tests/cpp_greedy_modularity_test.py b/cpp_easygraph/functions/community/tests/cpp_greedy_modularity_test.py new file mode 100644 index 0000000..351bcf8 --- /dev/null +++ b/cpp_easygraph/functions/community/tests/cpp_greedy_modularity_test.py @@ -0,0 +1,117 @@ +import unittest + +import easygraph as eg + + +def _get_cpp_module(): + try: + import cpp_easygraph + return cpp_easygraph + except ImportError: + return None + + +def _to_undirected_cpp(G_cpp_digraph): + import cpp_easygraph + + G_cpp = cpp_easygraph.Graph() + G_cpp.graph.update(G_cpp_digraph.graph) + for node, node_attr in G_cpp_digraph.nodes.items(): + G_cpp.add_node(node, **node_attr) + + seen_edges = set() + for u, v, edge_data in G_cpp_digraph.edges: + edge = (min(u, v), max(u, v)) + if edge not in seen_edges: + seen_edges.add(edge) + G_cpp.add_edge(u, v, **edge_data) + + return G_cpp + + +class TestGreedyModularity(unittest.TestCase): + def setUp(self): + self.G_simple = eg.Graph() + self.G_simple.add_edges_from([(0, 1), (1, 2), (2, 3), (3, 0)]) + + self.G_disconnected = eg.Graph() + self.G_disconnected.add_edges_from([(0, 1), (2, 3), (4, 5)]) + + self.G_weighted = eg.Graph() + self.G_weighted.add_edge(0, 1, weight=5) + self.G_weighted.add_edge(1, 2, weight=3) + self.G_weighted.add_edge(2, 0, weight=2) + self.G_weighted.add_edge(3, 4, weight=1) + + self.G_single = eg.Graph() + self.G_single.add_node(42) + + self.G_empty = eg.Graph() + + def _get_cpp_module(self): + return _get_cpp_module() + + def test_greedy_simple(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = cpeg.cpp_greedy_modularity_communities(self.G_simple.cpp()) + self.assertIsInstance(communities, list) + flat = {node for comm in communities for node in comm} + self.assertSetEqual(flat, set(self.G_simple.nodes)) + + def test_greedy_weighted(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = cpeg.cpp_greedy_modularity_communities(self.G_weighted.cpp(), weight="weight") + self.assertIsInstance(communities, list) + flat = {node for comm in communities for node in comm} + self.assertSetEqual(flat, set(self.G_weighted.nodes)) + + def test_greedy_disconnected(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = cpeg.cpp_greedy_modularity_communities(self.G_disconnected.cpp()) + self.assertIsInstance(communities, list) + flat = {node for comm in communities for node in comm} + self.assertSetEqual(flat, set(self.G_disconnected.nodes)) + + def test_greedy_single_node(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = cpeg.cpp_greedy_modularity_communities(self.G_single.cpp()) + self.assertEqual(len(communities), 1) + self.assertIn(42, communities[0]) + + def test_greedy_empty_graph(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = cpeg.cpp_greedy_modularity_communities(self.G_empty.cpp()) + self.assertEqual(communities, []) + + def test_python_cpp_consistency(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities_cpp = cpeg.cpp_greedy_modularity_communities(self.G_simple.cpp()) + communities_py = eg.functions.community.greedy_modularity_communities(self.G_simple) + + self.assertEqual(len(communities_cpp), len(communities_py)) + + flat_cpp = {node for comm in communities_cpp for node in comm} + flat_py = {node for comm in communities_py for node in comm} + self.assertEqual(flat_cpp, flat_py) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/cpp_easygraph/functions/community/tests/cpp_localsearch_test.py b/cpp_easygraph/functions/community/tests/cpp_localsearch_test.py new file mode 100644 index 0000000..e34c1e7 --- /dev/null +++ b/cpp_easygraph/functions/community/tests/cpp_localsearch_test.py @@ -0,0 +1,188 @@ +import unittest + +import easygraph as eg + + +def _get_cpp_module(): + try: + import cpp_easygraph + return cpp_easygraph + except ImportError: + return None + + +def _to_undirected_cpp(G_cpp_digraph): + import cpp_easygraph + + G_cpp = cpp_easygraph.Graph() + G_cpp.graph.update(G_cpp_digraph.graph) + for node, node_attr in G_cpp_digraph.nodes.items(): + G_cpp.add_node(node, **node_attr) + + seen_edges = set() + for u, v, edge_data in G_cpp_digraph.edges: + edge = (min(u, v), max(u, v)) + if edge not in seen_edges: + seen_edges.add(edge) + G_cpp.add_edge(u, v, **edge_data) + + return G_cpp + + +class TestLocalsearch(unittest.TestCase): + def setUp(self): + self.G_simple = eg.Graph() + self.G_simple.add_edges_from([(0, 2), (0, 3), (0, 4), (0, 5), (1, 2), (1, 3), (1, 4), (1, 5)]) + + self.G_disconnected = eg.Graph() + self.G_disconnected.add_edges_from([(0, 1), (2, 3), (4, 5)]) + + self.G_single = eg.Graph() + self.G_single.add_node(42) + + self.G_empty = eg.Graph() + + def _get_cpp_module(self): + return _get_cpp_module() + + def test_localsearch_simple(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.G_simple.cpp() + G_cpp.generate_linkgraph('weight') + result = cpeg.cpp_localsearch(G_cpp, seed=163) + + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 6) + y_partition = result[3] + self.assertIsInstance(y_partition, dict) + + all_nodes = set(self.G_simple.nodes) + result_nodes = set(y_partition.keys()) + self.assertEqual(all_nodes, result_nodes) + + def test_localsearch_disconnected(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.G_disconnected.cpp() + G_cpp.generate_linkgraph('weight') + result = cpeg.cpp_localsearch(G_cpp, seed=163) + + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 6) + y_partition = result[3] + self.assertIsInstance(y_partition, dict) + + all_nodes = set(self.G_disconnected.nodes) + result_nodes = set(y_partition.keys()) + self.assertEqual(all_nodes, result_nodes) + + def test_localsearch_single_node(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.G_single.cpp() + G_cpp.generate_linkgraph('weight') + result = cpeg.cpp_localsearch(G_cpp, seed=163) + + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 6) + y_partition = result[3] + self.assertIsInstance(y_partition, dict) + self.assertIn(42, y_partition) + + def test_localsearch_empty_graph(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.G_empty.cpp() + G_cpp.generate_linkgraph('weight') + result = cpeg.cpp_localsearch(G_cpp, seed=163) + + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 6) + y_partition = result[3] + self.assertIsInstance(y_partition, dict) + + def test_localsearch_maximum_tree_true(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.G_simple.cpp() + G_cpp.generate_linkgraph('weight') + result = cpeg.cpp_localsearch(G_cpp, maximum_tree=True, seed=163) + + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 6) + y_partition = result[3] + self.assertIsInstance(y_partition, dict) + + def test_localsearch_maximum_tree_false(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.G_simple.cpp() + G_cpp.generate_linkgraph('weight') + result = cpeg.cpp_localsearch(G_cpp, maximum_tree=False, seed=163) + + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 6) + y_partition = result[3] + self.assertIsInstance(y_partition, dict) + + def test_localsearch_auto_choose_centers(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.G_simple.cpp() + G_cpp.generate_linkgraph('weight') + result = cpeg.cpp_localsearch(G_cpp, auto_choose_centers=True, seed=163) + + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 6) + y_partition = result[3] + self.assertIsInstance(y_partition, dict) + + def test_localsearch_center_num(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.G_simple.cpp() + G_cpp.generate_linkgraph('weight') + result = cpeg.cpp_localsearch(G_cpp, center_num=2, seed=163) + + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 6) + y_partition = result[3] + self.assertIsInstance(y_partition, dict) + + def test_localsearch_with_seed_reproducibility(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + G_cpp = self.G_simple.cpp() + G_cpp.generate_linkgraph('weight') + + result1 = cpeg.cpp_localsearch(G_cpp, seed=42) + result2 = cpeg.cpp_localsearch(G_cpp, seed=42) + + self.assertIsInstance(result1, tuple) + self.assertIsInstance(result2, tuple) + y_partition1 = result1[3] + y_partition2 = result2[3] + self.assertEqual(y_partition1.keys(), y_partition2.keys()) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/cpp_easygraph/functions/community/tests/cpp_louvain_test.py b/cpp_easygraph/functions/community/tests/cpp_louvain_test.py new file mode 100644 index 0000000..4be9f1d --- /dev/null +++ b/cpp_easygraph/functions/community/tests/cpp_louvain_test.py @@ -0,0 +1,178 @@ +import unittest + +import easygraph as eg + + +def _get_cpp_module(): + try: + import cpp_easygraph + return cpp_easygraph + except ImportError: + return None + + +def _to_undirected_cpp(G_cpp_digraph): + import cpp_easygraph + + G_cpp = cpp_easygraph.Graph() + G_cpp.graph.update(G_cpp_digraph.graph) + for node, node_attr in G_cpp_digraph.nodes.items(): + G_cpp.add_node(node, **node_attr) + + seen_edges = set() + for u, v, edge_data in G_cpp_digraph.edges: + edge = (min(u, v), max(u, v)) + if edge not in seen_edges: + seen_edges.add(edge) + G_cpp.add_edge(u, v, **edge_data) + + return G_cpp + + +class TestLouvainCommunities(unittest.TestCase): + def setUp(self): + self.G_simple = eg.Graph() + self.G_simple.add_edges_from([(0, 1), (1, 2), (3, 4)]) + + self.G_weighted = eg.Graph() + self.G_weighted.add_edge(0, 1, weight=5) + self.G_weighted.add_edge(1, 2, weight=3) + self.G_weighted.add_edge(3, 4, weight=2) + + self.G_disconnected = eg.Graph() + self.G_disconnected.add_edges_from([(0, 1), (2, 3), (4, 5)]) + + self.G_single = eg.Graph() + self.G_single.add_node(42) + + self.G_empty = eg.Graph() + + def _get_cpp_module(self): + return _get_cpp_module() + + def test_louvain_simple(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = cpeg.cpp_louvain_communities(self.G_simple.cpp()) + self.assertIsInstance(communities, list) + flat = {node for comm in communities for node in comm} + self.assertSetEqual(flat, set(self.G_simple.nodes)) + + def test_louvain_weighted(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = cpeg.cpp_louvain_communities(self.G_weighted.cpp(), weight="weight") + self.assertIsInstance(communities, list) + flat = {node for comm in communities for node in comm} + self.assertSetEqual(flat, set(self.G_weighted.nodes)) + + def test_louvain_disconnected(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = cpeg.cpp_louvain_communities(self.G_disconnected.cpp()) + self.assertIsInstance(communities, list) + flat = {node for comm in communities for node in comm} + self.assertSetEqual(flat, set(self.G_disconnected.nodes)) + + def test_louvain_single_node(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = cpeg.cpp_louvain_communities(self.G_single.cpp()) + self.assertEqual(len(communities), 1) + self.assertIn(42, communities[0]) + + def test_louvain_empty_graph(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = cpeg.cpp_louvain_communities(self.G_empty.cpp()) + self.assertEqual(communities, []) + + def test_louvain_resolution_parameter(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities1 = cpeg.cpp_louvain_communities(self.G_simple.cpp(), resolution=1.0) + communities2 = cpeg.cpp_louvain_communities(self.G_simple.cpp(), resolution=0.5) + + self.assertIsInstance(communities1, list) + self.assertIsInstance(communities2, list) + + flat1 = {node for comm in communities1 for node in comm} + flat2 = {node for comm in communities2 for node in comm} + self.assertEqual(flat1, flat2) + + def test_python_cpp_consistency(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities_cpp = cpeg.cpp_louvain_communities(self.G_simple.cpp()) + communities_py = eg.functions.community.louvain_communities(self.G_simple) + + self.assertEqual(len(communities_cpp), len(communities_py)) + + flat_cpp = {node for comm in communities_cpp for node in comm} + flat_py = {node for comm in communities_py for node in comm} + self.assertEqual(flat_cpp, flat_py) + + +class TestLouvainCommunitiesSerial(unittest.TestCase): + def setUp(self): + self.G_simple = eg.Graph() + self.G_simple.add_edges_from([(0, 1), (1, 2), (3, 4)]) + + self.G_weighted = eg.Graph() + self.G_weighted.add_edge(0, 1, weight=5) + self.G_weighted.add_edge(1, 2, weight=3) + self.G_weighted.add_edge(3, 4, weight=2) + + def _get_cpp_module(self): + return _get_cpp_module() + + def test_louvain_serial_simple(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = cpeg.cpp_louvain_communities_serial(self.G_simple.cpp()) + self.assertIsInstance(communities, list) + flat = {node for comm in communities for node in comm} + self.assertSetEqual(flat, set(self.G_simple.nodes)) + + def test_louvain_serial_weighted(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = cpeg.cpp_louvain_communities_serial(self.G_weighted.cpp(), weight="weight") + self.assertIsInstance(communities, list) + flat = {node for comm in communities for node in comm} + self.assertSetEqual(flat, set(self.G_weighted.nodes)) + + def test_parallel_vs_serial_consistency(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities_parallel = cpeg.cpp_louvain_communities(self.G_simple.cpp()) + communities_serial = cpeg.cpp_louvain_communities_serial(self.G_simple.cpp()) + + flat_parallel = {frozenset(comm) for comm in communities_parallel} + flat_serial = {frozenset(comm) for comm in communities_serial} + + self.assertEqual(flat_parallel, flat_serial) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/cpp_easygraph/functions/community/tests/cpp_modularity_test.py b/cpp_easygraph/functions/community/tests/cpp_modularity_test.py new file mode 100644 index 0000000..6985e82 --- /dev/null +++ b/cpp_easygraph/functions/community/tests/cpp_modularity_test.py @@ -0,0 +1,161 @@ +import unittest + +import easygraph as eg + + +def _get_cpp_module(): + """获取cpp_easygraph模块""" + try: + import cpp_easygraph + return cpp_easygraph + except ImportError: + return None + + +def _to_undirected_cpp(G_cpp_digraph): + """将C++ DiGraph转换为C++ Graph无向图""" + import cpp_easygraph + + G_cpp = cpp_easygraph.Graph() + G_cpp.graph.update(G_cpp_digraph.graph) + for node, node_attr in G_cpp_digraph.nodes.items(): + G_cpp.add_node(node, **node_attr) + + seen_edges = set() + for u, v, edge_data in G_cpp_digraph.edges: + edge = (min(u, v), max(u, v)) + if edge not in seen_edges: + seen_edges.add(edge) + G_cpp.add_edge(u, v, **edge_data) + + return G_cpp + + +def _communities_to_membership(G, communities): + + if not isinstance(communities, list): + communities = list(communities) + + N = G.number_of_nodes() + membership = [-1] * N + node_index = G.node_index + + for comm_id, community in enumerate(communities): + for node in community: + if node in node_index: + node_id = node_index[node] + membership[node_id] = comm_id + + return membership + + +class TestModularity(unittest.TestCase): + def setUp(self): + self.G = eg.Graph() + self.G.add_edges_from([(0, 1), (1, 2), (2, 3), (3, 0)]) + + self.DG = eg.DiGraph() + self.DG.add_edges_from([(0, 1), (1, 2), (2, 0)]) + + self.G_weighted = eg.Graph() + self.G_weighted.add_edge(0, 1, weight=2) + self.G_weighted.add_edge(1, 2, weight=3) + self.G_weighted.add_edge(2, 0, weight=1) + + self.G_selfloop = eg.Graph() + self.G_selfloop.add_edges_from([(0, 0), (1, 1), (0, 1)]) + + self.G_empty = eg.Graph() + + self.config = type('Config', (), { + 'is_directed': False + })() + + def _get_cpp_module(self): + return _get_cpp_module() + + def test_undirected_modularity(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = [{0, 1}, {2, 3}] + membership = _communities_to_membership(self.G, communities) + q = cpeg.cpp_modularity(self.G.cpp(), membership) + self.assertIsInstance(q, float) + self.assertGreaterEqual(q, -1.0) + self.assertLessEqual(q, 1.0) + + def test_directed_modularity(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = [{0, 1, 2}] + membership = _communities_to_membership(self.DG, communities) + q = cpeg.cpp_modularity(self.DG.cpp(), membership) + self.assertIsInstance(q, float) + + def test_weighted_graph(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = [{0, 1}, {2}] + membership = _communities_to_membership(self.G_weighted, communities) + q = cpeg.cpp_modularity(self.G_weighted.cpp(), membership, weight="weight") + self.assertIsInstance(q, float) + + def test_self_loops(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = [{0, 1}] + membership = _communities_to_membership(self.G_selfloop, communities) + q = cpeg.cpp_modularity(self.G_selfloop.cpp(), membership) + self.assertIsInstance(q, float) + + def test_single_community(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = [{0, 1, 2, 3}] + membership = _communities_to_membership(self.G, communities) + q = cpeg.cpp_modularity(self.G.cpp(), membership) + self.assertIsInstance(q, float) + + def test_each_node_its_own_community(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities = [{0}, {1}, {2}, {3}] + membership = _communities_to_membership(self.G, communities) + q = cpeg.cpp_modularity(self.G.cpp(), membership) + self.assertIsInstance(q, float) + + def test_empty_community_list(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + q = cpeg.cpp_modularity(self.G.cpp(), []) + self.assertEqual(q, 0.0) + + def test_python_cpp_consistency(self): + cpeg = self._get_cpp_module() + if cpeg is None: + self.skipTest("cpp_easygraph module not available") + + communities_py = [{0, 1}, {2, 3}] + + q_py = eg.functions.community.modularity(self.G, communities_py) + q_cpp = cpeg.cpp_modularity(self.G.cpp(), communities_py) + + self.assertAlmostEqual(q_py, q_cpp, places=5) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/cpp_easygraph/functions/community/tests/run_all_tests.py b/cpp_easygraph/functions/community/tests/run_all_tests.py new file mode 100644 index 0000000..5c7588f --- /dev/null +++ b/cpp_easygraph/functions/community/tests/run_all_tests.py @@ -0,0 +1,55 @@ +import unittest +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from cpp_modularity_test import TestModularity +from cpp_greedy_modularity_test import TestGreedyModularity +from cpp_enumerate_subgraph_test import ( + TestEnumerateSubgraph, + TestRandomEnumerateSubgraph +) +from cpp_louvain_test import TestLouvainCommunities, TestLouvainCommunitiesSerial +from cpp_LPA_test import TestLPA +from cpp_ego_graph_test import TestEgoGraph, TestEgoGraphCSR +from cpp_localsearch_test import TestLocalsearch + + +def create_test_suite(): + suite = unittest.TestSuite() + suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestModularity)) + suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestGreedyModularity)) + suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestEnumerateSubgraph)) + suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestRandomEnumerateSubgraph)) + suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestLouvainCommunities)) + suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestLouvainCommunitiesSerial)) + suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestLPA)) + suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestEgoGraph)) + suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestEgoGraphCSR)) + suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestLocalsearch)) + return suite + + +if __name__ == "__main__": + print("=" * 70) + print("Easy-Graph C++ Module Test Suite") + print("=" * 70) + print() + + suite = create_test_suite() + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + print() + print("=" * 70) + print("Test Summary") + print("=" * 70) + print(f"Tests run: {result.testsRun}") + print(f"Successes: {result.testsRun - len(result.failures) - len(result.errors)}") + print(f"Failures: {len(result.failures)}") + print(f"Errors: {len(result.errors)}") + print(f"Skipped: {len(result.skipped)}") + print("=" * 70) + + sys.exit(0 if result.wasSuccessful() else 1) \ No newline at end of file diff --git a/cpp_easygraph/functions/components/__init__.h b/cpp_easygraph/functions/components/__init__.h new file mode 100644 index 0000000..fe1cffd --- /dev/null +++ b/cpp_easygraph/functions/components/__init__.h @@ -0,0 +1,5 @@ +#pragma once + +#include "biconnected.h" +#include "connected.h" +#include "strongly_connected.h" \ No newline at end of file diff --git a/cpp_easygraph/functions/components/biconnected.cpp b/cpp_easygraph/functions/components/biconnected.cpp new file mode 100644 index 0000000..24e84ca --- /dev/null +++ b/cpp_easygraph/functions/components/biconnected.cpp @@ -0,0 +1,109 @@ +#include "biconnected.h" +#include "../../classes/graph.h" +#include "../../common/utils.h" + + +node_t index_edge(std::vector>& edges, const std::pair& target) { + for (int i = edges.size() - 1;i >= 0;i--) { + if ((edges[i].first == target.first) && (edges[i].second == target.second)) { + return i; + } + } + return -1; +} + + +py::object _biconnected_dfs_record_edges(py::object G, py::object need_components) { + py::list ret = py::list(); + std::unordered_set visited; + Graph& G_ = G.cast(); + node_dict_factory &nodes_list = G_.node; + for (node_dict_factory::iterator iter = nodes_list.begin();iter != nodes_list.end();iter++) { + node_t start_id = iter->first; + if (visited.find(start_id) != visited.end()) { + continue; + } + std::unordered_map discovery; + std::unordered_map low; + node_t root_children = 0; + discovery.emplace(start_id, 0); + low.emplace(start_id, 0); + visited.emplace(start_id); + std::vector> edge_stack; + std::vector stack; + adj_attr_dict_factory& start_adj = G_.adj[start_id]; + NeighborIterator neighbors_iter = NeighborIterator(start_adj); + stack_node initial_stack_node(start_id, start_id, neighbors_iter); + stack.emplace_back(initial_stack_node); + while (!stack.empty()) { + stack_node& node_info = stack.back(); + node_t node_grandparent_id = node_info.grandparent; + node_t node_parent_id = node_info.parent; + try { + node_t node_child_id = node_info.neighbors_iter.next(); + if (node_grandparent_id == node_child_id) { + continue; + } + if (visited.find(node_child_id) != visited.end()) { + if (discovery[node_child_id] <= discovery[node_parent_id]) { + low[node_parent_id] = std::min(low[node_parent_id], discovery[node_child_id]); + if (need_components.cast()) { + edge_stack.emplace_back(std::make_pair(node_parent_id, node_child_id)); + } + } + } + else { + low[node_child_id] = discovery[node_child_id] = discovery.size(); + visited.emplace(node_child_id); + adj_attr_dict_factory& node_child_adj = G_.adj[node_child_id]; + NeighborIterator child_neighbors_iter = NeighborIterator(G_.adj[node_child_id]); + stack.emplace_back(node_parent_id, node_child_id, child_neighbors_iter); + if (need_components.cast()) { + edge_stack.emplace_back(std::make_pair(node_parent_id, node_child_id)); + } + } + } + catch (int) { + stack.pop_back(); + if (stack.size() > 1) { + if (low[node_parent_id] >= discovery[node_grandparent_id]) { + if (need_components.cast()) { + py::list tmp_ret = py::list(); + std::pair iter_edge = std::make_pair(-1, -1); + while ((iter_edge.first != node_grandparent_id || iter_edge.second != node_parent_id)) { + iter_edge = edge_stack.back(); + edge_stack.pop_back(); + tmp_ret.append(py::make_tuple(G_.id_to_node[py::cast(iter_edge.first)], G_.id_to_node[py::cast(iter_edge.second)])); + } + ret.append(tmp_ret); + } + else { + ret.append(G_.id_to_node[py::cast(node_grandparent_id)]); + } + } + low[node_grandparent_id] = std::min(low[node_grandparent_id], low[node_parent_id]); + } + else if (stack.size() > 0) { + root_children += 1; + if (need_components.cast()) { + std::pair target = std::make_pair(node_grandparent_id, node_parent_id); + node_t ind = index_edge(edge_stack, target); + if (ind != -1) { + py::list tmp_ret = py::list(); + for (node_t z = ind;z < edge_stack.size();z++) { + tmp_ret.append(py::make_tuple(G_.id_to_node[py::cast(edge_stack[z].first)], G_.id_to_node[py::cast(edge_stack[z].second)])); + } + ret.append(tmp_ret); + } + } + } + } + } + if (!need_components.cast()) { + if (root_children > 1) { + ret.append(G_.id_to_node(start_id)); + } + } + } + return ret; +} diff --git a/cpp_easygraph/functions/components/biconnected.h b/cpp_easygraph/functions/components/biconnected.h new file mode 100644 index 0000000..64cbff8 --- /dev/null +++ b/cpp_easygraph/functions/components/biconnected.h @@ -0,0 +1,34 @@ +#pragma once + +#include "../../common/common.h" + +class NeighborIterator { +public: + NeighborIterator() { + } + NeighborIterator(adj_attr_dict_factory& neighbor_map) { + now = neighbor_map.begin();; + end = neighbor_map.end(); + } + node_t next() { + if (now == end) { + throw -1; + } + else { + return (now++)->first; + } + } +private: + adj_attr_dict_factory::iterator now, end; +}; +typedef struct stackNode { + node_t grandparent, parent; + NeighborIterator neighbors_iter; + stackNode(node_t grandparent, node_t parent, NeighborIterator neighbors_iter) { + this->grandparent = grandparent; + this->parent = parent; + this->neighbors_iter = neighbors_iter; + } +}stack_node; + +py::object _biconnected_dfs_record_edges(py::object G, py::object need_components); diff --git a/cpp_easygraph/functions/components/connected.cpp b/cpp_easygraph/functions/components/connected.cpp new file mode 100644 index 0000000..b3b57e8 --- /dev/null +++ b/cpp_easygraph/functions/components/connected.cpp @@ -0,0 +1,205 @@ +#include "connected.h" + +#include + +#include "../../classes/graph.h" +#include "../../classes/directed_graph.h" +#include "../../common/utils.h" +#include "time.h" + +#define gmin(x, y) x = x < y? x: y + +py::object plain_bfs(py::object G, py::object source) { + Graph& G_ = G.cast(); + node_t source_id = G_.node_to_id.attr("get")(source).cast(); + adj_dict_factory& G_adj = G_.adj; + std::unordered_set seen; + std::unordered_set nextlevel; + nextlevel.emplace(source_id); + py::list res = py::list(); + while (nextlevel.size()) { + std::unordered_set thislevel = nextlevel; + nextlevel = std::unordered_set(); + for (std::unordered_set::iterator i = thislevel.begin(); i != thislevel.end(); i++) { + node_t v_id = *i; + if (seen.find(v_id) == seen.end()) { + seen.emplace(v_id); + adj_attr_dict_factory& v_adj = G_adj[v_id]; + for (adj_attr_dict_factory::iterator j = v_adj.begin(); j != v_adj.end(); j++) { + node_t neighbor_id = j->first; + nextlevel.emplace(neighbor_id); + } + } + } + } + + for (std::unordered_set::iterator i = seen.begin(); i != seen.end(); i++) { + node_t res_id = *(i); + res.append(G_.id_to_node.attr("get")(res_id)); + } + return res; +} + +py::object connected_component_undirected(py::object G) { + Graph& G_ = G.cast(); + bool is_directed = G.attr("is_directed")().cast(); + if (is_directed == true) { + printf("connected_component_undirected is designed for undirected graphs.\n"); + return py::dict(); + } + int N = G_.node.size(); + int M = G.attr("number_of_edges")().cast(); + std::vector E_res(N+5); + for(int i=0; i < N+5; i++) { + E_res[i].toward = 0; + E_res[i].next = 0; + } + int edge_number_res = 0; + std::vector parent(N+5, 0); + std::vector rank_node(N+5, 0); + std::vector color(N+5, 0); + std::vector head_res(N+5, 0); + std::vector has_edge(N+5, false); + + + py::list nodes_list = py::list(G.attr("nodes")); + for (int i = 0;i < py::len(nodes_list);i++) { + node_t i_id = (G_.node_to_id[nodes_list[i]]).cast(); + parent[i_id] = i_id; + } + + for (graph_edge& edge : G_._get_edges()) { + node_t u = edge.u, v = edge.v; + has_edge[u] = true; has_edge[v] = true; + _union_node(u, v, parent, rank_node); + } + + int Tot = 0; + for(int i = 1; i < N + 1; ++i) { + if (!has_edge[i]) + continue; + int fx = _getfa(i, parent); + if(fx == i){ + color[++Tot] = fx; + } + } + + for (int i = 1; i < N + 1; ++i) { + int fx = _getfa(i, parent); + _add_edge_res(fx, i, E_res, head_res, &edge_number_res); + } + + py::dict ret = py::dict(); + for (int i = 1; i <= Tot; ++i) { + py::list tmp = py::list(); + for(int p = head_res[color[i]]; p; p = E_res[p].next){ + tmp.append(py::cast(E_res[p].toward)); + } + ret[py::cast(i)] = tmp; + } + return ret; +} + +inline void _union_node(const int &u, const int &v, std::vector &parent, std::vector &rank_node) { + int x = _getfa(u, parent), y = _getfa(v, parent); //先找到两个根节点 + if (rank_node[x] <= rank_node[y]) + parent[x] = y; + else + parent[y] = x; + if (rank_node[x] == rank_node[y] && x != y) + rank_node[y]++; //如果深度相同且根节点不同,则新的根节点的深度+1 +} + +int _getfa(const int & x, std::vector &parent) { + int r,k,t; + r=x; + while(parent[r]!=r) + r=parent[r]; + k=r; + r=x; + while(parent[r]!=k) { + t=parent[r]; + parent[r]=k; + r=t; + } + return k; +} + +py::object connected_component_directed(py::object G) { + bool is_directed = G.attr("is_directed")().cast(); + if (is_directed == false) { + printf("connected_component_directed is designed for directed graphs.\n"); + return py::list(); + } + DiGraph& G_ = G.cast(); + int N = G_.node.size(); + Graph_L G_l; + if(G_.linkgraph_dirty || G_.linkgraph_structure.max_deg == -1){ + G_l = graph_to_linkgraph(G_, is_directed, "", true, false); + G_.linkgraph_dirty = false; + } + else{ + G_l = G_.linkgraph_structure; + } + std::vector& E = G_l.edges; + std::vector outDegree = G_l.degree; + std::vector head = G_l.head; + + int Time = 0, cnt = 0, Tot = 0, edge_number_res = 0; + + std::vector dfn(N+5, 0); + std::vector low(N+5, 0); + std::vector st(N+5, 0); + std::vector color(N+5, 0); + std::vector head_res(N+5, 0); + std::vector in_stack(N+5, false); + std::vector has_edge(N+5, false); + + std::vector E_res(N+5); + for(int i=0; i < N+5; i++) { + E_res[i].toward = 0; + E_res[i].next = 0; + } + + for (graph_edge& edge : G_._get_edges()) { + node_t u = edge.u, v = edge.v; + has_edge[u] = true; has_edge[v] = true; + } + + for (int i = 1; i < N + 1; ++i) + if (!dfn[i] && has_edge[i]) + _tarjan(i, &Time, &cnt, &Tot, E, head, dfn, low, st, color, in_stack, E_res, head_res, &edge_number_res); + + py::list ret = py::list(); + for (int i = 1; i <= Tot; ++i) { + py::set tmp; + for(int p = head_res[i]; p; p = E_res[p].next) + tmp.add(G_.id_to_node.attr("get")(E_res[p].toward)); + ret.append(tmp); + } + return ret; +} + +void _add_edge_res(const int &u, const int &v, std::vector &E_res, std::vector &head_res, int *edge_number_res) { + E_res[++(*edge_number_res)].next = head_res[u]; + E_res[*edge_number_res].toward = v; + head_res[u] = *edge_number_res; +} + +void _tarjan(const int &u, int *Time, int *cnt, int *Tot, std::vector& E, std::vector& head, std::vector &dfn, std::vector &low, std::vector &st, std::vector &color, std::vector &in_stack, std::vector &E_res, std::vector &head_res, int *edge_number_res) { + dfn[u] = low[u] = ++(*Time); st[++(*cnt)] = u; in_stack[u] = true; + for(int p = head[u]; p != -1; p = E[p].next){ + int v = E[p].to; + if (!dfn[v]) _tarjan(v, Time, cnt, Tot, E, head, dfn, low, st, color, in_stack, E_res, head_res, edge_number_res), gmin(low[u], low[v]); + else if (in_stack[v]) gmin(low[u], dfn[v]); + } + + if (dfn[u] == low[u]) { + for (++(*Tot); st[*cnt] != u; --(*cnt)) { + _add_edge_res(*Tot, st[*cnt], E_res, head_res, edge_number_res); + in_stack[st[*cnt]] = false, color[st[*cnt]] = *Tot; + } + _add_edge_res(*Tot, st[*cnt], E_res, head_res, edge_number_res); + in_stack[u] = false; color[u] = *Tot; --(*cnt); + } +} diff --git a/cpp_easygraph/functions/components/connected.h b/cpp_easygraph/functions/components/connected.h new file mode 100644 index 0000000..37e4562 --- /dev/null +++ b/cpp_easygraph/functions/components/connected.h @@ -0,0 +1,16 @@ +#pragma once + +#include "../../common/common.h" +#include "../../classes/linkgraph.h" + +struct Edge_weighted{ + int toward, next; +}; + +py::object plain_bfs(py::object G, py::object source); +py::object connected_component_undirected(py::object G); +inline void _union_node(const int &u, const int &v, std::vector& parent, std::vector &rank_node); +int _getfa(const int & x, std::vector &parent); +py::object connected_component_directed(py::object G); +void _tarjan(const int &u, int *Time, int *cnt, int *Tot, std::vector& E, std::vector& head, std::vector &dfn, std::vector &low, std::vector &st, std::vector &color, std::vector &in_stack, std::vector &E_res, std::vector &head_res, int *edge_number_res); +void _add_edge_res(const int &u, const int &v, std::vector &E_res, std::vector &head_res, int *edge_number_res); diff --git a/cpp_easygraph/functions/components/strongly_connected.cpp b/cpp_easygraph/functions/components/strongly_connected.cpp new file mode 100644 index 0000000..9e91639 --- /dev/null +++ b/cpp_easygraph/functions/components/strongly_connected.cpp @@ -0,0 +1,90 @@ +#include "strongly_connected.h" +#include "connected.h" + +#include "../../classes/directed_graph.h" + +#define MAX_NODES_NUM4RECURSION_METHOD 100000 + +py::object strongly_connected_components(py::object G) { + bool is_directed = G.attr("is_directed")().cast(); + if (is_directed == false) { + printf("connected_component_directed is designed for directed graphs.\n"); + return py::list(); + } + + int N = G.attr("number_of_nodes")().cast(); + if(N < MAX_NODES_NUM4RECURSION_METHOD){ + return connected_component_directed(G); + } + + return strongly_connected_components_iteration_impl(G); +} + +py::object strongly_connected_components_iteration_impl(py::object G) { + py::list res = py::list(); + DiGraph& G_ = py::cast(G); + adj_dict_factory& adj = G_.adj; + std::unordered_map preorder; + std::unordered_map lowlink; + std::set scc_found; + std::vector scc_queue; + int i = 0; + node_dict_factory& nodes_list = G_.node; + for (node_dict_factory::iterator source = nodes_list.begin(); source != nodes_list.end(); source++) { + node_t source_id = source->first; + if (scc_found.find(source_id) == scc_found.end()) { + std::vector que; + que.emplace_back(source_id); + while (!que.empty()) { + node_t v_id = que.back(); + if (preorder.find(v_id) == preorder.end()) { + i += 1; + preorder[v_id] = i; + } + bool done = true; + adj_attr_dict_factory& v_neighbors = adj[v_id]; + for (adj_attr_dict_factory::iterator w = v_neighbors.begin(); w != v_neighbors.end(); w++) { + node_t w_id = w->first; + if (preorder.find(w_id) == preorder.end()) { + que.emplace_back(w_id); + done = false; + break; + } + } + if (done) { + lowlink[v_id] = preorder[v_id]; + for (adj_attr_dict_factory::iterator w = v_neighbors.begin(); w != v_neighbors.end(); w++) { + node_t w_id = w->first; + if (scc_found.find(w_id) == scc_found.end()) { + if (preorder[w_id] > preorder[v_id]) { + lowlink[v_id] = std::min(lowlink[v_id], lowlink[w_id]); + } else { + lowlink[v_id] = std::min(lowlink[v_id], preorder[w_id]); + } + } + } + que.pop_back(); + if (lowlink[v_id] == preorder[v_id]) { + std::unordered_set scc; + scc.emplace(v_id); + while (!scc_queue.empty() && (preorder[scc_queue.back()] > preorder[v_id])) { + node_t k = scc_queue.back(); + scc_queue.pop_back(); + scc.emplace(k); + } + + py::set tmp_res; + for (std::unordered_set::iterator z = scc.begin(); z != scc.end(); z++) { + scc_found.emplace(*z); + tmp_res.add(G_.id_to_node.attr("get")(*z)); + } + res.append(tmp_res); + } else { + scc_queue.emplace_back(v_id); + } + } + } + } + } + return res; +} diff --git a/cpp_easygraph/functions/components/strongly_connected.h b/cpp_easygraph/functions/components/strongly_connected.h new file mode 100644 index 0000000..4673d52 --- /dev/null +++ b/cpp_easygraph/functions/components/strongly_connected.h @@ -0,0 +1,6 @@ +#pragma once + +#include "../../common/common.h" + +py::object strongly_connected_components(py::object G); +py::object strongly_connected_components_iteration_impl(py::object G); \ No newline at end of file diff --git a/cpp_easygraph/functions/cores/__init__.h b/cpp_easygraph/functions/cores/__init__.h new file mode 100644 index 0000000..fdcbd2c --- /dev/null +++ b/cpp_easygraph/functions/cores/__init__.h @@ -0,0 +1,3 @@ +#pragma once + +#include "k_cores.h" \ No newline at end of file diff --git a/cpp_easygraph/functions/cores/k_cores.cpp b/cpp_easygraph/functions/cores/k_cores.cpp new file mode 100644 index 0000000..30aced7 --- /dev/null +++ b/cpp_easygraph/functions/cores/k_cores.cpp @@ -0,0 +1,101 @@ +#ifdef EASYGRAPH_ENABLE_GPU +#include +#endif + +#include "k_cores.h" +#include "../../classes/graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" +#include + + +py::object invoke_cpp_core_decomposition(py::object G) { + // reference:https://arxiv.org/pdf/cs/0310049.pdf + Graph& G_ = G.cast(); + int N = G_.node.size(); + bool is_directed = G.attr("is_directed")().cast(); + Graph_L G_l; + if(G_.linkgraph_dirty || G_.linkgraph_structure.max_deg == -1){ + G_l = graph_to_linkgraph(G_, is_directed, "", true, false); + G_.linkgraph_dirty = false; + } + else{ + G_l = G_.linkgraph_structure; + } + std::vector edges = G_l.edges; + int edges_num = edges.size(); + std::vector deg = G_l.degree; + std::vector head = G_l.head; + int max_deg = G_l.max_deg; + std::vector core(N+1, 0); + std::vector bin(max_deg+1, 0); + std::vector pos(N+1, 0); + std::vector vert(N+1, 0); + for(int i = 1; i <= N; ++i) + ++bin[deg[i]]; + int start = 1; + for(int i = 0; i <= max_deg; ++i){ + int num = bin[i]; + bin[i] = start; + start += num; + } + for(int i = 1; i <= N; ++i){ + pos[i] = bin[deg[i]]; + vert[pos[i]] = i; + ++bin[deg[i]]; + } + for(int i = max_deg; i >= 1; --i){ + bin[i] = bin[i - 1]; + } + bin[0] = 1; + for(int i = 1; i <= N; ++i){ + int v = vert[i]; + core[v] = deg[v]; + for(int p = head[v]; p!=-1; p = edges[p].next){ + int u = edges[p].to; + if (deg[u] > deg[v]) { + int w = vert[bin[deg[u]]]; + if(u != w){ + std::swap(vert[pos[u]],vert[pos[w]]); + std::swap(pos[u],pos[w]); + } + ++bin[deg[u]]; + --deg[u]; + } + } + } + + py::array::ShapeContainer ret_shape{(int)core.size()}; + py::array_t ret(ret_shape, core.data()); + + return ret; +} + +#ifdef EASYGRAPH_ENABLE_GPU +py::object invoke_gpu_core_decomposition(py::object G) { + Graph& G_ = G.cast(); + auto csr_graph = G_.gen_CSR(); + std::vector& E = csr_graph->E; + std::vector& V = csr_graph->V; + std::vector KC; + int gpu_r = gpu_easygraph::k_core(V, E, KC); + + if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { + // the code below will throw an exception + py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); + } + + py::array::ShapeContainer ret_shape{(int)KC.size()}; + py::array_t ret(ret_shape, KC.data()); + + return ret; +} +#endif + +py::object core_decomposition(py::object G) { +#ifdef EASYGRAPH_ENABLE_GPU + return invoke_gpu_core_decomposition(G); +#else + return invoke_cpp_core_decomposition(G); +#endif +} diff --git a/cpp_easygraph/functions/cores/k_cores.h b/cpp_easygraph/functions/cores/k_cores.h new file mode 100644 index 0000000..8f05f92 --- /dev/null +++ b/cpp_easygraph/functions/cores/k_cores.h @@ -0,0 +1,5 @@ +#pragma once + +#include "../../common/common.h" + +py::object core_decomposition(py::object G); \ No newline at end of file diff --git a/cpp_easygraph/functions/pagerank/__init__.h b/cpp_easygraph/functions/pagerank/__init__.h new file mode 100644 index 0000000..181674b --- /dev/null +++ b/cpp_easygraph/functions/pagerank/__init__.h @@ -0,0 +1,5 @@ +#pragma once + +#include "../../common/common.h" + +py::object _pagerank(py::object G, double alpha, int max_iterator, double threshold, py::object weight); \ No newline at end of file diff --git a/cpp_easygraph/functions/pagerank/pagerank.cpp b/cpp_easygraph/functions/pagerank/pagerank.cpp new file mode 100644 index 0000000..dcc489a --- /dev/null +++ b/cpp_easygraph/functions/pagerank/pagerank.cpp @@ -0,0 +1,119 @@ +#include +#include +#include +#include +#include +#ifdef _OPENMP +#include +#endif + +#include "pagerank.h" +#include "../../classes/directed_graph.h" +#include "../../classes/graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" + +namespace py = pybind11; + +py::object _pagerank(py::object G, double alpha, int max_iterator, double threshold, py::object weight) { + + bool is_directed = G.attr("is_directed")().cast(); + std::string weight_key = weight_to_string(weight); + bool has_weight_key = !weight.is_none() && !weight_key.empty(); + + Graph_L* G_l_ptr = nullptr; + int N = 0; + if (is_directed) { + DiGraph& G_ = G.cast(); + N = G_.node.size(); + if (G_.linkgraph_dirty) { + G_.linkgraph_structure = graph_to_linkgraph(G_, true, weight_key, true, false); + G_.linkgraph_dirty = false; + } + G_l_ptr = &G_.linkgraph_structure; + } else { + Graph& G_ = G.cast(); + N = G_.node.size(); + if (G_.linkgraph_dirty) { + G_.linkgraph_structure = graph_to_linkgraph(G_, false, weight_key, true, false); + G_.linkgraph_dirty = false; + } + G_l_ptr = &G_.linkgraph_structure; + } + + const std::vector& E = G_l_ptr->edges; + const std::vector& outDegree = G_l_ptr->degree; + const std::vector& head = G_l_ptr->head; + + bool actually_weighted = false; + std::vector outWeightSum(N + 1, 0.0); + + if (has_weight_key) { + #pragma omp parallel for reduction(|:actually_weighted) + for (int i = 1; i <= N; ++i) { + double sum_w = 0.0; + for (int p = head[i]; p != -1; p = E[p].next) { + sum_w += E[p].w; + if (!actually_weighted && std::abs(E[p].w - 1.0) > 1e-9) { + actually_weighted = true; + } + } + outWeightSum[i] = sum_w; + } + } + bool use_weighted_logic = has_weight_key && actually_weighted; + + std::vector oldPR(N + 1, 1.0 / N); + std::vector newPR(N + 1, 0.0); + int cnt = 0; + + while (cnt < max_iterator) { + double dangling_sum = 0.0; + + #pragma omp parallel for reduction(+:dangling_sum) + for (int i = 1; i <= N; ++i) { + bool is_dangling = use_weighted_logic ? (outWeightSum[i] < 1e-15) : (outDegree[i] == 0); + if (is_dangling) dangling_sum += oldPR[i]; + } + + if (!use_weighted_logic) { + #pragma omp parallel for schedule(dynamic, 128) + for (int i = 1; i <= N; ++i) { + if (outDegree[i] == 0) continue; + double out_val = (oldPR[i] / outDegree[i]) * alpha; + for (int p = head[i]; p != -1; p = E[p].next) { + #pragma omp atomic + newPR[E[p].to] += out_val; + } + } + } else { + #pragma omp parallel for schedule(dynamic, 128) + for (int i = 1; i <= N; ++i) { + if (outWeightSum[i] < 1e-15) continue; + double out_val = (oldPR[i] / outWeightSum[i]) * alpha; + for (int p = head[i]; p != -1; p = E[p].next) { + #pragma omp atomic + newPR[E[p].to] += out_val * E[p].w; + } + } + } + + double diff_sum = 0.0; + double jump_val = (1.0 - alpha) / N + (dangling_sum / N) * alpha; + + #pragma omp parallel for reduction(+:diff_sum) + for (int i = 1; i <= N; ++i) { + double final_pr = newPR[i] + jump_val; + diff_sum += std::fabs(final_pr - oldPR[i]); + oldPR[i] = final_pr; + newPR[i] = 0.0; + } + + if (diff_sum < threshold * N) break; + cnt++; + } + + py::list res_lst; + for (int i = 1; i <= N; ++i) res_lst.append(oldPR[i]); + return res_lst; +} \ No newline at end of file diff --git a/cpp_easygraph/functions/pagerank/pagerank.h b/cpp_easygraph/functions/pagerank/pagerank.h new file mode 100644 index 0000000..181674b --- /dev/null +++ b/cpp_easygraph/functions/pagerank/pagerank.h @@ -0,0 +1,5 @@ +#pragma once + +#include "../../common/common.h" + +py::object _pagerank(py::object G, double alpha, int max_iterator, double threshold, py::object weight); \ No newline at end of file diff --git a/cpp_easygraph/functions/path/__init__.h b/cpp_easygraph/functions/path/__init__.h new file mode 100644 index 0000000..ad284b9 --- /dev/null +++ b/cpp_easygraph/functions/path/__init__.h @@ -0,0 +1,4 @@ +#pragma once + +#include "path.h" +#include "mst.h" \ No newline at end of file diff --git a/cpp_easygraph/functions/path/average_shortest_path_length.cpp b/cpp_easygraph/functions/path/average_shortest_path_length.cpp new file mode 100644 index 0000000..698155a --- /dev/null +++ b/cpp_easygraph/functions/path/average_shortest_path_length.cpp @@ -0,0 +1,148 @@ +#include "path.h" + +#ifdef _OPENMP +#include +#endif + +#include "../../classes/graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" + +#include +#include +#include +#include + +double _bfs_sum(const Graph_L& G_l, int source) { + int N = G_l.n; + std::vector dis(N + 1, -1); + std::queue q; + + dis[source] = 0; + q.push(source); + + double sum = 0.0; + int visited_count = 0; + + const std::vector& head = G_l.head; + const std::vector& E = G_l.edges; + + while (!q.empty()) { + int u = q.front(); + q.pop(); + sum += dis[u]; + visited_count++; + + for (int p = head[u]; p != -1; p = E[p].next) { + int v = E[p].to; + if (dis[v] == -1) { + dis[v] = dis[u] + 1; + q.push(v); + } + } + } + return (visited_count == N) ? sum : -1.0; +} + +double _dijkstra_sum(const Graph_L& G_l, int source) { + int N = G_l.n; + const double INF = std::numeric_limits::infinity(); + std::vector dis(N + 1, INF); + std::priority_queue, + std::vector>, + std::greater>> pq; + + dis[source] = 0.0; + pq.push({0.0, source}); + + double sum = 0.0; + int visited_count = 0; + + const std::vector& head = G_l.head; + const std::vector& E = G_l.edges; + + while (!pq.empty()) { + auto top_node = pq.top(); + double d = top_node.first; + int u = top_node.second; + pq.pop(); + + if (d > dis[u]) continue; + sum += d; + visited_count++; + + for (int p = head[u]; p != -1; p = E[p].next) { + int v = E[p].to; + double w = static_cast(E[p].w); + if (dis[u] + w < dis[v]) { + dis[v] = dis[u] + w; + pq.push({dis[v], v}); + } + } + } + return (visited_count == N) ? sum : -1.0; +} + + +py::object average_shortest_path_length(py::object G, py::object weight, py::object method) { + Graph& G_ = G.cast(); + bool is_directed = G.attr("is_directed")().cast(); + + std::string weight_key = ""; + if (!weight.is_none()) { + weight_key = weight.cast(); + } + + std::string method_str; + if (method.is_none()) { + method_str = weight.is_none() ? "single_source_bfs" : "dijkstra"; + } else { + method_str = method.cast(); + } + + if(G_.linkgraph_dirty){ + G_.linkgraph_structure = graph_to_linkgraph(G_, is_directed, weight_key, true, false); + G_.linkgraph_dirty = false; + } + const Graph_L& G_l = G_.linkgraph_structure; + + int N = G_l.n; + if (N <= 1) return py::float_(0.0); + + double total_sum = 0.0; + bool is_connected = true; + + { + py::gil_scoped_release release; + + #pragma omp parallel for reduction(+:total_sum) schedule(dynamic) + for (int i = 1; i <= N; i++) { + if (!is_connected) continue; + + double local_sum = 0.0; + if (method_str == "single_source_bfs" || method_str == "unweighted") { + local_sum = _bfs_sum(G_l, i); + } else { + local_sum = _dijkstra_sum(G_l, i); + } + + if (local_sum < 0) { + #pragma omp critical + is_connected = false; + } else { + total_sum += local_sum; + } + } + } + + if (!is_connected) { + if (is_directed) { + throw py::value_error("Graph is not strongly connected."); + } + else { + throw py::value_error("Graph is not connected."); + } + } + + return py::float_(total_sum / (static_cast(N) * (N - 1))); +} \ No newline at end of file diff --git a/cpp_easygraph/functions/path/diameter.cpp b/cpp_easygraph/functions/path/diameter.cpp new file mode 100644 index 0000000..0e38696 --- /dev/null +++ b/cpp_easygraph/functions/path/diameter.cpp @@ -0,0 +1,207 @@ +#include "path.h" + +#ifdef _OPENMP +#include +#endif + +#include "../../classes/graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" + +#include +#include +#include +#include +#include + +namespace py = pybind11; + +double _bfs_eccentricity(const Graph_L& G_l, int source) { + int N = G_l.n; + std::vector dis(N + 1, -1); + std::queue q; + + dis[source] = 0; + q.push(source); + + double max_dist = 0.0; + int visited_count = 0; + + const std::vector& head = G_l.head; + const std::vector& E = G_l.edges; + + while (!q.empty()) { + int u = q.front(); + q.pop(); + visited_count++; + + if (dis[u] > max_dist) { + max_dist = dis[u]; + } + + for (int p = head[u]; p != -1; p = E[p].next) { + int v = E[p].to; + if (dis[v] == -1) { + dis[v] = dis[u] + 1; + q.push(v); + } + } + } + + return (visited_count == N) ? max_dist : -1.0; +} + + +py::object eccentricity(py::object G, py::object v = py::none(), py::object sp = py::none()) { + Graph& G_ = G.cast(); + + bool is_directed = py::hasattr(G, "is_directed") ? G.attr("is_directed")().cast() : false; + + int order = py::len(G.attr("nodes")); + + if (!sp.is_none()) { + py::dict result; + py::dict sp_dict = sp.cast(); + + py::list nodes_to_check; + bool is_single = false; + + if (v.is_none()) { + nodes_to_check = py::list(G.attr("nodes")); + } else if (py::isinstance(v) || py::isinstance(v) || py::isinstance(v)) { + nodes_to_check = py::list(v); + } else { + nodes_to_check.append(v); + is_single = true; + } + + for (py::handle n : nodes_to_check) { + if (!sp_dict.contains(n)) { + throw py::type_error("Format of 'sp' is invalid."); + } + py::dict length_dict = sp_dict[n].cast(); + if (py::len(length_dict) != order) { + std::string msg = is_directed ? + "Found infinite path length because the digraph is not strongly connected" : + "Found infinite path length because the graph is not connected"; + throw py::value_error(msg); + } + + double max_val = 0.0; + for (auto item : length_dict) { + double val = item.second.cast(); + if (val > max_val) max_val = val; + } + result[n] = max_val; + } + + if (is_single) return result[v]; + return result; + } + + if (G_.linkgraph_dirty) { + G_.linkgraph_structure = graph_to_linkgraph(G_, is_directed, "", true, false); + G_.linkgraph_dirty = false; + } + const Graph_L& G_l = G_.linkgraph_structure; + + int N = G_l.n; + if (N == 0) return py::dict(); + + py::dict node_index; + std::vector index_to_node_vec; + + if (py::hasattr(G, "node_index")) { + node_index = G.attr("node_index").cast(); + } else { + int idx = 0; + for (py::handle n : G.attr("nodes")) { + node_index[n] = py::cast(idx++); + } + } + + if (py::hasattr(G, "index_node")) { + py::object idx_n = G.attr("index_node"); + if (py::isinstance(idx_n)) { + py::list l = idx_n.cast(); + for (auto item : l) index_to_node_vec.push_back(item.cast()); + } else if (py::isinstance(idx_n)) { + py::dict d = idx_n.cast(); + index_to_node_vec.resize(py::len(d)); + for (auto item : d) { + index_to_node_vec[item.first.cast()] = item.second.cast(); + } + } + } else { + index_to_node_vec.resize(py::len(node_index)); + for (auto item : node_index) { + index_to_node_vec[item.second.cast()] = item.first.cast(); + } + } + + std::vector target_ids; + bool is_single_node = false; + + if (v.is_none()) { + target_ids.resize(N); + for (int i = 0; i < N; ++i) target_ids[i] = i + 1; + } else if (py::isinstance(v) || py::isinstance(v) || py::isinstance(v)) { + py::iterable v_iterable = v.cast(); + for (py::handle node : v_iterable) { + if (node_index.contains(node)) { + target_ids.push_back(node_index[node].cast() + 1); + } + } + } else { + if (node_index.contains(v)) { + is_single_node = true; + target_ids.push_back(node_index[v].cast() + 1); + } else { + throw py::value_error("Node not found in graph."); + } + } + + int num_targets = target_ids.size(); + if (num_targets == 0) return py::dict(); + + std::vector ecc_values(num_targets, -1.0); + bool is_connected = true; + + { + py::gil_scoped_release release; + + #pragma omp parallel for schedule(dynamic) + for (int i = 0; i < num_targets; i++) { + if (!is_connected) continue; + int u = target_ids[i]; + + double ecc_val = _bfs_eccentricity(G_l, u); + + if (ecc_val < 0) { + #pragma omp critical + is_connected = false; + } else { + ecc_values[i] = ecc_val; + } + } + } + + if (!is_connected) { + std::string msg = is_directed ? + "Found infinite path length because the digraph is not strongly connected" : + "Found infinite path length because the graph is not connected"; + throw py::value_error(msg); + } + + if (is_single_node) { + return py::cast(ecc_values[0]); + } else { + py::dict result; + for (int i = 0; i < num_targets; i++) { + int internal_id = target_ids[i]; + py::object original_node = index_to_node_vec[internal_id - 1]; + result[original_node] = ecc_values[i]; + } + return result; + } +} \ No newline at end of file diff --git a/cpp_easygraph/functions/path/mst.cpp b/cpp_easygraph/functions/path/mst.cpp new file mode 100644 index 0000000..704ed34 --- /dev/null +++ b/cpp_easygraph/functions/path/mst.cpp @@ -0,0 +1,314 @@ +#include "mst.h" +#ifdef _OPENMP +#include +#endif +#include + +#include "../../classes/graph.h" +#include "../../common/utils.h" + +UnionFind::UnionFind() {} + +UnionFind::UnionFind(std::vector elements) { + for (node_t x : elements) { + parents[x] = x; + weights[x] = 1; + } +} +node_t UnionFind::operator[](node_t object) { + if (!parents.count(object)) { + parents[object] = object; + weights[object] = 1; + return object; + } + + std::vector path; + path.push_back(object); + node_t root = parents[object]; + while (root != path.back()) { + path.push_back(root); + root = parents[root]; + } + for (node_t ancestor : path) { + parents[ancestor] = root; + } + return root; +} + +void UnionFind::_union(node_t object1, node_t object2) { + node_t root, r; + object1 = (*this)[object1]; + object2 = (*this)[object2]; + if (weights[object1] < weights[object2]) { + root = object1, r = object2; + } else { + root = object2, r = object1; + } + weights[root] += weights[r]; + parents[r] = root; +} + +struct mst_Edge { + double wt; + node_t start_node, end_node; + edge_attr_dict_factory edge_attr; + mst_Edge(double wt, node_t start_node, node_t end_node, edge_attr_dict_factory edge_attr) { + this->wt = wt; + this->start_node = start_node; + this->end_node = end_node; + this->edge_attr = edge_attr; + } +}; + +py::object kruskal_mst_edges(py::object G, py::object minimum, py::object weight, py::object data, py::object ignore_nan) { + UnionFind subtrees; + Graph G_ = G.cast(); + std::string weight_key = weight_to_string(weight); + std::vector> edges; + int sign = minimum.cast().equal(py::cast(true)) ? 1 : -1; + for (graph_edge& edge : G_._get_edges()) { + weight_t wt = (edge.attr.count(weight_key) ? edge.attr[weight_key] : 1) * sign; + if (!ignore_nan.cast() && isnan(wt)) { + PyErr_Format(PyExc_ValueError, "NaN found as an edge weight. Edge (%R, %R, %R)", G_.id_to_node[py::cast(edge.u)].ptr(), G_.id_to_node[py::cast(edge.v)].ptr(), attr_to_dict(edge.attr).ptr()); + return py::none(); + } + edges.emplace_back(wt, edge); + } + std::sort(edges.begin(), edges.end(), [](const std::pair& edge1, const std::pair& edge2) -> bool { + return edge1.first < edge2.first; + }); + py::list ret; + for (const auto& edge : edges) { + node_t u = edge.second.u, v = edge.second.v; + if (subtrees[u] != subtrees[v]) { + if (data.cast()) { + ret.append(py::make_tuple(G_.id_to_node[py::cast(u)], G_.id_to_node[py::cast(v)], attr_to_dict(edge.second.attr))); + } else { + ret.append(py::make_tuple(G_.id_to_node[py::cast(u)], G_.id_to_node[py::cast(v)])); + } + subtrees._union(u, v); + } + } + return ret; +}; + +struct cmp { + bool operator()(const mst_Edge& node1, const mst_Edge& node2) { + return node1.wt > node2.wt; + } +}; + +py::object prim_mst_edges(py::object G, py::object minimum, py::object weight, py::object data, py::object ignore_nan) { + Graph& G_ = G.cast(); + py::list res = py::list(); + node_dict_factory nodes_list = G_.node; + std::unordered_set nodes; + for (node_dict_factory::iterator iter = nodes_list.begin(); iter != nodes_list.end(); iter++) { + node_t node_id = iter->first; + nodes.emplace(node_id); + } + int sign = 1; + if (!minimum.cast().equal(py::cast(true))) { + sign = -1; + } + while (!nodes.empty()) { + const node_t u = *(nodes.begin()); + nodes.erase(nodes.begin()); + std::priority_queue, cmp> frontier; + std::unordered_map visited; + node_t u_ = u; + visited.emplace(u_, true); + adj_attr_dict_factory u_neighbors = G_.adj[u]; + for (adj_attr_dict_factory::iterator i = u_neighbors.begin(); i != u_neighbors.end(); i++) { + node_t v = i->first; + edge_attr_dict_factory d = i->second; + double wt = sign; + if (d.find(py::cast(weight)) != d.end()) { + wt = d[py::cast(weight)] * sign; + } + if (isnan(wt)) { + if (ignore_nan.cast()) { + continue; + } + PyErr_Format(PyExc_ValueError, "NaN found as an edge weight. Edge {(%R %R %R)}", (G_.id_to_node.attr("get")(u)).ptr(), G_.id_to_node.attr("get")(v).ptr(), attr_to_dict(d).ptr()); + return py::none(); + } + frontier.push(mst_Edge(wt, u_, v, d)); + } + while (!frontier.empty()) { + mst_Edge node = frontier.top(); + frontier.pop(); + double W = node.wt; + node_t u_id = node.start_node; + node_t v_id = node.end_node; + edge_attr_dict_factory d = node.edge_attr; + if (visited.find(v_id) != visited.end() || nodes.find(v_id) == nodes.end()) { + continue; + } + if (data.cast()) { + res.append(py::make_tuple(G_.id_to_node.attr("get")(u_id), G_.id_to_node.attr("get")(v_id), attr_to_dict(d))); + } else { + res.append(py::make_tuple(G_.id_to_node.attr("get")(u_id), G_.id_to_node.attr("get")(v_id))); + } + visited.emplace(v_id, true); + nodes.erase(v_id); + adj_attr_dict_factory v_neighbors = G_.adj[v_id]; + for (adj_attr_dict_factory::iterator j = v_neighbors.begin(); j != v_neighbors.end(); j++) { + node_t w = j->first; + edge_attr_dict_factory d2 = j->second; + if (visited.find(w) != visited.end()) { + continue; + } + double new_weight = sign; + if (d2.find(py::cast(weight)) != d2.end()) { + new_weight = d2[py::cast(weight)] * sign; + } + frontier.push(mst_Edge(new_weight, v_id, w, d2)); + } + } + } + return res; +} + +struct CompactEdge { + int u, v, id; + double wt; +}; + +struct AtomicBest { + std::atomic edge_idx; + AtomicBest() : edge_idx(-1) {} + AtomicBest(const AtomicBest& other) : edge_idx(other.edge_idx.load()) {} +}; + +struct IntUnionFind { + std::vector parent; + std::vector rank; + int component_count; + + IntUnionFind(int n) : parent(n), rank(n, 0), component_count(n) { + for (int i = 0; i < n; i++) parent[i] = i; + } + + int find(int i) { + if (parent[i] == i) return i; + return parent[i] = find(parent[i]); + } + + int find_readonly(int i) const { + while (i != parent[i]) { + i = parent[i]; + } + return i; + } + + bool unite(int i, int j) { + int root_i = find(i); + int root_j = find(j); + if (root_i != root_j) { + if (rank[root_i] < rank[root_j]) parent[root_i] = root_j; + else if (rank[root_i] > rank[root_j]) parent[root_j] = root_i; + else { parent[root_i] = root_j; rank[root_j]++; } + component_count--; + return true; + } + return false; + } +}; + +py::object boruvka_mst_edges(py::object G, py::object minimum, py::object weight, py::object data, py::object ignore_nan) { + Graph& G_ = G.cast(); + std::string weight_key = weight_to_string(weight); + int sign = minimum.cast() ? 1 : -1; + bool return_data = data.cast(); + bool ignore_n = ignore_nan.cast(); + + std::shared_ptr coo = G_.gen_COO(weight_key); + int num_nodes = coo->nodes.size(); + int num_edges = coo->row.size(); + + if (num_nodes == 0) return py::list(); + + std::vector active_edges; + active_edges.reserve(num_edges); + + const auto& W_vec = *(coo->W_map[weight_key]); + + for (int i = 0; i < num_edges; ++i) { + double wt = W_vec[i] * sign; + if (std::isnan(wt)) { + if (!ignore_n) { + PyErr_Format(PyExc_ValueError, "NaN found as an edge weight."); + return py::none(); + } + continue; + } + active_edges.push_back({coo->row[i], coo->col[i], i, wt}); + } + + IntUnionFind uf(num_nodes); + std::vector in_mst(num_edges, false); + { + py::gil_scoped_release release; + + while (uf.component_count > 1) { + std::vector best_at(num_nodes); + bool changed = false; + + #pragma omp parallel for + for (int i = 0; i < (int)active_edges.size(); ++i) { + int root_u = uf.find_readonly(active_edges[i].u); + int root_v = uf.find_readonly(active_edges[i].v); + + if (root_u != root_v) { + auto update_best = [&](int root, int edge_idx) { + int current = best_at[root].edge_idx.load(std::memory_order_relaxed); + while (current == -1 || active_edges[edge_idx].wt < active_edges[current].wt) { + if (best_at[root].edge_idx.compare_exchange_weak(current, edge_idx)) break; + } + }; + update_best(root_u, i); + update_best(root_v, i); + } + } + + for (int i = 0; i < num_nodes; ++i) { + int e_idx = best_at[i].edge_idx.load(); + if (e_idx != -1) { + const auto& e = active_edges[e_idx]; + if (uf.unite(e.u, e.v)) { + in_mst[e.id] = true; + changed = true; + } + } + } + + if (!changed) break; + + auto new_end = std::remove_if(active_edges.begin(), active_edges.end(), [&](const CompactEdge& e) { + return uf.find(e.u) == uf.find(e.v); + }); + active_edges.erase(new_end, active_edges.end()); + } + } + + py::list ret; + for (int i = 0; i < num_edges; ++i) { + if (in_mst[i]) { + node_t u = coo->nodes[coo->row[i]]; + node_t v = coo->nodes[coo->col[i]]; + + py::object u_obj = G_.id_to_node[py::cast(u)]; + py::object v_obj = G_.id_to_node[py::cast(v)]; + + if (return_data) { + const auto& edge_attr = G_.adj.at(u).at(v); + ret.append(py::make_tuple(u_obj, v_obj, attr_to_dict(edge_attr))); + } else { + ret.append(py::make_tuple(u_obj, v_obj)); + } + } + } + + return ret; +} \ No newline at end of file diff --git a/cpp_easygraph/functions/path/mst.h b/cpp_easygraph/functions/path/mst.h new file mode 100644 index 0000000..da40e01 --- /dev/null +++ b/cpp_easygraph/functions/path/mst.h @@ -0,0 +1,19 @@ +#pragma once + +#include "../../common/common.h" + +class UnionFind { + public: + UnionFind(); + UnionFind(std::vector elements); + node_t operator[](node_t object); + void _union(node_t object1, node_t object2); + + private: + std::unordered_map parents; + std::unordered_map weights; +}; + +py::object kruskal_mst_edges(py::object G, py::object minimum, py::object weight, py::object data, py::object ignore_nan); +py::object prim_mst_edges(py::object G, py::object minimum, py::object weight, py::object data, py::object ignore_nan); +py::object boruvka_mst_edges(py::object G, py::object minimum, py::object weight, py::object data, py::object ignore_nan); \ No newline at end of file diff --git a/cpp_easygraph/functions/path/path.cpp b/cpp_easygraph/functions/path/path.cpp new file mode 100644 index 0000000..e57cb56 --- /dev/null +++ b/cpp_easygraph/functions/path/path.cpp @@ -0,0 +1,376 @@ +#include "path.h" + +#ifdef _OPENMP +#include +#endif +#ifdef EASYGRAPH_ENABLE_GPU +#include +#endif + +#include "../../classes/graph.h" +#include "../../common/utils.h" +#include "../../classes/linkgraph.h" +#include "../../classes/segment_tree.cpp" + + +#include +#include +#include +#include + + +std::vector _dijkstra(const Graph_L& G_l, int source, int target) { + int N = G_l.n; + const double INF = std::numeric_limits::infinity(); + std::vector dis(N + 1, INF); + std::priority_queue, + std::vector>, + std::greater>> pq; + + dis[source] = 0.0; + pq.push({0.0, source}); + + const std::vector& head = G_l.head; + const std::vector& E = G_l.edges; + + while (!pq.empty()) { + std::pair top = pq.top(); + pq.pop(); + + double d = top.first; + int u = top.second; + + //Lazy deletion + if (d > dis[u]) continue; + + // cutoff + if (u == target) break; + + for (int p = head[u]; p != -1; p = E[p].next) { + int v = E[p].to; + double w = static_cast(E[p].w); + + if (dis[u] + w < dis[v]) { + dis[v] = dis[u] + w; + pq.push({dis[v], v}); + } + } + } + return dis; +} + + +py::object _invoke_cpp_dijkstra_multisource(py::object G, py::object sources, py::object weight, py::object target) { + bool is_directed = G.attr("is_directed")().cast(); + Graph& G_ = G.cast(); + std::string weight_key = weight_to_string(weight); + + Graph_L G_l; + if(G_.linkgraph_dirty){ + G_l = graph_to_linkgraph(G_, is_directed, weight_key, true, false); + G_.linkgraph_structure = G_l; + G_.linkgraph_dirty = false; + } else { + G_l = G_.linkgraph_structure; + } + + node_t target_id = -1; + if (!target.is_none()) { + target_id = G_.node_to_id.attr("get")(target, -1).cast(); + } + + py::list sources_list = py::list(sources); + int num_sources = py::len(sources_list); + int N = G_l.n; + + std::vector source_ids(num_sources); + for(int i = 0; i < num_sources; i++){ + if(G_.node_to_id.attr("get")(sources_list[i], py::none()).is(py::none())){ + printf("The node should exist in the graph!"); + return py::none(); + } + source_ids[i] = G_.node_to_id.attr("get")(sources_list[i]).cast(); + } + std::vector results(num_sources * N); + { + py::gil_scoped_release release; + + #pragma omp parallel for schedule(dynamic) + for (int i = 0; i < num_sources; i++) { + node_t s = source_ids[i]; + + std::vector dists = _dijkstra(G_l, s, target_id); + + size_t offset = (size_t)i * N; + for (int j = 1; j <= N; j++) { + results[offset + (j - 1)] = dists[j]; + } + } + } + + py::array::ShapeContainer ret_shape{num_sources, N}; + py::array_t ret(ret_shape, results.data()); + + return ret; +} + +#ifdef EASYGRAPH_ENABLE_GPU +py::object _invoke_gpu_dijkstra_multisource(py::object G,py::object py_sources, py::object weight, py::object target) { + Graph& G_ = G.cast(); + if (weight.is_none()) { + G_.gen_CSR(); + } else { + G_.gen_CSR(weight_to_string(weight)); + } + auto csr_graph = G_.csr_graph; + std::vector& E = csr_graph->E; + std::vector& V = csr_graph->V; + std::vector *W_p = weight.is_none() ? &(csr_graph->unweighted_W) + : csr_graph->W_map.find(weight_to_string(weight))->second.get(); + auto sources = G_.gen_CSR_sources(py_sources); + std::vector sssp; + int gpu_r = gpu_easygraph::sssp_dijkstra(V, E, *W_p, *sources, + target.is_none() ? -1 : (int)py::cast(target), + sssp); + + if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { + // the code below will throw an exception + py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); + } + + py::array::ShapeContainer ret_shape{(int)sources->size(), (int)V.size() - 1}; + py::array_t ret(ret_shape, sssp.data()); + + return ret; +} +#endif + +py::object _dijkstra_multisource(py::object G,py::object sources, py::object weight, py::object target) { +#ifdef EASYGRAPH_ENABLE_GPU + return _invoke_gpu_dijkstra_multisource(G, sources, weight, target); +#else + return _invoke_cpp_dijkstra_multisource(G, sources, weight, target); +#endif +} + + +py::object _spfa(py::object G, py::object source, py::object weight) { + Graph& G_ = G.cast(); + bool is_directed = G.attr("is_directed")().cast(); + std::string weight_key = weight_to_string(weight); + Graph_L G_l = graph_to_linkgraph(G_, is_directed,weight_key, false); + int N = G_.node.size(); + + std::vector Q(N+10,0); + std::vector dis(N+1,INFINITY); + std::vector vis(N+1,false); + + int l = 0, r = 1; + node_t S = G_.node_to_id[source].cast(); + Q[0] = S; vis[S] = true; dis[S] = 0; + std::vector& E = G_l.edges; + + std::vector& head = G_l.head; + while (l != r) { + if (r != 0 && dis[Q[l]] >= dis[Q[r - 1]]) + std::swap(Q[l], Q[r - 1]); + int u = Q[l++]; + if (l >= N) l -= N; + vis[u] = true; + + for(int p = head[u]; p != -1; p = E[p].next) { + int v=E[p].to; + if (dis[v]>dis[u]+E[p].w) { + dis[v]=dis[u]+E[p].w; + if (!vis[v]) { + vis[v]=true; + if (l == 0 || dis[v] >= dis[Q[l]]) + Q[r++]=v; + else + Q[--l]=v; + if (r >= N) r -= N; + } + } + } + } + py::list pydist = py::list(); + for(int i = 1; i <= N; i++){ + pydist.append(py::cast(dis[i])); + } + return pydist; +} + + +py::object Prim(py::object G, py::object weight) { + std::unordered_map> res_dict; + py::dict result_dict = py::dict(); + Graph& G_ = G.cast(); + adj_dict_factory adj = G_.adj; + std::vector selected; + std::vector candidate; + node_dict_factory& node_list = G_.node; + std::string weight_key = weight_to_string(weight); + for (node_dict_factory::iterator i = node_list.begin(); i != node_list.end(); i++) { + node_t node_id = i->first; + result_dict[G_.id_to_node[py::cast(node_id)]] = py::dict(); + if (selected.size() == 0) { + selected.emplace_back(node_id); + } else { + candidate.emplace_back(node_id); + } + } + while (candidate.size() > 0) { + node_t start_id = -1; + node_t end_id = -1; + weight_t min_weight = INFINITY; + int selected_len = selected.size(); + int candidate_len = candidate.size(); + for (int i = 0; i < selected_len; i++) { + for (int j = 0; j < candidate_len; j++) { + adj_attr_dict_factory node_adj = G_.adj[selected[i]]; + edge_attr_dict_factory edge_attr; + weight_t edge_weight = INFINITY; + bool j_exist = false; + if (node_adj.find(candidate[j]) != node_adj.end()) { + edge_attr = node_adj[candidate[j]]; + edge_weight = edge_attr.find(weight_key) != edge_attr.end() ? edge_attr[weight_key] : 1; + j_exist = true; + } + if ((node_list.find(selected[i]) != node_list.end()) && + j_exist && + (edge_weight < min_weight)) { + start_id = selected[i]; + end_id = candidate[j]; + min_weight = edge_weight; + } + } + } + if (start_id != -1 && end_id != -1) { + res_dict[start_id][end_id] = min_weight; + selected.emplace_back(end_id); + std::vector::iterator temp_iter; + temp_iter = std::find(candidate.begin(), candidate.end(), end_id); + candidate.erase(temp_iter); + } else { + break; + } + } + for (std::unordered_map>::iterator k = res_dict.begin(); + k != res_dict.end(); k++) { + py::object res_node = G_.id_to_node[py::cast(k->first)]; + for (std::unordered_map::iterator z = k->second.begin(); z != k->second.end(); z++) { + py::object res_adj_node = G_.id_to_node[py::cast(z->first)]; + result_dict[res_node][res_adj_node] = z->second; + } + } + return result_dict; +} +bool comp(const std::pair, weight_t>& a, const std::pair, weight_t>& b) { + return a.second < b.second; +} +py::object Kruskal(py::object G, py::object weight) { + std::unordered_map> res_dict; + py::dict result_dict = py::dict(); + std::vector> group; + Graph& G_ = G.cast(); + adj_dict_factory& adj = G_.adj; + node_dict_factory& node_list = G_.node; + std::vector, weight_t>> edge_list; + std::string weight_key = weight_to_string(weight); + for (node_dict_factory::iterator i = node_list.begin(); i != node_list.end(); i++) { + node_t i_id = i->first; + result_dict[G_.id_to_node[py::cast(i_id)]] = py::dict(); + std::vector temp_vector; + temp_vector.emplace_back(i_id); + group.emplace_back(temp_vector); + adj_attr_dict_factory i_adj = adj[i_id]; + for (adj_attr_dict_factory::iterator j = i_adj.begin(); j != i_adj.end(); j++) { + node_t j_id = j->first; + weight_t edge_weight = adj[i_id][j_id].find(weight_key) != adj[i_id][j_id].end() ? adj[i_id][j_id][weight_key] : 1; + edge_list.emplace_back(std::make_pair(std::make_pair(i_id, j_id), edge_weight)); + } + } + std::sort(edge_list.begin(), edge_list.end(), comp); + node_t m, n; + int group_size = group.size(); + for (auto edge : edge_list) { + for (int i = 0; i < group_size; i++) { + int group_i_size = group[i].size(); + for (int j = 0; j < group_i_size; j++) { + if (group[i][j] == edge.first.first) { + m = i; + break; + } + } + for (int j = 0; j < group_i_size; j++) { + if (group[i][j] == edge.first.second) { + n = i; + break; + } + } + } + if (m != n) { + res_dict[edge.first.first][edge.first.second] = edge.second; + std::vector temp_vector; + group[m].insert(group[m].end(), group[n].begin(), group[n].end()); + group[n].clear(); + } + } + for (std::unordered_map>::iterator k = res_dict.begin(); + k != res_dict.end(); k++) { + py::object res_node = G_.id_to_node[py::cast(k->first)]; + for (std::unordered_map::iterator z = k->second.begin(); z != k->second.end(); z++) { + py::object res_adj_node = G_.id_to_node[py::cast(z->first)]; + result_dict[res_node][res_adj_node] = z->second; + } + } + return result_dict; +} + +py::object Floyd(py::object G, py::object weight) { + std::unordered_map> res_dict; + Graph& G_ = G.cast(); + adj_dict_factory& adj = G_.adj; + py::dict result_dict = py::dict(); + node_dict_factory& node_list = G_.node; + std::string weight_key = weight_to_string(weight); + for (node_dict_factory::iterator i = node_list.begin(); i != node_list.end(); i++) { + result_dict[G_.id_to_node[py::cast(i->first)]] = py::dict(); + adj_attr_dict_factory temp_key = adj[i->first]; + for (node_dict_factory::iterator j = node_list.begin(); j != node_list.end(); j++) { + if (temp_key.find(j->first) != temp_key.end()) { + if (adj[i->first][j->first].count(weight_key) == 0) { + adj[i->first][j->first][weight_key] = 1; + } + res_dict[i->first][j->first] = adj[i->first][j->first][weight_key]; + } else { + res_dict[i->first][j->first] = INFINITY; + } + if (i->first == j->first) { + res_dict[i->first][i->first] = 0; + } + } + } + + for (node_dict_factory::iterator k = node_list.begin(); k != node_list.end(); k++) { + for (node_dict_factory::iterator i = node_list.begin(); i != node_list.end(); i++) { + for (node_dict_factory::iterator j = node_list.begin(); j != node_list.end(); j++) { + weight_t temp = res_dict[i->first][k->first] + res_dict[k->first][j->first]; + weight_t i_j_weight = res_dict[i->first][j->first]; + if (i_j_weight > temp) { + res_dict[i->first][j->first] = temp; + } + } + } + } + + for (std::unordered_map>::iterator k = res_dict.begin(); + k != res_dict.end(); k++) { + py::object res_node = G_.id_to_node[py::cast(k->first)]; + for (std::unordered_map::iterator z = k->second.begin(); z != k->second.end(); z++) { + py::object res_adj_node = G_.id_to_node[py::cast(z->first)]; + result_dict[res_node][res_adj_node] = z->second; + } + } + return result_dict; +} diff --git a/cpp_easygraph/functions/path/path.h b/cpp_easygraph/functions/path/path.h new file mode 100644 index 0000000..420a6b4 --- /dev/null +++ b/cpp_easygraph/functions/path/path.h @@ -0,0 +1,12 @@ +#pragma once + +#include "../../common/common.h" + +py::object _dijkstra_multisource(py::object G, py::object sources, py::object weight, py::object target); +// py::object _dijkstra(py::object G, py::object sources, py::object weight, py::object target); +py::object _spfa(py::object G, py::object source, py::object weight); +py::object Floyd(py::object G,py::object weight); +py::object Prim(py::object G,py::object weight); +py::object Kruskal(py::object G,py::object weight); +py::object average_shortest_path_length(py::object G, py::object weight, py::object method); +py::object eccentricity(py::object G, py::object v, py::object sp); \ No newline at end of file diff --git a/cpp_easygraph/functions/structural_holes/__init__.h b/cpp_easygraph/functions/structural_holes/__init__.h new file mode 100644 index 0000000..5c0b6ba --- /dev/null +++ b/cpp_easygraph/functions/structural_holes/__init__.h @@ -0,0 +1,3 @@ +#pragma once + +#include "evaluation.h" \ No newline at end of file diff --git a/cpp_easygraph/functions/structural_holes/evaluation.cpp b/cpp_easygraph/functions/structural_holes/evaluation.cpp new file mode 100644 index 0000000..82262cd --- /dev/null +++ b/cpp_easygraph/functions/structural_holes/evaluation.cpp @@ -0,0 +1,827 @@ +#include "evaluation.h" +#include +#include +#include +#include +#include +#include +#include + +#ifdef _OPENMP +#include +#endif +#ifdef EASYGRAPH_ENABLE_GPU +#include +#endif + +#include "../../classes/graph.h" +#include "../../classes/directed_graph.h" +#include "../../common/utils.h" + +struct pair_hash { + template + std::size_t operator()(const std::pair& p) const { + auto h1 = std::hash()(p.first); + auto h2 = std::hash()(p.second); + return h1 ^ h2; + } +}; + +typedef std::unordered_map, weight_t, pair_hash> rec_type; + +enum norm_t { + sum, + max +}; + +void preprocess_graph_for_constraint( + Graph& G, + std::string weight_key, + std::unordered_map>& weighted_adj, + std::unordered_map& strength +) { + for (auto& u_entry : G.adj) { + node_t u = u_entry.first; + for (auto& v_entry : u_entry.second) { + node_t v = v_entry.first; + double w = 1.0; + if (!weight_key.empty() && v_entry.second.count(weight_key)) { + w = v_entry.second[weight_key]; + } + weighted_adj[u][v] += w; + strength[u] += w; + weighted_adj[v][u] += w; + strength[v] += w; + } + } +} + +py::object invoke_cpp_constraint(py::object G, py::object nodes, py::object weight) { + std::string weight_key = weight_to_string(weight); + + if (nodes.is_none()) { + nodes = G.attr("nodes"); + } + py::list nodes_list = py::list(nodes); + int nodes_list_len = py::len(nodes_list); + + Graph& G_ref = G.cast(); + std::vector node_ids(nodes_list_len); + for (int i = 0; i < nodes_list_len; i++) { + node_ids[i] = G_ref.node_to_id[nodes_list[i]].cast(); + } + + std::unordered_map> weighted_adj; + std::unordered_map strength; + preprocess_graph_for_constraint(G_ref, weight_key, weighted_adj, strength); + + std::vector constraint_results(nodes_list_len, 0.0); + + { + py::gil_scoped_release release; + #pragma omp parallel for schedule(dynamic) + for (int i = 0; i < nodes_list_len; i++) { + node_t u = node_ids[i]; + + auto str_it = strength.find(u); + if (str_it == strength.end() || str_it->second == 0.0) { + constraint_results[i] = Py_NAN; + continue; + } + double u_strength = str_it->second; + + auto& neighbors_u = weighted_adj[u]; + if (neighbors_u.empty()) { + constraint_results[i] = Py_NAN; + continue; + } + + std::unordered_map contrib; + + for (auto& neighbor : neighbors_u) { + node_t j = neighbor.first; + double w_uj = neighbor.second; + double p_uj = w_uj / u_strength; + + contrib[j] += p_uj; + } + + for (auto& neighbor_j : neighbors_u) { + node_t j = neighbor_j.first; + double w_uj = neighbor_j.second; + double p_uj = w_uj / u_strength; + + auto q_it = weighted_adj.find(j); + if (q_it != weighted_adj.end()) { + double j_strength = strength[j]; + for (auto& neighbor_q : q_it->second) { + node_t q = neighbor_q.first; + if (q == u) continue; + + double w_jq = neighbor_q.second; + double p_jq = w_jq / j_strength; + + contrib[q] += p_uj * p_jq; + } + } + } + + double c_u = 0.0; + for (auto& neighbor : neighbors_u) { + node_t j = neighbor.first; + if (contrib.count(j)) { + c_u += pow(contrib[j], 2); + } + } + constraint_results[i] = c_u; + } + } + + py::array::ShapeContainer ret_shape{nodes_list_len}; + py::array_t ret(ret_shape, constraint_results.data()); + return ret; +} + +#ifdef EASYGRAPH_ENABLE_GPU +static py::object invoke_gpu_constraint(py::object G, py::object nodes, py::object weight) { + Graph& G_ = G.cast(); + if (weight.is_none()) { + G_.gen_CSR(); + } else { + G_.gen_CSR(weight_to_string(weight)); + } + auto csr_graph = G_.csr_graph; + auto coo_graph = G_.transfer_csr_to_coo(csr_graph); + std::vector& V = csr_graph->V; + std::vector& E = csr_graph->E; + std::vector& row = coo_graph->row; + std::vector& col = coo_graph->col; + std::vector *W_p = weight.is_none() ? &(coo_graph->unweighted_W) + : coo_graph->W_map.find(weight_to_string(weight))->second.get(); + std::unordered_map& node2idx = coo_graph->node2idx; + int num_nodes = coo_graph->node2idx.size(); + bool is_directed = G.attr("is_directed")().cast(); + std::vector constraint_results(num_nodes, 0.0); + + std::vector node_mask(num_nodes, 0); + py::list nodes_list; + if (!nodes.is_none()) { + nodes_list = py::list(nodes); + for (auto node : nodes_list) { + int node_id = node2idx[G_.node_to_id[node].cast()]; + node_mask[node_id] = 1; + } + } else { + nodes_list = py::list(G.attr("nodes")); + std::fill(node_mask.begin(), node_mask.end(), 1); + } + + int gpu_r = gpu_easygraph::constraint(V, E, row, col, num_nodes, *W_p, is_directed, node_mask, constraint_results); + if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { + py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); + } + + py::array::ShapeContainer ret_shape{(int)constraint_results.size()}; + py::array_t ret(ret_shape, constraint_results.data()); + + return ret; +} +#endif + +py::object constraint(py::object G, py::object nodes, py::object weight, py::object n_workers) { +#ifdef EASYGRAPH_ENABLE_GPU + return invoke_gpu_constraint(G, nodes, weight); +#else + return invoke_cpp_constraint(G, nodes, weight); +#endif +} + +template +inline weight_t get_edge_weight(const MapType& attrs, const std::string& weight_key) { + if (weight_key.empty()) return 1.0; + auto it = attrs.find(weight_key); + return it != attrs.end() ? it->second : 1.0; +} + +inline weight_t compute_mutual_weight(const Graph& G, node_t u, node_t v, const std::string& weight_key) { + weight_t w = 0; + if (G.adj.count(u)) { + const auto& adj_u = G.adj.at(u); + auto it = adj_u.find(v); + if (it != adj_u.end()) w += get_edge_weight(it->second, weight_key); + } + if (G.adj.count(v)) { + const auto& adj_v = G.adj.at(v); + auto it = adj_v.find(u); + if (it != adj_v.end()) w += get_edge_weight(it->second, weight_key); + } + return w; +} + +inline weight_t compute_directed_mutual_weight(const DiGraph& G, node_t u, node_t v, const std::string& weight_key) { + weight_t w = 0; + if (G.adj.count(u)) { + const auto& adj_u = G.adj.at(u); + auto it = adj_u.find(v); + if (it != adj_u.end()) w += get_edge_weight(it->second, weight_key); + } + if (G.adj.count(v)) { + const auto& adj_v = G.adj.at(v); + auto it = adj_v.find(u); + if (it != adj_v.end()) w += get_edge_weight(it->second, weight_key); + } + return w; +} + +std::vector compute_redundancy_core(py::object G_obj, const std::vector& target_nodes, const std::string& weight_key, bool is_directed) { + + // Cast to C++ objects once to avoid Python API overhead + const Graph* G_ptr = nullptr; + const DiGraph* DiG_ptr = nullptr; + if (is_directed) { + DiG_ptr = &G_obj.cast(); + } else { + G_ptr = &G_obj.cast(); + } + + // Pre-compute max ID and node list + node_t max_graph_id = 0; + std::vector all_nodes_vec; + + if (is_directed) { + for (const auto& kv : DiG_ptr->adj) if (kv.first > max_graph_id) max_graph_id = kv.first; + for (const auto& kv : DiG_ptr->pred) if (kv.first > max_graph_id) max_graph_id = kv.first; + all_nodes_vec.reserve(DiG_ptr->adj.size() + DiG_ptr->pred.size()); + for(const auto& kv : DiG_ptr->adj) all_nodes_vec.push_back(kv.first); + for(const auto& kv : DiG_ptr->pred) all_nodes_vec.push_back(kv.first); + } else { + for (const auto& kv : G_ptr->adj) if (kv.first > max_graph_id) max_graph_id = kv.first; + all_nodes_vec.reserve(G_ptr->adj.size()); + for(const auto& kv : G_ptr->adj) all_nodes_vec.push_back(kv.first); + } + + // Deduplicate nodes + std::sort(all_nodes_vec.begin(), all_nodes_vec.end()); + all_nodes_vec.erase(std::unique(all_nodes_vec.begin(), all_nodes_vec.end()), all_nodes_vec.end()); + + // Ensure vector size covers target nodes + if (!target_nodes.empty()) { + node_t max_target = *std::max_element(target_nodes.begin(), target_nodes.end()); + max_graph_id = std::max(max_graph_id, max_target); + } + + // Pre-compute Scale + std::vector scale_sum_vec(max_graph_id + 1, 0.0); + std::vector scale_max_vec(max_graph_id + 1, 0.0); + + #pragma omp parallel for schedule(dynamic) + for(int i = 0; i < (int)all_nodes_vec.size(); ++i) { + node_t u = all_nodes_vec[i]; + double s_sum = 0; + double s_max = 0; + + if (is_directed) { + if (DiG_ptr->adj.count(u)) { + for(const auto& p : DiG_ptr->adj.at(u)) { + weight_t tw = compute_directed_mutual_weight(*DiG_ptr, u, p.first, weight_key); + s_sum += tw; s_max = std::max(s_max, (double)tw); + } + } + if (DiG_ptr->pred.count(u)) { + for(const auto& p : DiG_ptr->pred.at(u)) { + weight_t tw = compute_directed_mutual_weight(*DiG_ptr, u, p.first, weight_key); + s_sum += tw; s_max = std::max(s_max, (double)tw); + } + } + } else { + if (G_ptr->adj.count(u)) { + for(const auto& p : G_ptr->adj.at(u)) { + weight_t tw = compute_mutual_weight(*G_ptr, u, p.first, weight_key); + s_sum += tw; s_max = std::max(s_max, (double)tw); + } + } + } + if (u < scale_sum_vec.size()) { + scale_sum_vec[u] = s_sum; + scale_max_vec[u] = s_max; + } + } + + // Compute Redundancy + std::vector results(target_nodes.size()); + + if (!is_directed) { + // Undirected + #pragma omp parallel for schedule(dynamic) + for (int i = 0; i < (int)target_nodes.size(); i++) { + node_t v_id = target_nodes[i]; + + if (G_ptr->adj.find(v_id) == G_ptr->adj.end() || G_ptr->adj.at(v_id).empty()) { + results[i] = NAN; + continue; + } + + const auto& v_neighbors = G_ptr->adj.at(v_id); + double redundancy_sum = 0; + double scale_v_sum = (v_id < scale_sum_vec.size()) ? scale_sum_vec[v_id] : 0; + + // Direct iteration avoids malloc locks + for (const auto& neighbor_info : v_neighbors) { + node_t u_id = neighbor_info.first; + double scale_u_max = (u_id < scale_max_vec.size()) ? scale_max_vec[u_id] : 0; + double r_vu = 0; + + for (const auto& w_pair : v_neighbors) { + node_t w_id = w_pair.first; + if (u_id == w_id) continue; + + weight_t mw_uw = compute_mutual_weight(*G_ptr, u_id, w_id, weight_key); + if (mw_uw == 0) continue; + + weight_t mw_vw = compute_mutual_weight(*G_ptr, v_id, w_id, weight_key); + + double p_iq = (scale_v_sum > 0) ? (mw_vw / scale_v_sum) : 0; + double m_jq = (scale_u_max > 0) ? (mw_uw / scale_u_max) : 0; + + r_vu += p_iq * m_jq; + } + redundancy_sum += (1.0 - r_vu); + } + results[i] = redundancy_sum; + } + } else { + //Directed + #pragma omp parallel for schedule(dynamic) + for (int i = 0; i < target_nodes.size(); i++) { + node_t v_id = target_nodes[i]; + + bool has_neighbors = (DiG_ptr->adj.count(v_id) && !DiG_ptr->adj.at(v_id).empty()) || + (DiG_ptr->pred.count(v_id) && !DiG_ptr->pred.at(v_id).empty()); + + if (!has_neighbors) { + results[i] = NAN; + continue; + } + + double redundancy_sum = 0; + double scale_v_sum = (v_id < scale_sum_vec.size()) ? scale_sum_vec[v_id] : 0; + + // Prepare common candidates + std::vector common_candidates; + if (DiG_ptr->adj.count(v_id)) { + for(auto& p : DiG_ptr->adj.at(v_id)) common_candidates.push_back(p.first); + } + if (DiG_ptr->pred.count(v_id)) { + for(auto& p : DiG_ptr->pred.at(v_id)) common_candidates.push_back(p.first); + } + std::sort(common_candidates.begin(), common_candidates.end()); + common_candidates.erase(std::unique(common_candidates.begin(), common_candidates.end()), common_candidates.end()); + + // Loop A: Out-neighbors + if (DiG_ptr->adj.count(v_id)) { + for (const auto& neighbor_info : DiG_ptr->adj.at(v_id)) { + node_t u_id = neighbor_info.first; + double scale_u_max = (u_id < scale_max_vec.size()) ? scale_max_vec[u_id] : 0; + double r_vu = 0; + + for (const auto& w_id : common_candidates) { + if (u_id == w_id) continue; + weight_t mw_uw = compute_directed_mutual_weight(*DiG_ptr, u_id, w_id, weight_key); + if (mw_uw == 0) continue; + weight_t mw_vw = compute_directed_mutual_weight(*DiG_ptr, v_id, w_id, weight_key); + + double p_iq = (scale_v_sum > 0) ? (mw_vw / scale_v_sum) : 0; + double m_jq = (scale_u_max > 0) ? (mw_uw / scale_u_max) : 0; + r_vu += p_iq * m_jq; + } + redundancy_sum += (1.0 - r_vu); + } + } + + // Loop B: In-neighbors + if (DiG_ptr->pred.count(v_id)) { + for (const auto& neighbor_info : DiG_ptr->pred.at(v_id)) { + node_t u_id = neighbor_info.first; + double scale_u_max = (u_id < scale_max_vec.size()) ? scale_max_vec[u_id] : 0; + double r_vu = 0; + + for (const auto& w_id : common_candidates) { + if (u_id == w_id) continue; + weight_t mw_uw = compute_directed_mutual_weight(*DiG_ptr, u_id, w_id, weight_key); + if (mw_uw == 0) continue; + weight_t mw_vw = compute_directed_mutual_weight(*DiG_ptr, v_id, w_id, weight_key); + + double p_iq = (scale_v_sum > 0) ? (mw_vw / scale_v_sum) : 0; + double m_jq = (scale_u_max > 0) ? (mw_uw / scale_u_max) : 0; + r_vu += p_iq * m_jq; + } + redundancy_sum += (1.0 - r_vu); + } + } + results[i] = redundancy_sum; + } + } + + return results; +} + +py::object invoke_cpp_effective_size(py::object G, py::object nodes, py::object weight) { + std::string weight_key = weight.is_none() ? "" : weight.cast(); + bool is_directed = G.attr("is_directed")().cast(); + + if (nodes.is_none()) nodes = G.attr("nodes"); + py::list nodes_list = py::list(nodes); + int len = py::len(nodes_list); + std::vector target_ids(len); + + if (py::hasattr(G, "node_to_id")) { + py::object node_to_id = G.attr("node_to_id"); + for (int i = 0; i < len; i++) { + target_ids[i] = node_to_id[nodes_list[i]].cast(); + } + } else { + for (int i = 0; i < len; i++) { + target_ids[i] = nodes_list[i].cast(); + } + } + + std::vector results = compute_redundancy_core(G, target_ids, weight_key, is_directed); + + py::array::ShapeContainer ret_shape{ (long)results.size() }; + return py::array_t(ret_shape, results.data()); +} + +#ifdef EASYGRAPH_ENABLE_GPU +static py::object invoke_gpu_effective_size(py::object G, py::object nodes, py::object weight) { + Graph& G_ = G.cast(); + + if (weight.is_none()) { + G_.gen_CSR(); + } else { + G_.gen_CSR(weight_to_string(weight)); + } + auto csr_graph = G_.csr_graph; + auto coo_graph = G_.transfer_csr_to_coo(csr_graph); + + std::vector& V = csr_graph->V; + std::vector& E = csr_graph->E; + std::vector& row = coo_graph->row; + std::vector& col = coo_graph->col; + + std::vector* W_p = weight.is_none() ? &(coo_graph->unweighted_W) + : coo_graph->W_map.find(weight_to_string(weight))->second.get(); + + std::unordered_map& node2idx = coo_graph->node2idx; + int num_nodes = coo_graph->node2idx.size(); + std::vector effective_size_results(num_nodes); + bool is_directed = G.attr("is_directed")().cast(); + + std::vector node_mask(num_nodes, 0); + py::list nodes_list; + if (!nodes.is_none()) { + nodes_list = py::list(nodes); + for (auto node : nodes_list) { + int node_id = node2idx[G_.node_to_id[node].cast()]; + node_mask[node_id] = 1; + } + } else { + nodes_list = py::list(G.attr("nodes")); + std::fill(node_mask.begin(), node_mask.end(), 1); + } + + int gpu_r = gpu_easygraph::effective_size(V, E, row, col, num_nodes, *W_p, is_directed, node_mask, effective_size_results); + + if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { + py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); + } + + py::array::ShapeContainer ret_shape{(int)effective_size_results.size()}; + py::array_t ret(ret_shape, effective_size_results.data()); + + return ret; +} +#endif + +py::object effective_size(py::object G, py::object nodes, py::object weight, py::object n_workers) { +#ifdef EASYGRAPH_ENABLE_GPU + return invoke_gpu_effective_size(G, nodes, weight); +#else + return invoke_cpp_effective_size(G, nodes, weight); +#endif +} + +#ifdef EASYGRAPH_ENABLE_GPU +static py::object invoke_gpu_efficiency(py::object G, py::object nodes, py::object weight) { + Graph& G_ = G.cast(); + py::dict effective_size = py::dict(); + if (weight.is_none()) { + G_.gen_CSR(); + } else { + G_.gen_CSR(weight_to_string(weight)); + } + auto csr_graph = G_.csr_graph; + auto coo_graph = G_.transfer_csr_to_coo(csr_graph); + + std::vector& V = csr_graph->V; + std::vector& E = csr_graph->E; + std::vector& row = coo_graph->row; + std::vector& col = coo_graph->col; + + std::vector* W_p = weight.is_none() ? &(coo_graph->unweighted_W) + : coo_graph->W_map.find(weight_to_string(weight))->second.get(); + + std::unordered_map& node2idx = coo_graph->node2idx; + int num_nodes = coo_graph->node2idx.size(); + std::vector effective_size_results(num_nodes); + bool is_directed = G.attr("is_directed")().cast(); + + std::vector node_mask(num_nodes, 0); + py::list nodes_list; + if (!nodes.is_none()) { + nodes_list = py::list(nodes); + for (auto node : nodes_list) { + int node_id = node2idx[G_.node_to_id[node].cast()]; + node_mask[node_id] = 1; + } + } else { + nodes_list = py::list(G.attr("nodes")); + std::fill(node_mask.begin(), node_mask.end(), 1); + } + + int gpu_r = gpu_easygraph::effective_size(V, E, row, col, num_nodes, *W_p, is_directed, node_mask, effective_size_results); + + if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { + py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); + } + + py::dict effective_size_dict; + for (auto node : nodes_list) { + int node_id = G_.node_to_id[node].cast(); + int idx = node2idx[node_id]; + + py::object node_name = G_.id_to_node.attr("get")(py::cast(node_id)); + effective_size_dict[node_name] = py::cast(effective_size_results[idx]); + } + py::dict degree; + if (weight.is_none()) { + degree = G.attr("degree")(py::none()).cast(); + } else { + degree = G.attr("degree")(weight).cast(); + } + + py::dict efficiency_dict; + for (auto item : effective_size_dict) { + int node = py::reinterpret_borrow(item.first).cast(); + double eff_size = py::reinterpret_borrow(item.second).cast(); + + if (!degree.contains(py::cast(node))) { + continue; + } + + double node_degree = py::reinterpret_borrow(degree[py::cast(node)]).cast(); + if (node_degree == 0.0) { + efficiency_dict[py::cast(node)] = py::cast(Py_NAN); + } else { + double efficiency_value = eff_size / node_degree; + efficiency_dict[py::cast(node)] = py::cast(efficiency_value); + } + } + + return efficiency_dict; +} +#endif + + +py::object invoke_cpp_efficiency(py::object G, py::object nodes, py::object weight, py::object n_workers) { + std::string weight_key = weight.is_none() ? "" : weight.cast(); + bool is_directed = G.attr("is_directed")().cast(); + + // Parsing Nodes + if (nodes.is_none()) nodes = G.attr("nodes"); + py::list nodes_list = py::list(nodes); + int len = py::len(nodes_list); + std::vector target_ids(len); + + if (py::hasattr(G, "node_to_id")) { + py::object node_to_id = G.attr("node_to_id"); + for (int i = 0; i < len; i++) { + target_ids[i] = node_to_id[nodes_list[i]].cast(); + } + } else { + for (int i = 0; i < len; i++) { + target_ids[i] = nodes_list[i].cast(); + } + } + + // Compute Efficiency = Effective Size / Degree + std::vector eff_sizes = compute_redundancy_core(G, target_ids, weight_key, is_directed); + + // Cast Graph pointers for fast degree access + const Graph* G_ptr = nullptr; + const DiGraph* DiG_ptr = nullptr; + if (is_directed) DiG_ptr = &G.cast(); + else G_ptr = &G.cast(); + + std::vector efficiency_results(len); + + #pragma omp parallel for schedule(static) + for (int i = 0; i < len; ++i) { + double es = eff_sizes[i]; + + // Propagate NAN from core + if (std::isnan(es)) { + efficiency_results[i] = NAN; + continue; + } + + node_t v = target_ids[i]; + double degree = 0; + + if (is_directed) { + if (DiG_ptr->adj.count(v)) degree += DiG_ptr->adj.at(v).size(); + if (DiG_ptr->pred.count(v)) degree += DiG_ptr->pred.at(v).size(); + } else { + if (G_ptr->adj.count(v)) degree += G_ptr->adj.at(v).size(); + } + + if (degree > 0) { + efficiency_results[i] = es / degree; + } else { + efficiency_results[i] = NAN; + } + } + + py::array::ShapeContainer ret_shape{ (long)len }; + return py::array_t(ret_shape, efficiency_results.data()); +} + +py::object efficiency(py::object G, py::object nodes, py::object weight, py::object n_workers) { +#ifdef EASYGRAPH_ENABLE_GPU + return invoke_gpu_efficiency(G, nodes, weight); +#else + return invoke_cpp_efficiency(G, nodes, weight, n_workers); +#endif +} + +py::object invoke_cpp_hierarchy(py::object G, py::object nodes, py::object weight, py::object n_workers) { + std::string weight_key = weight_to_string(weight); + + if (nodes.is_none()) { + nodes = G.attr("nodes"); + } + py::list nodes_list = py::list(nodes); + int nodes_list_len = py::len(nodes_list); + + Graph& G_ref = G.cast(); + std::vector node_ids(nodes_list_len); + for (int i = 0; i < nodes_list_len; i++) { + node_ids[i] = G_ref.node_to_id[nodes_list[i]].cast(); + } + + std::unordered_map> weighted_adj; + std::unordered_map strength; + preprocess_graph_for_constraint(G_ref, weight_key, weighted_adj, strength); + + std::vector hierarchy_results(nodes_list_len, 0.0); + + // Release GIL for parallel computation + { + py::gil_scoped_release release; + + #pragma omp parallel for schedule(dynamic) + for (int i = 0; i < nodes_list_len; i++) { + node_t u = node_ids[i]; + + // Validate node strength + auto str_it = strength.find(u); + if (str_it == strength.end() || str_it->second == 0.0) continue; + double u_strength = str_it->second; + + auto& neighbors_u = weighted_adj[u]; + int N = neighbors_u.size(); + if (N <= 1) continue; + + // Calculate dyadic constraint components + std::unordered_map contrib; + + // Direct + for (auto& neighbor : neighbors_u) { + node_t j = neighbor.first; + double p_uj = neighbor.second / u_strength; + contrib[j] += p_uj; + } + + // Indirect + for (auto& neighbor_j : neighbors_u) { + node_t q = neighbor_j.first; + double p_uq = neighbor_j.second / u_strength; + + auto q_it = weighted_adj.find(q); + if (q_it != weighted_adj.end()) { + double q_strength = strength[q]; + for (auto& neighbor_k : q_it->second) { + node_t j = neighbor_k.first; + if (j == u) continue; + + // Check if closed triad exists + if (weighted_adj[u].count(j)) { + double p_qj = neighbor_k.second / q_strength; + contrib[j] += p_uq * p_qj; + } + } + } + } + + // Compute Hierarchy score + double C_total = 0.0; + std::unordered_map C_j; + + for (auto& neighbor : neighbors_u) { + node_t j = neighbor.first; + if (contrib.count(j)) { + double val = std::pow(contrib[j], 2); + C_j[j] = val; + C_total += val; + } + } + + if (C_total > 0) { + double hierarchy_sum = 0.0; + double denominator = N * std::log(N); + + for (auto& item : C_j) { + double c_val = item.second; + if (c_val > 0) { + double p_i = c_val / C_total; + double term = p_i * N; + if (term > 0) { + hierarchy_sum += term * std::log(term); + } + } + } + hierarchy_results[i] = hierarchy_sum / denominator; + } + } + } + + py::array::ShapeContainer ret_shape{nodes_list_len}; + py::array_t ret(ret_shape, hierarchy_results.data()); + return ret; +} + +#ifdef EASYGRAPH_ENABLE_GPU +static py::object invoke_gpu_hierarchy(py::object G, py::object nodes, py::object weight) { + Graph& G_ = G.cast(); + if (weight.is_none()) { + G_.gen_CSR(); + } else { + G_.gen_CSR(weight_to_string(weight)); + } + auto csr_graph = G_.csr_graph; + auto coo_graph = G_.transfer_csr_to_coo(csr_graph); + std::vector& V = csr_graph->V; + std::vector& E = csr_graph->E; + std::vector& row = coo_graph->row; + std::vector& col = coo_graph->col; + std::vector *W_p = weight.is_none() ? &(coo_graph->unweighted_W) + : coo_graph->W_map.find(weight_to_string(weight))->second.get(); + std::unordered_map& node2idx = coo_graph->node2idx; + int num_nodes = coo_graph->node2idx.size(); + bool is_directed = G.attr("is_directed")().cast(); + std::vector hierarchy_results; + std::vector node_mask(num_nodes, 0); + py::list nodes_list; + if (!nodes.is_none()) { + nodes_list = py::list(nodes); + for (auto node : nodes_list) { + int node_id = node2idx[G_.node_to_id[node].cast()]; + node_mask[node_id] = 1; + } + } else { + nodes_list = py::list(G.attr("nodes")); + std::fill(node_mask.begin(), node_mask.end(), 1); + } + + int gpu_r = gpu_easygraph::hierarchy(V, E, row, col, num_nodes, *W_p, is_directed, node_mask, hierarchy_results); + if (gpu_r != gpu_easygraph::EG_GPU_SUCC) { + py::pybind11_fail(gpu_easygraph::err_code_detail(gpu_r)); + } + py::dict hierarchy_dict; + for (auto node : nodes_list) { + int node_id = G_.node_to_id[node].cast(); + int idx = node2idx[node_id]; + + py::object node_name = G_.id_to_node.attr("get")(py::cast(node_id)); + hierarchy_dict[node_name] = py::cast(hierarchy_results[idx]); + } + return hierarchy_dict; +} +#endif + +py::object hierarchy(py::object G, py::object nodes, py::object weight, py::object n_workers) { +#ifdef EASYGRAPH_ENABLE_GPU + return invoke_gpu_hierarchy(G, nodes, weight); +#else + return invoke_cpp_hierarchy(G, nodes, weight, n_workers); +#endif +} \ No newline at end of file diff --git a/cpp_easygraph/functions/structural_holes/evaluation.h b/cpp_easygraph/functions/structural_holes/evaluation.h new file mode 100644 index 0000000..c2c5af6 --- /dev/null +++ b/cpp_easygraph/functions/structural_holes/evaluation.h @@ -0,0 +1,8 @@ +#pragma once + +#include "../../common/common.h" + +py::object constraint(py::object G, py::object nodes, py::object weight, py::object n_workers); +py::object effective_size(py::object G, py::object nodes, py::object weight, py::object n_workers); +py::object efficiency(py::object G, py::object nodes, py::object weight, py::object n_workers); +py::object hierarchy(py::object G, py::object nodes, py::object weight, py::object n_workers); \ No newline at end of file diff --git a/diagram.svg b/diagram.svg new file mode 100644 index 0000000..568c51f --- /dev/null +++ b/diagram.svg @@ -0,0 +1 @@ +scriptsscriptseasygrapheasygraphutilsutilsteststestsreadwritereadwritefunctionsfunctionsdatasetsdatasetsclassesclassesteststestsstructural_holesstructural_holespathpathnot_sortednot_sortedgraph_generatorgraph_generatorgraph_embeddinggraph_embeddingdrawingdrawingcomponentscomponentscommunitycommunitycentralitycentralityteststestsGraphCGraphCdecorator...decorator...decorator...convert_t...convert_t...convert_t...graphml.pygraphml.pygraphml.pygml.pygml.pygml.pygexf.pygexf.pygexf.pypajek.pypajek.pypajek.pyucinet.pyucinet.pyucinet.pymultigrap...multigrap...multigrap...graph.pygraph.pygraph.pydirected_...directed_...directed_...directed_...directed_...directed_...operation.pyoperation.pyoperation.pytest_grap...test_grap...test_grap...test_gml.pytest_gml.pytest_gml.pytest_gexf.pytest_gexf.pytest_gexf.pytest_paj...test_paj...test_paj...maxBlock.pymaxBlock.pymaxBlock.pyevaluatio...evaluatio...evaluatio...metrics.pymetrics.pymetrics.pyAP_Greedy.pyAP_Greedy.pyAP_Greedy.pySHII_met...SHII_met...SHII_met...weakTie.pyweakTie.pyweakTie.pyMaxD.pyMaxD.pyMaxD.pymst.pymst.pymst.pyRandomNe...RandomNe...RandomNe...drawing.pydrawing.pydrawing.pyLPA.pyLPA.pyLPA.pyGraphMap.cppGraphMap.cppGraphMap.cppGraph.cppGraph.cppGraph.cpp.bat.cpp.gitignore.h.ini.mat.md.py.rst.sh.svg.yamleach dot sized by file size diff --git a/easygraph/__init__.py b/easygraph/__init__.py new file mode 100644 index 0000000..1daedcc --- /dev/null +++ b/easygraph/__init__.py @@ -0,0 +1,29 @@ +import easygraph.classes +import easygraph.convert +import easygraph.datapipe +import easygraph.datasets +import easygraph.exception +import easygraph.experiments +import easygraph.functions +import easygraph.ml_metrics +import easygraph.model +import easygraph.nn +import easygraph.readwrite +import easygraph.utils + +from easygraph.classes import * +from easygraph.convert import * +from easygraph.datapipe import * +from easygraph.datasets import * +from easygraph.exception import * +from easygraph.experiments import * +from easygraph.functions import * +from easygraph.ml_metrics import * +from easygraph.model import * +from easygraph.nn import * +from easygraph.readwrite import * +from easygraph.utils import * + + +def __getattr__(name): + print(f"attr {name} doesn't exist!") diff --git a/easygraph/_global.py b/easygraph/_global.py new file mode 100644 index 0000000..1f7430b --- /dev/null +++ b/easygraph/_global.py @@ -0,0 +1,15 @@ +from pathlib import Path + + +def get_eg_cache_root(): + root = Path.home() / Path(".easygraph/") + root.mkdir(parents=True, exist_ok=True) + return root + + +AUTHOR_EMAIL = "bdye22@m.fudan.edu.cn" +# global paths +CACHE_ROOT = get_eg_cache_root() +DATASETS_ROOT = CACHE_ROOT / "datasets" +REMOTE_ROOT = "https://download.moon-lab.tech:28501/" +REMOTE_DATASETS_ROOT = REMOTE_ROOT + "datasets/" diff --git a/easygraph/classes/__init__.py b/easygraph/classes/__init__.py new file mode 100644 index 0000000..b5b315b --- /dev/null +++ b/easygraph/classes/__init__.py @@ -0,0 +1,19 @@ +from .directed_graph import DiGraph +from .directed_graph import DiGraphC +from .directed_multigraph import MultiDiGraph +from .graph import Graph +from .graph import GraphC +from .graphviews import * +from .multigraph import MultiGraph +from .operation import * + + +try: + from .base import BaseHypergraph + from .base import load_structure + from .hypergraph import Hypergraph +except: + print( + "Warning raise in module:classes. Please install Pytorch before you use" + " functions related to Hypergraph" + ) diff --git a/easygraph/classes/base.py b/easygraph/classes/base.py new file mode 100644 index 0000000..9055f04 --- /dev/null +++ b/easygraph/classes/base.py @@ -0,0 +1,996 @@ +import abc + +from collections import defaultdict +from pathlib import Path +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from typing import Union + +import numpy as np +import torch + +from easygraph.utils.exception import EasyGraphError + + +__all__ = ["load_structure", "BaseHypergraph"] + + +def load_structure(file_path: Union[str, Path]): + r"""Load a EasyGraph's high-order network structure from a file. The supported structure ``Hypergraph``. + + Args: + ``file_path`` (``Union[str, Path]``): The file path to load the EasyGraph's structure. + """ + import pickle as pkl + + import easygraph + + file_path = Path(file_path) + assert file_path.exists(), f"{file_path} does not exist" + with open(file_path, "rb") as f: + data = pkl.load(f) + class_name, state_dict = data["class"], data["state_dict"] + structure_class = getattr(easygraph, class_name) + structure = structure_class.from_state_dict(state_dict) + return structure + + +class BaseHypergraph: + r"""The ``BaseHypergraph`` class is the base class for all hypergraph structures. + + Args: + ``num_v`` (``int``): The number of vertices. + ``e_list`` (``Union[List[int], List[List[int]]], optional``): Edge list. Defaults to ``None``. + ``e_weight`` (``Union[float, List[float]], optional``): A list of weights for edges. Defaults to ``None``. + ``extra_selfloop`` (``bool``, optional): Whether to add extra self-loop to the graph. Defaults to ``False``. + ``device`` (``torch.device``, optional): The device to store the graph. Defaults to ``torch.device('cpu')``. + + """ + + def __init__( + self, + num_v: int, + v_property: Optional[Union[Dict, List[Dict]]] = None, + e_list: Optional[Union[List[int], List[List[int]]]] = None, + e_property: Optional[Union[Dict, List[Dict]]] = None, + e_weight: Optional[Union[float, List[float]]] = None, + extra_selfloop: bool = False, + device: str = "cpu", + ): + assert ( + isinstance(num_v, int) and num_v > 0 + ), "num_v should be a positive integer" + self.clear() + self._num_v = num_v + # self.device = torch.cuda.device(device) + if v_property == None: + self._v_property = [{} for i in range(num_v)] + else: + v_property = self._format_v_property_list(num_v, v_property) + self._v_property = v_property + + if e_property == None and e_list != None: + self._e_property = [{} for i in range(len(e_list))] + elif e_property != None and e_list != None: + e_property = self._format_e_property_list(len(e_list), e_property) + self._e_property = e_property + + self._has_extra_selfloop = extra_selfloop + + @abc.abstractmethod + def __repr__(self) -> str: + r"""Print the hypergraph information.""" + + @property + @abc.abstractmethod + def state_dict(self) -> Dict[str, Any]: + r"""Get the state dict of the hypergraph.""" + + @abc.abstractmethod + def save(self, file_path: Union[str, Path]): + r"""Save the EasyGraph's hypergraph structure to a file. + + Args: + ``file_path`` (``str``): The file_path to store the EasyGraph's hypergraph structure. + """ + + @staticmethod + @abc.abstractmethod + def load(file_path: Union[str, Path]): + r"""Load the EasyGraph's hypergraph structure from a file. + + Args: + ``file_path`` (``str``): The file path to load the DEasyGraph's hypergraph structure. + """ + + @staticmethod + @abc.abstractmethod + def from_state_dict(state_dict: dict): + r"""Load the EasyGraph's hypergraph structure from the state dict. + + Args: + ``state_dict`` (``dict``): The state dict to load the EasyGraph's hypergraph. + """ + + @abc.abstractmethod + def draw(self, **kwargs): + r"""Draw the structure.""" + + def clear(self): + r"""Remove all hyperedges and caches from the hypergraph.""" + self._clear_raw() + self._clear_cache() + + def _clear_raw(self): + self._v_weight = None + self._raw_groups = {} + + def _clear_cache(self, group_name: Optional[str] = None): + r"""Clear the cache.""" + self.cache = {} + if group_name is None: + self.group_cache = defaultdict(dict) + else: + self.group_cache.pop(group_name, None) + + @abc.abstractmethod + def clone(self) -> "BaseHypergraph": + r"""Return a copy of this type of hypergraph.""" + + def to(self, device: str = "cpu") -> "BaseHypergraph": + r"""Move the hypergraph to the specified device. + + Args: + ``device`` (``torch.device``): The device to store the hypergraph. + """ + # self.device = torch.device + for v in self.vars_for_DL: + if v in self.cache and self.cache[v] is not None: + self.cache[v] = self.cache[v].to(device) + for name in self.group_names: + if ( + v in self.group_cache[name] + and self.group_cache[name][v] is not None + ): + self.group_cache[name][v] = self.group_cache[name][v].to(device) + return self + + # utils + def _hyperedge_code(self, src_v_set: List[int], dst_v_set: List[int]) -> Tuple: + r"""Generate the hyperedge code. + + Args: + ``src_v_set`` (``List[int]``): The source vertex set. + ``dst_v_set`` (``List[int]``): The destination vertex set. + """ + return tuple([src_v_set, dst_v_set]) + + def _merge_hyperedges(self, e1: dict, e2: dict, op: str = "mean"): + assert op in [ + "mean", + "sum", + "max", + ], "Hyperedge merge operation must be one of ['mean', 'sum', 'max']" + _func = { + "mean": lambda x, y: (x + y) / 2, + "sum": lambda x, y: x + y, + "max": lambda x, y: max(x, y), + } + _e = {} + if "w_v2e" in e1 and "w_v2e" in e2: + for _idx in range(len(e1["w_v2e"])): + _e["w_v2e"] = _func[op](e1["w_v2e"][_idx], e2["w_v2e"][_idx]) + if "w_e2v" in e1 and "w_e2v" in e2: + for _idx in range(len(e1["w_e2v"])): + _e["w_e2v"] = _func[op](e1["w_e2v"][_idx], e2["w_e2v"][_idx]) + _e["w_e"] = _func[op](e1["w_e"], e2["w_e"]) + return _e + + @staticmethod + def _format_e_list(e_list: Union[List[int], List[List[int]]]) -> List[List[int]]: + r"""Format the hyperedge list. + + Args: + ``e_list`` (``List[int]`` or ``List[List[int]]``): The hyperedge list. + """ + if len(e_list) == 0: + pass + elif type(e_list[0]) in (int, float): + return [tuple(sorted(e_list))] + elif type(e_list) == tuple: + e_list = list(e_list) + elif type(e_list) == list: + pass + else: + raise TypeError("e_list must be List[int] or List[List[int]].") + for _idx in range(len(e_list)): + e_list[_idx] = tuple(sorted(list(set(e_list[_idx])))) + + return e_list + + def _format_e_property_list(self, e_num, e_property_list: Union[Dict, List[Dict]]): + r"""Format the property list. + + Args: + ``e_list`` (``Dict`` or ``List[Dict]``): The property list. + """ + if type(e_property_list) == dict: + return [e_property_list] + elif type(e_property_list) == list and len(e_property_list) != e_num: + raise EasyGraphError( + "The length of property list must be equal to edge number" + ) + elif type(e_property_list) == list: + pass + else: + raise TypeError("e_property_list must be Dict or List[Dict].") + + return e_property_list + + def _format_v_property_list(self, v_num, v_property_list: Union[Dict, List[Dict]]): + r"""Format the property list. + + Args: + ``e_list`` (``Dict`` or ``List[Dict]``): The property list. + """ + if type(v_property_list) == dict: + return [v_property_list] + elif type(v_property_list) == list and len(v_property_list) != v_num: + raise EasyGraphError( + "The length of property list must be equal to node number" + ) + elif type(v_property_list) == list: + pass + else: + raise TypeError("v_property_list must be Dict or List[Dict].") + + return v_property_list + + @staticmethod + def _format_e_list_and_w_on_them( + e_list: Union[List[int], List[List[int]]], + w_list: Optional[Union[List[int], List[List[int]]]] = None, + ): + r"""Format ``e_list`` and ``w_list``. + + Args: + ``e_list`` (Union[List[int], List[List[int]]]): Hyperedge list. + ``w_list`` (Optional[Union[List[int], List[List[int]]]]): Weights on connections. Defaults to ``None``. + """ + bad_connection_msg = ( + "The weight on connections between vertices and hyperedges must have the" + " same size as the hyperedges." + ) + if isinstance(e_list, tuple): + e_list = list(e_list) + if w_list is not None and isinstance(w_list, tuple): + w_list = list(w_list) + if isinstance(e_list[0], int) and w_list is None: + w_list = [1] * len(e_list) + e_list, w_list = [e_list], [w_list] + elif isinstance(e_list[0], int) and w_list is not None: + assert len(e_list) == len(w_list), bad_connection_msg + e_list, w_list = [e_list], [w_list] + elif isinstance(e_list[0], list) and w_list is None: + w_list = [[1] * len(e) for e in e_list] + assert len(e_list) == len(w_list), bad_connection_msg + # TODO: this step can be speeded up + for idx in range(len(e_list)): + assert len(e_list[idx]) == len(w_list[idx]), bad_connection_msg + cur_e, cur_w = np.array(e_list[idx]), np.array(w_list[idx]) + sorted_idx = np.argsort(cur_e) + e_list[idx] = tuple(cur_e[sorted_idx].tolist()) + w_list[idx] = cur_w[sorted_idx].tolist() + return e_list, w_list + + def _fetch_H(self, direction: str, group_name: str): + r"""Fetch the H matrix of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + + Args: + ``direction`` (``str``): The direction of hyperedges can be either ``'v2e'`` or ``'e2v'``. + ``group_name`` (``str``): The name of the group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + assert direction in ["v2e", "e2v"], "direction must be one of ['v2e', 'e2v']" + if direction == "v2e": + select_idx = 0 + else: + select_idx = 1 + num_e = len(self._raw_groups[group_name]) + e_idx, v_idx = [], [] + for _e_idx, e in enumerate(self._raw_groups[group_name].keys()): + sub_e = e[select_idx] + v_idx.extend(sub_e) + e_idx.extend([_e_idx] * len(sub_e)) + + H = torch.sparse_coo_tensor( + torch.tensor([v_idx, e_idx], dtype=torch.long), + torch.ones(len(v_idx)), + torch.Size([self.num_v, num_e]), + device=self.device, + ).coalesce() + return H + + def _fetch_H_of_group(self, direction: str, group_name: str): + r"""Fetch the H matrix of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + + Args: + ``direction`` (``str``): The direction of hyperedges can be either ``'v2e'`` or ``'e2v'``. + ``group_name`` (``str``): The name of the group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + assert direction in ["v2e", "e2v"], "direction must be one of ['v2e', 'e2v']" + if direction == "v2e": + select_idx = 0 + else: + select_idx = 1 + num_e = len(self._raw_groups[group_name]) + e_idx, v_idx = [], [] + for _e_idx, e in enumerate(self._raw_groups[group_name].keys()): + sub_e = e[select_idx] + v_idx.extend(sub_e) + e_idx.extend([_e_idx] * len(sub_e)) + + H = torch.sparse_coo_tensor( + torch.tensor([v_idx, e_idx], dtype=torch.long), + torch.ones(len(v_idx)), + torch.Size([self.num_v, num_e]), + device=self.device, + ).coalesce() + return H + + def _fetch_R_of_group(self, direction: str, group_name: str): + r"""Fetch the R matrix of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + + Args: + ``direction`` (``str``): The direction of hyperedges can be either ``'v2e'`` or ``'e2v'``. + ``group_name`` (``str``): The name of the group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + assert direction in ["v2e", "e2v"], "direction must be one of ['v2e', 'e2v']" + if direction == "v2e": + select_idx = 0 + else: + select_idx = 1 + num_e = len(self._raw_groups[group_name]) + e_idx, v_idx, w_list = [], [], [] + for _e_idx, e in enumerate(self._raw_groups[group_name].keys()): + sub_e = e[select_idx] + v_idx.extend(sub_e) + e_idx.extend([_e_idx] * len(sub_e)) + w_list.extend(self._raw_groups[group_name][e][f"w_{direction}"]) + R = torch.sparse_coo_tensor( + torch.vstack([v_idx, e_idx]), + torch.tensor(w_list), + torch.Size([self.num_v, num_e]), + device=self.device, + ).coalesce() + return R + + def _fetch_W_of_group(self, group_name: str): + r"""Fetch the W matrix of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + + Args: + ``group_name`` (``str``): The name of the group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + w_list = [1.0] * len(self._raw_groups["main"]) + W = torch.tensor(w_list, device=self.device).view((-1, 1)) + return W + + # some structure modification functions + def add_hyperedges( + self, + e_list_v2e: Union[List[int], List[List[int]]], + e_list_e2v: Union[List[int], List[List[int]]], + w_list_v2e: Optional[Union[List[float], List[List[float]]]] = None, + w_list_e2v: Optional[Union[List[float], List[List[float]]]] = None, + e_weight: Optional[Union[float, List[float]]] = None, + merge_op: str = "mean", + group_name: str = "main", + ): + r"""Add hyperedges to the hypergraph. If the ``group_name`` is not specified, the hyperedges will be added to the default ``main`` hyperedge group. + + Args: + ``num_v`` (``int``): The number of vertices in the hypergraph. + ``e_list_v2e`` (``Union[List[int], List[List[int]]]``): A list of hyperedges describes how the vertices point to the hyperedges. + ``e_list_e2v`` (``Union[List[int], List[List[int]]]``): A list of hyperedges describes how the hyperedges point to the vertices. + ``w_list_v2e`` (``Union[List[float], List[List[float]]]``, optional): The weights are attached to the connections from vertices to hyperedges, which has the same shape + as ``e_list_v2e``. If set to ``None``, the value ``1`` is used for all connections. Defaults to ``None``. + ``w_list_e2v`` (``Union[List[float], List[List[float]]]``, optional): The weights are attached to the connections from the hyperedges to the vertices, which has the + same shape to ``e_list_e2v``. If set to ``None``, the value ``1`` is used for all connections. Defaults to ``None``. + ``e_weight`` (``Union[float, List[float]]``, optional): A list of weights for hyperedges. If set to ``None``, the value ``1`` is used for all hyperedges. Defaults to ``None``. + ``merge_op`` (``str``): The merge operation for the conflicting hyperedges. The possible values are ``mean``, ``sum``, ``max``, and ``min``. Defaults to ``mean``. + ``group_name`` (``str``, optional): The target hyperedge group to add these hyperedges. Defaults to the ``main`` hyperedge group. + """ + e_list_v2e, w_list_v2e = self._format_e_list_and_w_on_them( + e_list_v2e, w_list_v2e + ) + e_list_e2v, w_list_e2v = self._format_e_list_and_w_on_them( + e_list_e2v, w_list_e2v + ) + if e_weight is None: + e_weight = [1.0] * len(e_list_v2e) + assert len(e_list_v2e) == len( + e_weight + ), "The number of hyperedges and the number of weights are not equal." + assert len(e_list_v2e) == len( + e_list_e2v + ), "Hyperedges of 'v2e' and 'e2v' must have the same size." + for _idx in range(len(e_list_v2e)): + self._add_hyperedge( + self._hyperedge_code(e_list_v2e[_idx], e_list_e2v[_idx]), + { + "w_v2e": w_list_v2e[_idx], + "w_e2v": w_list_e2v[_idx], + "w_e": e_weight[_idx], + }, + merge_op, + group_name, + ) + self._clear_cache(group_name) + + def _add_hyperedge( + self, + hyperedge_code: Tuple[List[int], List[int]], + content: Dict[str, Any], + merge_op: str, + group_name: str, + ): + r"""Add a hyperedge to the specified hyperedge group. + + Args: + ``hyperedge_code`` (``Tuple[List[int], List[int]]``): The hyperedge code. + ``content`` (``Dict[str, Any]``): The content of the hyperedge. + ``merge_op`` (``str``): The merge operation for the conflicting hyperedges. + ``group_name`` (``str``): The target hyperedge group to add this hyperedge. + """ + if group_name not in self._raw_groups: + self._raw_groups[group_name] = {} + self._raw_groups[group_name][hyperedge_code] = content + else: + if hyperedge_code not in self._raw_groups[group_name]: + self._raw_groups[group_name][hyperedge_code] = content + else: + self._raw_groups[group_name][hyperedge_code] = self._merge_hyperedges( + self._raw_groups[group_name][hyperedge_code], content, merge_op + ) + + def remove_hyperedges( + self, + e_list_v2e: Union[List[int], List[List[int]]], + e_list_e2v: Union[List[int], List[List[int]]], + group_name: Optional[str] = None, + ): + r"""Remove the specified hyperedges from the hypergraph. + + Args: + ``e_list_v2e`` (``Union[List[int], List[List[int]]]``): A list of hyperedges describes how the vertices point to the hyperedges. + ``e_list_e2v`` (``Union[List[int], List[List[int]]]``): A list of hyperedges describes how the hyperedges point to the vertices. + ``group_name`` (``str``, optional): Remove these hyperedges from the specified hyperedge group. If not specified, the function will + remove those hyperedges from all hyperedge groups. Defaults to the ``None``. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + assert len(e_list_v2e) == len( + e_list_e2v + ), "Hyperedges of 'v2e' and 'e2v' must have the same size." + e_list_v2e = self._format_e_list(e_list_v2e) + e_list_e2v = self._format_e_list(e_list_e2v) + if group_name is None: + for _idx in range(len(e_list_v2e)): + e_code = self._hyperedge_code(e_list_v2e[_idx], e_list_e2v[_idx]) + for name in self.group_names: + self._raw_groups[name].pop(e_code, None) + else: + for _idx in range(len(e_list_v2e)): + e_code = self._hyperedge_code(e_list_v2e[_idx], e_list_e2v[_idx]) + self._raw_groups[group_name].pop(e_code, None) + self._clear_cache(group_name) + + @abc.abstractmethod + def drop_hyperedges(self, drop_rate: float, ord="uniform"): + r"""Randomly drop hyperedges from the hypergraph. This function will return a new hypergraph with non-dropped hyperedges. + + Args: + ``drop_rate`` (``float``): The drop rate of hyperedges. + ``ord`` (``str``): The order of dropping edges. Currently, only ``'uniform'`` is supported. Defaults to ``uniform``. + """ + + @abc.abstractmethod + def drop_hyperedges_of_group( + self, group_name: str, drop_rate: float, ord="uniform" + ): + r"""Randomly drop hyperedges from the specified hyperedge group. This function will return a new hypergraph with non-dropped hyperedges. + + Args: + ``group_name`` (``str``): The name of the hyperedge group. + ``drop_rate`` (``float``): The drop rate of hyperedges. + ``ord`` (``str``): The order of dropping edges. Currently, only ``'uniform'`` is supported. Defaults to ``uniform``. + """ + + # properties for the hypergraph + @property + def v(self) -> List[int]: + r"""Return the list of vertices.""" + if self.cache.get("v") is None: + self.cache["v"] = list(range(self.num_v)) + return self.cache["v"] + + @property + def v_weight(self) -> List[float]: + r"""Return the vertex weights of the hypergraph.""" + if self._v_weight is None: + self._v_weight = [1.0] * self.num_v + return self._v_weight + + @v_weight.setter + def v_weight(self, v_weight: List[float]): + r"""Set the vertex weights of the hypergraph.""" + assert ( + len(v_weight) == self.num_v + ), "The length of vertex weights must be equal to the number of vertices." + self._v_weight = v_weight + self._clear_cache() + + @property + @abc.abstractmethod + def e(self) -> Tuple[List[List[int]], List[float]]: + r"""Return all hyperedges and weights in the hypergraph.""" + + @abc.abstractmethod + def e_of_group(self, group_name: str) -> Tuple[List[List[int]], List[float]]: + r"""Return all hyperedges and weights in the specified hyperedge group. + + Args: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + + @property + def v_property(self): + return self._v_property + + @property + def e_property(self): + group_e_property = {} + for group in self._raw_groups: + group_e_property[group] = list(self._raw_groups[group].values()) + return group_e_property + + @property + def num_v(self) -> int: + r"""Return the number of vertices in the hypergraph.""" + return self._num_v + + @property + def num_e(self) -> int: + r"""Return the number of hyperedges in the hypergraph.""" + _num_e = 0 + for name in self.group_names: + _num_e += len(self._raw_groups[name]) + return _num_e + + def num_e_of_group(self, group_name: str) -> int: + r"""Return the number of hyperedges in the specified hyperedge group. + + Args: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + return len(self._raw_groups[group_name]) + + @property + def num_groups(self) -> int: + r"""Return the number of hyperedge groups in the hypergraph.""" + return len(self._raw_groups) + + @property + def group_names(self) -> List[str]: + r"""Return the names of hyperedge groups in the hypergraph.""" + return list(self._raw_groups.keys()) + + # properties for deep learning + @property + @abc.abstractmethod + def vars_for_DL(self) -> List[str]: + r"""Return a name list of available variables for deep learning in this type of hypergraph. + """ + + @property + def W_v(self) -> torch.Tensor: + r"""Return the vertex weight matrix of the hypergraph.""" + if self.cache["W_v"] is None: + self.cache["W_v"] = torch.tensor( + self.v_weight, dtype=torch.float, device=self.device + ).view(-1, 1) + return self.cache["W_v"] + + @property + def W_e(self) -> torch.Tensor: + r"""Return the hyperedge weight matrix of the hypergraph.""" + if self.cache["W_e"] is None: + _tmp = [self.W_e_of_group(name) for name in self.group_names] + self.cache["W_e"] = torch.cat(_tmp, dim=0) + return self.cache["W_e"] + + def W_e_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the hyperedge weight matrix of the specified hyperedge group. + + Args: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name]["W_e"] is None: + self.group_cache[group_name]["W_e"] = self._fetch_W_of_group(group_name) + return self.group_cache[group_name]["W_e"] + + @property + @abc.abstractmethod + def H(self) -> torch.Tensor: + r"""Return the hypergraph incidence matrix.""" + + @property + @abc.abstractmethod + def H_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the hypergraph incidence matrix in the specified hyperedge group. + + Args: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + + @property + def H_v2e(self) -> torch.Tensor: + r"""Return the hypergraph incidence matrix with ``sparse matrix`` format.""" + if self.cache.get("H_v2e") is None: + _tmp = [self.H_v2e_of_group(name) for name in self.group_names] + self.cache["H_v2e"] = torch.cat(_tmp, dim=1) + return self.cache["H_v2e"] + + def H_v2e_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the hypergraph incidence matrix with ``sparse matrix`` format in the specified hyperedge group. + + Args: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("H_v2e") is None: + self.group_cache[group_name]["H_v2e"] = self._fetch_H_of_group( + "v2e", group_name + ) + return self.group_cache[group_name]["H_v2e"] + + @property + def H_e2v(self) -> torch.Tensor: + r"""Return the hypergraph incidence matrix with ``sparse matrix`` format.""" + if self.cache.get("H_e2v") is None: + _tmp = [self.H_e2v_of_group(name) for name in self.group_names] + self.cache["H_e2v"] = torch.cat(_tmp, dim=1) + return self.cache["H_e2v"] + + def H_e2v_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the hypergraph incidence matrix with ``sparse matrix`` format in the specified hyperedge group. + + Args: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("H_e2v") is None: + self.group_cache[group_name]["H_e2v"] = self._fetch_H_of_group( + "e2v", group_name + ) + return self.group_cache[group_name]["H_e2v"] + + @property + def R_v2e(self) -> torch.Tensor: + r"""Return the weight matrix of connections (vertices point to hyperedges) with ``sparse matrix`` format. + """ + if self.cache.get("R_v2e") is None: + _tmp = [self.R_v2e_of_group(name) for name in self.group_names] + self.cache["R_v2e"] = torch.cat(_tmp, dim=1) + return self.cache["R_v2e"] + + def R_v2e_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the weight matrix of connections (vertices point to hyperedges) with ``sparse matrix`` format in the specified hyperedge group. + + Args: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("R_v2e") is None: + self.group_cache[group_name]["R_v2e"] = self._fetch_R_of_group( + "v2e", group_name + ) + return self.group_cache[group_name]["R_v2e"] + + @property + def R_e2v(self) -> torch.Tensor: + r"""Return the weight matrix of connections (hyperedges point to vertices) with ``sparse matrix`` format. + """ + if self.cache.get("R_e2v") is None: + _tmp = [self.R_e2v_of_group(name) for name in self.group_names] + self.cache["R_e2v"] = torch.cat(_tmp, dim=1) + return self.cache["R_e2v"] + + def R_e2v_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the weight matrix of connections (hyperedges point to vertices) with ``sparse matrix`` format in the specified hyperedge group. + + Args: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("R_e2v") is None: + self.group_cache[group_name]["R_e2v"] = self._fetch_R_of_group( + "e2v", group_name + ) + return self.group_cache[group_name]["R_e2v"] + + # spectral-based smoothing + def smoothing(self, X: torch.Tensor, L: torch.Tensor, lamb: float) -> torch.Tensor: + r"""Spectral-based smoothing. + + .. math:: + X_{smoothed} = X + \lambda \mathcal{L} X + + Args: + ``X`` (``torch.Tensor``): The vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``L`` (``torch.Tensor``): The Laplacian matrix with ``torch.sparse_coo_tensor`` format. Size :math:`(|\mathcal{V}|, |\mathcal{V}|)`. + ``lamb`` (``float``): :math:`\lambda`, the strength of smoothing. + """ + return X + lamb * torch.sparse.mm(L, X) + + # message passing functions + @abc.abstractmethod + def v2e_aggregation( + self, + X: torch.Tensor, + aggr: str = "mean", + v2e_weight: Optional[torch.Tensor] = None, + drop_rate: float = 0.0, + ): + r"""Message aggretation step of ``vertices to hyperedges``. + + Args: + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``v2e_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (vertices point to hyepredges). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def v2e_aggregation_of_group( + self, + group_name: str, + X: torch.Tensor, + aggr: str = "mean", + v2e_weight: Optional[torch.Tensor] = None, + drop_rate: float = 0.0, + ): + r"""Message aggregation step of ``vertices to hyperedges`` in specified hyperedge group. + + Args: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``v2e_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (vertices point to hyepredges). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def v2e_update(self, X: torch.Tensor, e_weight: Optional[torch.Tensor] = None): + r"""Message update step of ``vertices to hyperedges``. + + Args: + ``X`` (``torch.Tensor``): Hyperedge feature matrix. Size :math:`(|\mathcal{E}|, C)`. + ``e_weight`` (``torch.Tensor``, optional): The hyperedge weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def v2e_update_of_group( + self, group_name: str, X: torch.Tensor, e_weight: Optional[torch.Tensor] = None + ): + r"""Message update step of ``vertices to hyperedges`` in specified hyperedge group. + + Args: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Hyperedge feature matrix. Size :math:`(|\mathcal{E}|, C)`. + ``e_weight`` (``torch.Tensor``, optional): The hyperedge weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def v2e( + self, + X: torch.Tensor, + aggr: str = "mean", + v2e_weight: Optional[torch.Tensor] = None, + e_weight: Optional[torch.Tensor] = None, + ): + r"""Message passing of ``vertices to hyperedges``. The combination of ``v2e_aggregation`` and ``v2e_update``. + + Args: + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``v2e_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (vertices point to hyepredges). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``e_weight`` (``torch.Tensor``, optional): The hyperedge weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def v2e_of_group( + self, + group_name: str, + X: torch.Tensor, + aggr: str = "mean", + v2e_weight: Optional[torch.Tensor] = None, + e_weight: Optional[torch.Tensor] = None, + ): + r"""Message passing of ``vertices to hyperedges`` in specified hyperedge group. The combination of ``e2v_aggregation_of_group`` and ``e2v_update_of_group``. + + Args: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``v2e_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (vertices point to hyepredges). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``e_weight`` (``torch.Tensor``, optional): The hyperedge weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def e2v_aggregation( + self, + X: torch.Tensor, + aggr: str = "mean", + e2v_weight: Optional[torch.Tensor] = None, + ): + r"""Message aggregation step of ``hyperedges to vertices``. + + Args: + ``X`` (``torch.Tensor``): Hyperedge feature matrix. Size :math:`(|\mathcal{E}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``e2v_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (hyperedges point to vertices). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def e2v_aggregation_of_group( + self, + group_name: str, + X: torch.Tensor, + aggr: str = "mean", + e2v_weight: Optional[torch.Tensor] = None, + ): + r"""Message aggregation step of ``hyperedges to vertices`` in specified hyperedge group. + + Args: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Hyperedge feature matrix. Size :math:`(|\mathcal{E}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``e2v_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (hyperedges point to vertices). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def e2v_update(self, X: torch.Tensor, v_weight: Optional[torch.Tensor] = None): + r"""Message update step of ``hyperedges to vertices``. + + Args: + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``v_weight`` (``torch.Tensor``, optional): The vertex weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def e2v_update_of_group( + self, group_name: str, X: torch.Tensor, v_weight: Optional[torch.Tensor] = None + ): + r"""Message update step of ``hyperedges to vertices`` in specified hyperedge group. + + Args: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``v_weight`` (``torch.Tensor``, optional): The vertex weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def e2v( + self, + X: torch.Tensor, + aggr: str = "mean", + e2v_weight: Optional[torch.Tensor] = None, + v_weight: Optional[torch.Tensor] = None, + ): + r"""Message passing of ``hyperedges to vertices``. The combination of ``e2v_aggregation`` and ``e2v_update``. + + Args: + ``X`` (``torch.Tensor``): Hyperedge feature matrix. Size :math:`(|\mathcal{E}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``e2v_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (hyperedges point to vertices). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``v_weight`` (``torch.Tensor``, optional): The vertex weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def e2v_of_group( + self, + group_name: str, + X: torch.Tensor, + aggr: str = "mean", + e2v_weight: Optional[torch.Tensor] = None, + v_weight: Optional[torch.Tensor] = None, + ): + r"""Message passing of ``hyperedges to vertices`` in specified hyperedge group. The combination of ``e2v_aggregation_of_group`` and ``e2v_update_of_group``. + + Args: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Hyperedge feature matrix. Size :math:`(|\mathcal{E}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``e2v_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (hyperedges point to vertices). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``v_weight`` (``torch.Tensor``, optional): The vertex weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def v2v( + self, + X: torch.Tensor, + aggr: str = "mean", + v2e_aggr: Optional[str] = None, + v2e_weight: Optional[torch.Tensor] = None, + e_weight: Optional[torch.Tensor] = None, + e2v_aggr: Optional[str] = None, + e2v_weight: Optional[torch.Tensor] = None, + v_weight: Optional[torch.Tensor] = None, + ): + r"""Message passing of ``vertices to vertices``. The combination of ``v2e`` and ``e2v``. + + Args: + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. If specified, this ``aggr`` will be used to both ``v2e`` and ``e2v``. + ``v2e_aggr`` (``str``, optional): The aggregation method for hyperedges to vertices. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. If specified, it will override the ``aggr`` in ``e2v``. + ``v2e_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (vertices point to hyepredges). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``e_weight`` (``torch.Tensor``, optional): The hyperedge weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``e2v_aggr`` (``str``, optional): The aggregation method for vertices to hyperedges. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. If specified, it will override the ``aggr`` in ``v2e``. + ``e2v_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (hyperedges point to vertices). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``v_weight`` (``torch.Tensor``, optional): The vertex weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + + @abc.abstractmethod + def v2v_of_group( + self, + group_name: str, + X: torch.Tensor, + aggr: str = "mean", + v2e_aggr: Optional[str] = None, + v2e_weight: Optional[torch.Tensor] = None, + e_weight: Optional[torch.Tensor] = None, + e2v_aggr: Optional[str] = None, + e2v_weight: Optional[torch.Tensor] = None, + v_weight: Optional[torch.Tensor] = None, + ): + r"""Message passing of ``vertices to vertices`` in specified hyperedge group. The combination of ``v2e_of_group`` and ``e2v_of_group``. + + Args: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. If specified, this ``aggr`` will be used to both ``v2e_of_group`` and ``e2v_of_group``. + ``v2e_aggr`` (``str``, optional): The aggregation method for hyperedges to vertices. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. If specified, it will override the ``aggr`` in ``e2v_of_group``. + ``v2e_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (vertices point to hyepredges). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``e_weight`` (``torch.Tensor``, optional): The hyperedge weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``e2v_aggr`` (``str``, optional): The aggregation method for vertices to hyperedges. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. If specified, it will override the ``aggr`` in ``v2e_of_group``. + ``e2v_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (hyperedges point to vertices). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``v_weight`` (``torch.Tensor``, optional): The vertex weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ diff --git a/easygraph/classes/directed_graph.py b/easygraph/classes/directed_graph.py new file mode 100644 index 0000000..4d5cae6 --- /dev/null +++ b/easygraph/classes/directed_graph.py @@ -0,0 +1,1315 @@ +from typing import Dict +from typing import List + +import easygraph +import easygraph.convert as convert + +from easygraph.classes.graph import Graph +from easygraph.utils.exception import EasyGraphError + + +class DiGraph(Graph): + """ + Base class for directed graphs. + + Nodes are allowed for any hashable Python objects, including int, string, dict, etc. + Edges are stored as Python dict type, with optional key/value attributes. + + Parameters + ---------- + graph_attr : keywords arguments, optional (default : None) + Attributes to add to graph as key=value pairs. + + See Also + -------- + Graph + + Examples + -------- + Create an empty directed graph with no nodes and edges. + + >>> G = eg.DiGraph() + + Create a deep copy graph *G2* from existing Graph *G1*. + + >>> G2 = G1.copy() + + Create an graph with attributes. + + >>> G = eg.DiGraph(name='Karate Club', date='2020.08.21') + + **Attributes:** + + Returns the adjacency matrix of the graph. + + >>> G.adj + + Returns all the nodes with their attributes. + + >>> G.nodes + + Returns all the edges with their attributes. + + >>> G.edges + + """ + + gnn_data_dict_factory = dict + graph_attr_dict_factory = dict + node_dict_factory = dict + node_attr_dict_factory = dict + adjlist_outer_dict_factory = dict + adjlist_inner_dict_factory = dict + edge_attr_dict_factory = dict + node_index_dict = dict + + def __init__(self, incoming_graph_data=None, **graph_attr): + self.graph = self.graph_attr_dict_factory() + self._ndata = self.gnn_data_dict_factory() + self._node = self.node_dict_factory() + self._adj = self.adjlist_outer_dict_factory() + self._pred = self.adjlist_outer_dict_factory() + self._node_index = self.node_index_dict() + self._id = 0 + self.cflag = 0 + self.cache = {} + self._node_index = self.node_index_dict() + if incoming_graph_data is not None: + convert.to_easygraph_graph(incoming_graph_data, create_using=self) + self.graph.update(graph_attr) + + def __iter__(self): + return iter(self._node) + + def __len__(self): + return len(self._node) + + def __contains__(self, node): + try: + return node in self._node + except TypeError: + return False + + def __getitem__(self, node): + # return list(self._adj[node].keys()) + return self._adj[node] + + @property + def node_index(self): + return self._node_index + + @property + def ndata(self): + return self._ndata + + @property + def pred(self): + """ + Return the pred of each node + """ + return self._pred + + @property + def adj(self): + """ + Return the adjacency matrix + """ + return self._adj + + @property + def nodes(self): + """ + return [node for node in self._node] + """ + return self._node + + @property + def edges(self): + """ + Return an edge list + """ + edges = list() + for u in self._adj: + for v in self._adj[u]: + edges.append((u, v, self._adj[u][v])) + return edges + + @property + def name(self): + """String identifier of the graph. + + This graph attribute appears in the attribute dict G.graph + keyed by the string `"name"`. as well as an attribute (technically + a property) `G.name`. This is entirely user controlled. + """ + return self.graph.get("name", "") + + @name.setter + def name(self, s): + """ + Set graph name + + Parameters + ---------- + s : name + """ + self.graph["name"] = s + + @property + def node_index(self): + """ + Assign an integer index for each node (start from 0) + """ + if self.cache.get("node_index", None) is None: + node2index_dict = {} + index = 0 + for n in self.nodes: + node2index_dict[n] = index + index += 1 + self.cache["node_index"] = node2index_dict + return self.cache["node_index"] + + @property + def index2node(self): + """ + Assign an integer index for each node (start from 0) + """ + if self.cache.get("index2node", None) is None: + index2node_dict = {} + for index, n in enumerate(self.nodes): + index2node_dict[index] = n + self.cache["index2node"] = index2node_dict + return self.cache["index2node"] + + def out_degree(self, weight="weight"): + """Returns the weighted out degree of each node. + + Parameters + ---------- + weight : string, optional (default : 'weight') + Weight key of the original weighted graph. + + Returns + ------- + out_degree : dict + Each node's (key) weighted out degree (value). + + Notes + ----- + If the graph is not weighted, all the weights will be regarded as 1. + + See Also + -------- + in_degree + degree + + Examples + -------- + + >>> G.out_degree(weight='weight') + + """ + degree = dict() + for u, v, d in self.edges: + if u in degree: + degree[u] += d.get(weight, 1) + else: + degree[u] = d.get(weight, 1) + + # For isolated nodes + for node in self.nodes: + if node not in degree: + degree[node] = 0 + + return degree + + def in_degree(self, weight="weight"): + """Returns the weighted in degree of each node. + + Parameters + ---------- + weight : string, optional (default : 'weight') + Weight key of the original weighted graph. + + Returns + ------- + in_degree : dict + Each node's (key) weighted in degree (value). + + Notes + ----- + If the graph is not weighted, all the weights will be regarded as 1. + + See Also + -------- + out_degree + degree + + Examples + -------- + + >>> G.in_degree(weight='weight') + + """ + degree = dict() + for u, v, d in self.edges: + if v in degree: + degree[v] += d.get(weight, 1) + else: + degree[v] = d.get(weight, 1) + + # For isolated nodes + for node in self.nodes: + if node not in degree: + degree[node] = 0 + + return degree + + def degree(self, weight="weight"): + """Returns the weighted degree of each node, i.e. sum of out/in degree. + + Parameters + ---------- + weight : string, optional (default : 'weight') + Weight key of the original weighted graph. + + Returns + ------- + degree : dict + Each node's (key) weighted in degree (value). + For directed graph, it returns the sum of out degree and in degree. + + Notes + ----- + If the graph is not weighted, all the weights will be regarded as 1. + + See also + -------- + out_degree + in_degree + + Examples + -------- + + >>> G.degree() + >>> G.degree(weight='weight') + + or you can customize the weight key + + >>> G.degree(weight='weight_1') + + """ + degree = dict() + outdegree = self.out_degree(weight=weight) + indegree = self.in_degree(weight=weight) + all_nodes = set(outdegree.keys()) | set(indegree.keys()) + for u in all_nodes: + degree[u] = outdegree[u] + indegree[u] + return degree + + def size(self, weight=None): + """Returns the number of edges or total of all edge weights. + + Parameters + ----------- + weight : String or None, optional + The weight key. If None, it will calculate the number of + edges, instead of total of all edge weights. + + Returns + ------- + size : int or float, optional (default: None) + The number of edges or total of all edge weights. + + Examples + -------- + + Returns the number of edges in G: + + >>> G.size() + + Returns the total of all edge weights in G: + + >>> G.size(weight='weight') + + """ + s = sum(d for v, d in self.out_degree(weight=weight).items()) + return int(s) if weight is None else s + + def number_of_edges(self, u=None, v=None): + """Returns the number of edges between two nodes. + + Parameters + ---------- + u, v : nodes, optional (default=all edges) + If u and v are specified, return the number of edges between + u and v. Otherwise return the total number of all edges. + + Returns + ------- + nedges : int + The number of edges in the graph. If nodes `u` and `v` are + specified return the number of edges between those nodes. If + the graph is directed, this only returns the number of edges + from `u` to `v`. + + See Also + -------- + size + + Examples + -------- + For undirected graphs, this method counts the total number of + edges in the graph: + + >>> G = eg.path_graph(4) + >>> G.number_of_edges() + 3 + + If you specify two nodes, this counts the total number of edges + joining the two nodes: + + >>> G.number_of_edges(0, 1) + 1 + + For directed graphs, this method can count the total number of + directed edges from `u` to `v`: + + >>> G = eg.DiGraph() + >>> G.add_edge(0, 1) + >>> G.add_edge(1, 0) + >>> G.number_of_edges(0, 1) + 1 + + """ + if u is None: + return int(self.size()) + if v in self._adj[u]: + return 1 + return 0 + + def nbunch_iter(self, nbunch=None): + """Returns an iterator over nodes contained in nbunch that are + also in the graph. + + The nodes in nbunch are checked for membership in the graph + and if not are silently ignored. + + Parameters + ---------- + nbunch : single node, container, or all nodes (default= all nodes) + The view will only report edges incident to these nodes. + + Returns + ------- + niter : iterator + An iterator over nodes in nbunch that are also in the graph. + If nbunch is None, iterate over all nodes in the graph. + + Raises + ------ + EasyGraphError + If nbunch is not a node or sequence of nodes. + If a node in nbunch is not hashable. + + See Also + -------- + Graph.__iter__ + + Notes + ----- + When nbunch is an iterator, the returned iterator yields values + directly from nbunch, becoming exhausted when nbunch is exhausted. + + To test whether nbunch is a single node, one can use + "if nbunch in self:", even after processing with this routine. + + If nbunch is not a node or a (possibly empty) sequence/iterator + or None, a :exc:`EasyGraphError` is raised. Also, if any object in + nbunch is not hashable, a :exc:`EasyGraphError` is raised. + """ + if nbunch is None: # include all nodes via iterator + bunch = iter(self._adj) + elif nbunch in self: # if nbunch is a single node + bunch = iter([nbunch]) + else: # if nbunch is a sequence of nodes + + def bunch_iter(nlist, adj): + try: + for n in nlist: + if n in adj: + yield n + except TypeError as err: + exc, message = err, err.args[0] + # capture error for non-sequence/iterator nbunch. + if "iter" in message: + exc = EasyGraphError( + "nbunch is not a node or a sequence of nodes." + ) + # capture error for unhashable node. + if "hashable" in message: + exc = EasyGraphError( + f"Node {n} in sequence nbunch is not a valid node." + ) + raise exc + + bunch = bunch_iter(nbunch, self._adj) + return bunch + + def neighbors(self, node): + """Returns an iterator of a node's neighbors (successors). + + Parameters + ---------- + node : Hashable + The target node. + + Returns + ------- + neighbors : iterator + An iterator of a node's neighbors (successors). + + Examples + -------- + >>> G = eg.Graph() + >>> G.add_edges([(1,2), (2,3), (2,4)]) + >>> for neighbor in G.neighbors(node=2): + ... print(neighbor) + + """ + # successors + try: + return iter(self._adj[node]) + except KeyError: + print("No node {}".format(node)) + + successors = neighbors + + def predecessors(self, node): + """Returns an iterator of a node's neighbors (predecessors). + + Parameters + ---------- + node : Hashable + The target node. + + Returns + ------- + neighbors : iterator + An iterator of a node's neighbors (predecessors). + + Examples + -------- + >>> G = eg.Graph() + >>> G.add_edges([(1,2), (2,3), (2,4)]) + >>> for predecessor in G.predecessors(node=2): + ... print(predecessor) + + """ + # predecessors + try: + return iter(self._pred[node]) + except KeyError: + print("No node {}".format(node)) + + def all_neighbors(self, node): + """Returns an iterator of a node's neighbors, including both successors and predecessors. + + Parameters + ---------- + node : Hashable + The target node. + + Returns + ------- + neighbors : iterator + An iterator of a node's neighbors, including both successors and predecessors. + + Examples + -------- + >>> G = eg.Graph() + >>> G.add_edges([(1,2), (2,3), (2,4)]) + >>> for neighbor in G.all_neighbors(node=2): + ... print(neighbor) + + """ + # union of successors and predecessors + try: + neighbors = list(self._adj[node]) + neighbors.extend(self._pred[node]) + return iter(neighbors) + except KeyError: + print("No node {}".format(node)) + + def add_node(self, node_for_adding, **node_attr): + """Add one node + + Add one node, type of which is any hashable Python object, such as int, string, dict, or even Graph itself. + You can add with node attributes using Python dict type. + + Parameters + ---------- + node_for_adding : any hashable Python object + Nodes for adding. + + node_attr : keywords arguments, optional + The node attributes. + You can customize them with different key-value pairs. + + See Also + -------- + add_nodes + + Examples + -------- + >>> G.add_node('a') + >>> G.add_node('hello world') + >>> G.add_node('Jack', age=10) + + >>> G.add_node('Jack', **{ + ... 'age': 10, + ... 'gender': 'M' + ... }) + + """ + self._add_one_node(node_for_adding, node_attr) + + def add_nodes(self, nodes_for_adding: list, nodes_attr: List[Dict] = []): + """Add nodes with a list of nodes. + + Parameters + ---------- + nodes_for_adding : list + + nodes_attr : list of dict + The corresponding attribute for each of *nodes_for_adding*. + + See Also + -------- + add_node + + Examples + -------- + Add nodes with a list of nodes. + You can add with node attributes using a list of Python dict type, + each of which is the attribute of each node, respectively. + + >>> G.add_nodes([1, 2, 'a', 'b']) + >>> G.add_nodes(range(1, 200)) + + >>> G.add_nodes(['Jack', 'Tom', 'Lily'], nodes_attr=[ + ... { + ... 'age': 10, + ... 'gender': 'M' + ... }, + ... { + ... 'age': 11, + ... 'gender': 'M' + ... }, + ... { + ... 'age': 10, + ... 'gender': 'F' + ... } + ... ]) + + """ + if nodes_attr is None: + nodes_attr = [] + if not len(nodes_attr) == 0: # Nodes attributes included in input + assert len(nodes_for_adding) == len( + nodes_attr + ), "Nodes and Attributes lists must have same length." + else: # Set empty attribute for each node + nodes_attr = [dict() for i in range(len(nodes_for_adding))] + + for i in range(len(nodes_for_adding)): + try: + self._add_one_node(nodes_for_adding[i], nodes_attr[i]) + except Exception as err: + print(err) + pass + + def add_nodes_from(self, nodes_for_adding, **attr): + """Add multiple nodes. + + Parameters + ---------- + nodes_for_adding : iterable container + A container of nodes (list, dict, set, etc.). + OR + A container of (node, attribute dict) tuples. + Node attributes are updated using the attribute dict. + attr : keyword arguments, optional (default= no attributes) + Update attributes for all nodes in nodes. + Node attributes specified in nodes as a tuple take + precedence over attributes specified via keyword arguments. + + See Also + -------- + add_node + + Examples + -------- + >>> G = eg.Graph() # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> G.add_nodes_from("Hello") + >>> K3 = eg.Graph([(0, 1), (1, 2), (2, 0)]) + >>> G.add_nodes_from(K3) + >>> sorted(G.nodes(), key=str) + [0, 1, 2, 'H', 'e', 'l', 'o'] + + Use keywords to update specific node attributes for every node. + + >>> G.add_nodes_from([1, 2], size=10) + >>> G.add_nodes_from([3, 4], weight=0.4) + + Use (node, attrdict) tuples to update attributes for specific nodes. + + >>> G.add_nodes_from([(1, dict(size=11)), (2, {"color": "blue"})]) + >>> G.nodes[1]["size"] + 11 + >>> H = eg.Graph() + >>> H.add_nodes_from(G.nodes(data=True)) + >>> H.nodes[1]["size"] + 11 + + """ + for n in nodes_for_adding: + try: + newnode = n not in self._node + newdict = attr + except TypeError: + n, ndict = n + newnode = n not in self._node + newdict = attr.copy() + newdict.update(ndict) + if newnode: + if n is None: + raise ValueError("None cannot be a node") + self._adj[n] = self.adjlist_inner_dict_factory() + self._pred[n] = self.adjlist_inner_dict_factory() + self._node[n] = self.node_attr_dict_factory() + self._node[n].update(newdict) + + def _add_one_node(self, one_node_for_adding, node_attr: dict = {}): + node = one_node_for_adding + if node not in self._node: + self._node_index[node] = self._id + self._id += 1 + self._adj[node] = self.adjlist_inner_dict_factory() + self._pred[node] = self.adjlist_inner_dict_factory() + attr_dict = self._node[node] = self.node_attr_dict_factory() + attr_dict.update(node_attr) + else: # If already exists, there is no complain and still updating the node attribute + self._node[node].update(node_attr) + + def add_edge(self, u_of_edge, v_of_edge, **edge_attr): + """Add a directed edge. + + Parameters + ---------- + u_of_edge : object + The start end of this edge + + v_of_edge : object + The destination end of this edge + + edge_attr : keywords arguments, optional + The attribute of the edge. + + Notes + ----- + Nodes of this edge will be automatically added to the graph, if they do not exist. + + See Also + -------- + add_edges + + Examples + -------- + + >>> G.add_edge(1,2) + >>> G.add_edge('Jack', 'Tom', weight=10) + + Add edge with attributes, edge weight, for example, + + >>> G.add_edge(1, 2, **{ + ... 'weight': 20 + ... }) + + """ + self._add_one_edge(u_of_edge, v_of_edge, edge_attr) + + def add_weighted_edge(self, u_of_edge, v_of_edge, weight): + """Add a weighted edge + + Parameters + ---------- + u_of_edge : start node + + v_of_edge : end node + + weight : weight value + + Examples + -------- + Add a weighted edge + + >>> G.add_weighted_edge( 1 , 3 , 1.0) + + """ + self._add_one_edge(u_of_edge, v_of_edge, edge_attr={"weight": weight}) + + def add_edges(self, edges_for_adding, edges_attr: List[Dict] = []): + """Add a list of edges. + + Parameters + ---------- + edges_for_adding : list of 2-element tuple + The edges for adding. Each element is a (u, v) tuple, and u, v are + start end and destination end, respectively. + + edges_attr : list of dict, optional + The corresponding attributes for each edge in *edges_for_adding*. + + Examples + -------- + Add a list of edges into *G* + + >>> G.add_edges([ + ... (1, 2), + ... (3, 4), + ... ('Jack', 'Tom') + ... ]) + + Add edge with attributes, for example, edge weight, + + >>> G.add_edges([(1,2), (2, 3)], edges_attr=[ + ... { + ... 'weight': 20 + ... }, + ... { + ... 'weight': 15 + ... } + ... ]) + + """ + if edges_attr is None: + edges_attr = [] + if not len(edges_attr) == 0: # Edges attributes included in input + assert len(edges_for_adding) == len( + edges_attr + ), "Edges and Attributes lists must have same length." + else: # Set empty attribute for each edge + edges_attr = [dict() for i in range(len(edges_for_adding))] + + for i in range(len(edges_for_adding)): + try: + edge = edges_for_adding[i] + attr = edges_attr[i] + assert len(edge) == 2, "Edge tuple {} must be 2-tuple.".format(edge) + self._add_one_edge(edge[0], edge[1], attr) + except Exception as err: + print(err) + + def add_edges_from(self, ebunch_to_add, **attr): + """Add all the edges in ebunch_to_add. + + Parameters + ---------- + ebunch_to_add : container of edges + Each edge given in the container will be added to the + graph. The edges must be given as 2-tuples (u, v) or + 3-tuples (u, v, d) where d is a dictionary containing edge data. + attr : keyword arguments, optional + Edge data (or labels or objects) can be assigned using + keyword arguments. + + See Also + -------- + add_edge : add a single edge + add_weighted_edges_from : convenient way to add weighted edges + + Notes + ----- + Adding the same edge twice has no effect but any edge data + will be updated when each duplicate edge is added. + + Edge attributes specified in an ebunch take precedence over + attributes specified via keyword arguments. + + Examples + -------- + >>> G = eg.Graph() # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> G.add_edges_from([(0, 1), (1, 2)]) # using a list of edge tuples + >>> e = zip(range(0, 3), range(1, 4)) + >>> G.add_edges_from(e) # Add the path graph 0-1-2-3 + + Associate data to edges + + >>> G.add_edges_from([(1, 2), (2, 3)], weight=3) + >>> G.add_edges_from([(3, 4), (1, 4)], label="WN2898") + """ + for e in ebunch_to_add: + ne = len(e) + if ne == 3: + u, v, dd = e + elif ne == 2: + u, v = e + dd = {} + else: + raise EasyGraphError(f"Edge tuple {e} must be a 2-tuple or 3-tuple.") + if u not in self._adj: + if u is None: + raise ValueError("None cannot be a node") + self._adj[u] = self.adjlist_inner_dict_factory() + self._pred[u] = self.adjlist_inner_dict_factory() + self._node[u] = self.node_attr_dict_factory() + if v not in self._adj: + if v is None: + raise ValueError("None cannot be a node") + self._adj[v] = self.adjlist_inner_dict_factory() + self._pred[v] = self.adjlist_inner_dict_factory() + self._node[v] = self.node_attr_dict_factory() + datadict = self._adj[u].get(v, self.edge_attr_dict_factory()) + datadict.update(attr) + datadict.update(dd) + self._adj[u][v] = datadict + self._pred[v][u] = datadict + + def add_edges_from_file(self, file, weighted=False): + """Added edges from file + For example, txt files, + + Each line is in form like: + a b 23.0 + which denotes an edge `a → b` with weight 23.0. + + Parameters + ---------- + file : string + The file path. + + weighted : boolean, optional (default : False) + If the file consists of weight information, set `True`. + The weight key will be set as 'weight'. + + Examples + -------- + + If `./club_network.txt` is: + + Jack Mary 23.0 + + Mary Tom 15.0 + + Tom Ben 20.0 + + Then add them to *G* + + >>> G.add_edges_from_file(file='./club_network.txt', weighted=True) + + + """ + import re + + with open(file, "r") as fp: + edges = fp.readlines() + if weighted: + for edge in edges: + edge = re.sub(",", " ", edge) + edge = edge.split() + try: + self.add_edge(edge[0], edge[1], weight=float(edge[2])) + except: + pass + else: + for edge in edges: + edge = re.sub(",", " ", edge) + edge = edge.split() + try: + self.add_edge(edge[0], edge[1]) + except: + pass + + def _add_one_edge(self, u_of_edge, v_of_edge, edge_attr: dict = {}): + u, v = u_of_edge, v_of_edge + # add nodes + if u not in self._node: + self._add_one_node(u) + if v not in self._node: + self._add_one_node(v) + # add the edge + datadict = self._adj[u].get(v, self.edge_attr_dict_factory()) + datadict.update(edge_attr) + self._adj[u][v] = datadict + self._pred[v][u] = datadict + + def remove_node(self, node_to_remove): + """Remove one node from your graph. + + Parameters + ---------- + node_to_remove : object + The node you want to remove. + + See Also + -------- + remove_nodes + + Examples + -------- + Remove node *Jack* from *G* + + >>> G.remove_node('Jack') + + """ + try: + succs = list(self._adj[node_to_remove]) + preds = list(self._pred[node_to_remove]) + del self._node[node_to_remove] + except KeyError: # Node not exists in self + raise KeyError("No node {} in graph.".format(node_to_remove)) + for succ in succs: # Remove edges start with node_to_remove + del self._pred[succ][node_to_remove] + for pred in preds: # Remove edges end with node_to_remove + del self._adj[pred][node_to_remove] + + # Remove this node + del self._adj[node_to_remove] + del self._pred[node_to_remove] + + def remove_nodes(self, nodes_to_remove: list): + """Remove nodes from your graph. + + Parameters + ---------- + nodes_to_remove : list of object + The list of nodes you want to remove. + + See Also + -------- + remove_node + + Examples + -------- + Remove node *[1, 2, 'a', 'b']* from *G* + + >>> G.remove_nodes([1, 2, 'a', 'b']) + + """ + for ( + node + ) in ( + nodes_to_remove + ): # If not all nodes included in graph, give up removing other nodes + assert node in self._node, "Remove Error: No node {} in graph".format(node) + for node in nodes_to_remove: + self.remove_node(node) + + def remove_edge(self, u, v): + """Remove one edge from your graph. + + Parameters + ---------- + u : object + The start end of the edge. + + v : object + The destination end of the edge. + + See Also + -------- + remove_edges + + Examples + -------- + Remove edge (1,2) from *G* + + >>> G.remove_edge(1,2) + + """ + try: + del self._adj[u][v] + del self._pred[v][u] + except KeyError: + raise KeyError("No edge {}-{} in graph.".format(u, v)) + + def remove_edges(self, edges_to_remove: [tuple]): + """Remove a list of edges from your graph. + + Parameters + ---------- + edges_to_remove : list of tuple + The list of edges you want to remove, + Each element is (u, v) tuple, which denote the start and destination + end of the edge, respectively. + + See Also + -------- + remove_edge + + Examples + -------- + Remove the edges *('Jack', 'Mary')* amd *('Mary', 'Tom')* from *G* + + >>> G.remove_edge([ + ... ('Jack', 'Mary'), + ... ('Mary', 'Tom') + ... ]) + + """ + for edge in edges_to_remove: + u, v = edge[:2] + self.remove_edge(u, v) + + def remove_edges_from(self, ebunch): + """Remove all edges specified in ebunch. + + Parameters + ---------- + ebunch: list or container of edge tuples + Each edge given in the list or container will be removed + from the graph. The edges can be: + + - 2-tuples (u, v) edge between u and v. + - 3-tuples (u, v, k) where k is ignored. + + See Also + -------- + remove_edge : remove a single edge + + Notes + ----- + Will fail silently if an edge in ebunch is not in the graph. + + Examples + -------- + >>> G = eg.path_graph(4) # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> ebunch = [(1, 2), (2, 3)] + >>> G.remove_edges_from(ebunch) + """ + for e in ebunch: + u, v = e[:2] # ignore edge data + if u in self._adj and v in self._adj[u]: + del self._adj[u][v] + del self._pred[v][u] + + def has_node(self, node): + """Returns whether a node exists + + Parameters + ---------- + node + + Returns + ------- + Bool : True (exist) or False (not exists) + + """ + return node in self._node + + def has_edge(self, u, v): + """Returns whether an edge exists + + Parameters + ---------- + u : start node + + v: end node + + Returns + ------- + Bool : True (exist) or False (not exists) + + """ + try: + return v in self._adj[u] + except KeyError: + return False + + def number_of_nodes(self): + """Returns the number of nodes. + + Returns + ------- + number_of_nodes : int + The number of nodes. + """ + return len(self._node) + + def is_directed(self): + """Returns True if graph is a directed_graph, False otherwise.""" + return True + + def is_multigraph(self): + """Returns True if graph is a multigraph, False otherwise.""" + return False + + def copy(self): + """Return a deep copy of the graph. + + Returns + ------- + copy : easygraph.DiGraph + A deep copy of the original graph. + + Examples + -------- + *G2* is a deep copy of *G1* + + >>> G2 = G1.copy() + + """ + G = self.__class__() + G.graph.update(self.graph) + for node, node_attr in self._node.items(): + G.add_node(node, **node_attr) + for u, nbrs in self._adj.items(): + for v, edge_data in nbrs.items(): + G.add_edge(u, v, **edge_data) + + return G + + def nodes_subgraph(self, from_nodes: list): + """Returns a subgraph of some nodes + + Parameters + ---------- + from_nodes : list of object + The nodes in subgraph. + + Returns + ------- + nodes_subgraph : easygraph.Graph + The subgraph consisting of *from_nodes*. + + Examples + -------- + + >>> G = eg.Graph() + >>> G.add_edges([(1,2), (2,3), (2,4), (4,5)]) + >>> G_sub = G.nodes_subgraph(from_nodes= [1,2,3]) + + """ + # Edge + from_nodes = set(from_nodes) + G = self.__class__() + G.graph.update(self.graph) + from_nodes = set(from_nodes) + for node in from_nodes: + try: + G.add_node(node, **self._node[node]) + except KeyError: + pass + + for v, edge_data in self._adj[node].items(): + if v in from_nodes: + G.add_edge(node, v, **edge_data) + return G + + def ego_subgraph(self, center): + """Returns an ego network graph of a node. + + Parameters + ---------- + center : object + The center node of the ego network graph + + Returns + ------- + ego_subgraph : easygraph.Graph + The ego network graph of *center*. + + + Examples + -------- + >>> G = eg.Graph() + >>> G.add_edges([ + ... ('Jack', 'Maria'), + ... ('Maria', 'Andy'), + ... ('Jack', 'Tom') + ... ]) + >>> G.ego_subgraph(center='Jack') + """ + neighbors_of_center = list(self.all_neighbors(center)) + neighbors_of_center.append(center) + return self.nodes_subgraph(from_nodes=neighbors_of_center) + + def to_index_node_graph(self, begin_index=0): + """Returns a deep copy of graph, with each node switched to its index. + + Considering that the nodes of your graph may be any possible hashable Python object, + you can get an isomorphic graph of the original one, with each node switched to its index. + + Parameters + ---------- + begin_index : int + The begin index of the index graph. + + Returns + ------- + G : easygraph.Graph + Deep copy of graph, with each node switched to its index. + + index_of_node : dict + Index of node + + node_of_index : dict + Node of index + + Examples + -------- + The following method returns this isomorphic graph and index-to-node dictionary + as well as node-to-index dictionary. + + >>> G = eg.Graph() + >>> G.add_edges([ + ... ('Jack', 'Maria'), + ... ('Maria', 'Andy'), + ... ('Jack', 'Tom') + ... ]) + >>> G_index_graph, index_of_node, node_of_index = G.to_index_node_graph() + + """ + G = self.__class__() + G.graph.update(self.graph) + index_of_node = dict() + node_of_index = dict() + for index, (node, node_attr) in enumerate(self._node.items()): + G.add_node(index + begin_index, **node_attr) + index_of_node[node] = index + begin_index + node_of_index[index + begin_index] = node + for u, nbrs in self._adj.items(): + for v, edge_data in nbrs.items(): + G.add_edge(index_of_node[u], index_of_node[v], **edge_data) + + return G, index_of_node, node_of_index + + def cpp(self): + G = DiGraphC() + G.graph.update(self.graph) + for u, attr in self.nodes.items(): + G.add_node(u, **attr) + for u, v, attr in self.edges: + G.add_edge(u, v, **attr) + G.generate_linkgraph() + return G + + +try: + import cpp_easygraph + + class DiGraphC(cpp_easygraph.DiGraph): + cflag = 1 + +except ImportError: + + class DiGraphC: + def __init__(self, **graph_attr): + print( + "Object cannot be instantiated because C extension has not been" + " successfully compiled and installed. Please refer to" + " https://github.com/easy-graph/Easy-Graph/blob/master/README.rst and" + " reinstall easygraph." + ) + raise RuntimeError diff --git a/easygraph/classes/directed_multigraph.py b/easygraph/classes/directed_multigraph.py new file mode 100644 index 0000000..06f17b8 --- /dev/null +++ b/easygraph/classes/directed_multigraph.py @@ -0,0 +1,420 @@ +from copy import deepcopy +from typing import Dict +from typing import List + +import easygraph as eg +import easygraph.convert as convert + +from easygraph.classes.directed_graph import DiGraph +from easygraph.classes.multigraph import MultiGraph +from easygraph.utils.exception import EasyGraphError + + +__all__ = ["MultiDiGraph"] + + +class MultiDiGraph(MultiGraph, DiGraph): + edge_key_dict_factory = dict + + def __init__(self, incoming_graph_data=None, multigraph_input=None, **attr): + """Initialize a graph with edges, name, or graph attributes. + + Parameters + ---------- + incoming_graph_data : input graph + Data to initialize graph. If incoming_graph_data=None (default) + an empty graph is created. The data can be an edge list, or any + EasyGraph graph object. If the corresponding optional Python + packages are installed the data can also be a NumPy matrix + or 2d ndarray, a SciPy sparse matrix, or a PyGraphviz graph. + + multigraph_input : bool or None (default None) + Note: Only used when `incoming_graph_data` is a dict. + If True, `incoming_graph_data` is assumed to be a + dict-of-dict-of-dict-of-dict structure keyed by + node to neighbor to edge keys to edge data for multi-edges. + A EasyGraphError is raised if this is not the case. + If False, :func:`to_easygraph_graph` is used to try to determine + the dict's graph data structure as either a dict-of-dict-of-dict + keyed by node to neighbor to edge data, or a dict-of-iterable + keyed by node to neighbors. + If None, the treatment for True is tried, but if it fails, + the treatment for False is tried. + + attr : keyword arguments, optional (default= no attributes) + Attributes to add to graph as key=value pairs. + + See Also + -------- + convert + + Examples + -------- + >>> G = eg.Graph() # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> G = eg.Graph(name="my graph") + >>> e = [(1, 2), (2, 3), (3, 4)] # list of edges + >>> G = eg.Graph(e) + + Arbitrary graph attribute pairs (key=value) may be assigned + + >>> G = eg.Graph(e, day="Friday") + >>> G.graph + {'day': 'Friday'} + + """ + self.edge_key_dict_factory = self.edge_key_dict_factory + # multigraph_input can be None/True/False. So check "is not False" + if isinstance(incoming_graph_data, dict) and multigraph_input is not False: + DiGraph.__init__(self) + try: + convert.from_dict_of_dicts( + incoming_graph_data, create_using=self, multigraph_input=True + ) + self.graph.update(attr) + except Exception as err: + if multigraph_input is True: + raise EasyGraphError( + f"converting multigraph_input raised:\n{type(err)}: {err}" + ) + DiGraph.__init__(self, incoming_graph_data, **attr) + else: + DiGraph.__init__(self, incoming_graph_data, **attr) + + def add_edge(self, u_for_edge, v_for_edge, key=None, **attr): + """Add an edge between u and v. + + The nodes u and v will be automatically added if they are + not already in the graph. + + Edge attributes can be specified with keywords or by directly + accessing the edge's attribute dictionary. See examples below. + + Parameters + ---------- + u_for_edge, v_for_edge : nodes + Nodes can be, for example, strings or numbers. + Nodes must be hashable (and not None) Python objects. + key : hashable identifier, optional (default=lowest unused integer) + Used to distinguish multiedges between a pair of nodes. + attr : keyword arguments, optional + Edge data (or labels or objects) can be assigned using + keyword arguments. + + Returns + ------- + The edge key assigned to the edge. + + See Also + -------- + add_edges_from : add a collection of edges + + Notes + ----- + To replace/update edge data, use the optional key argument + to identify a unique edge. Otherwise a new edge will be created. + + EasyGraph algorithms designed for weighted graphs cannot use + multigraphs directly because it is not clear how to handle + multiedge weights. Convert to Graph using edge attribute + 'weight' to enable weighted graph algorithms. + + Default keys are generated using the method `new_edge_key()`. + This method can be overridden by subclassing the base class and + providing a custom `new_edge_key()` method. + + Examples + -------- + The following all add the edge e=(1, 2) to graph G: + + >>> G = eg.MultiDiGraph() + >>> e = (1, 2) + >>> key = G.add_edge(1, 2) # explicit two-node form + >>> G.add_edge(*e) # single edge as tuple of two nodes + 1 + >>> G.add_edges_from([(1, 2)]) # add edges from iterable container + [2] + + Associate data to edges using keywords: + + >>> key = G.add_edge(1, 2, weight=3) + >>> key = G.add_edge(1, 2, key=0, weight=4) # update data for key=0 + >>> key = G.add_edge(1, 3, weight=7, capacity=15, length=342.7) + + For non-string attribute keys, use subscript notation. + + >>> ekey = G.add_edge(1, 2) + >>> G[1][2][0].update({0: 5}) + >>> G.edges[1, 2, 0].update({0: 5}) + + >>> + >>> + """ + u, v = u_for_edge, v_for_edge + if "attr" in attr: + temp = attr.get("attr") + attr = temp if temp != None else {} + # add nodes + if u not in self._adj: + if u is None: + raise ValueError("None cannot be a node") + self._adj[u] = self.adjlist_inner_dict_factory() + self._pred[u] = self.adjlist_inner_dict_factory() + self._node[u] = self.node_attr_dict_factory() + if v not in self._adj: + if v is None: + raise ValueError("None cannot be a node") + self._adj[v] = self.adjlist_inner_dict_factory() + self._pred[v] = self.adjlist_inner_dict_factory() + self._node[v] = self.node_attr_dict_factory() + if key is None: + key = self.new_edge_key(u, v) + if v in self._adj[u]: + keydict = self._adj[u][v] + datadict = keydict.get(key, self.edge_key_dict_factory()) + datadict.update(attr) + keydict[key] = datadict + else: + # selfloops work this way without special treatment + datadict = self.edge_attr_dict_factory() + datadict.update(attr) + keydict = self.edge_key_dict_factory() + keydict[key] = datadict + self._adj[u][v] = keydict + self._pred[v][u] = keydict + return key + + def remove_edge(self, u, v, key=None): + """Remove an edge between u and v. + + Parameters + ---------- + u, v : nodes + Remove an edge between nodes u and v. + key : hashable identifier, optional (default=None) + Used to distinguish multiple edges between a pair of nodes. + If None remove a single (arbitrary) edge between u and v. + + Raises + ------ + EasyGraphError + If there is not an edge between u and v, or + if there is no edge with the specified key. + + See Also + -------- + remove_edges_from : remove a collection of edges + + Examples + -------- + >>> G = eg.MultiDiGraph() + >>> G.add_edges_from([(1, 2), (1, 2), (1, 2)]) # key_list returned + [0, 1, 2] + >>> G.remove_edge(1, 2) # remove a single (arbitrary) edge + + For edges with keys + + >>> G = eg.MultiDiGraph() + >>> G.add_edge(1, 2, key="first") + 'first' + >>> G.add_edge(1, 2, key="second") + 'second' + >>> G.remove_edge(1, 2, key="second") + + """ + try: + d = self._adj[u][v] + except KeyError as err: + raise EasyGraphError(f"The edge {u}-{v} is not in the graph.") from err + # remove the edge with specified data + if key is None: + d.popitem() + else: + try: + del d[key] + except KeyError as err: + msg = f"The edge {u}-{v} with key {key} is not in the graph." + raise EasyGraphError(msg) from err + if len(d) == 0: + # remove the key entries if last edge + del self._adj[u][v] + del self._pred[v][u] + + @property + def edges(self): + edges = list() + for n, nbrs in self._adj.items(): + for nbr, kd in nbrs.items(): + for k, dd in kd.items(): + edges.append((n, nbr, k, dd)) + return edges + + out_edges = edges + + @property + def in_edges(self): + edges = list() + for n, nbrs in self._adj.items(): + for nbr, kd in nbrs.items(): + for k, dd in kd.items(): + edges.append((nbr, n, k)) + return edges + + @property + def degree(self, weight="weight"): + degree = dict() + if weight is None: + for n in self._node: + succs = self._adj[n] + preds = self._pred[n] + deg = sum(len(keys) for keys in succs.values()) + sum( + len(keys) for keys in preds.values() + ) + degree[n] = deg + else: + for n in self._node: + succs = self._adj[n] + preds = self._pred[n] + deg = sum( + d.get(weight, 1) + for key_dict in succs.values() + for d in key_dict.values() + ) + sum( + d.get(weight, 1) + for key_dict in preds.values() + for d in key_dict.values() + ) + degree[n] = deg + + @property + def in_degree(self, weight="weight"): + degree = dict() + if weight is None: + for n in self._node: + preds = self._pred[n] + deg = sum(len(keys) for keys in preds.values()) + degree[n] = deg + else: + for n in self._node: + preds = self._pred[n] + deg = sum( + d.get(weight, 1) + for key_dict in preds.values() + for d in key_dict.values() + ) + degree[n] = deg + + @property + def out_degree(self, weight="weight"): + degree = dict() + if weight is None: + for n in self._node: + succs = self._adj[n] + deg = sum(len(keys) for keys in succs.values()) + degree[n] = deg + else: + for n in self._node: + succs = self._adj[n] + deg = sum( + d.get(weight, 1) + for key_dict in succs.values() + for d in key_dict.values() + ) + degree[n] = deg + + def is_multigraph(self): + """Returns True if graph is a multigraph, False otherwise.""" + return True + + def is_directed(self): + """Returns True if graph is directed, False otherwise.""" + return True + + def to_undirected(self, reciprocal=False): + """Returns an undirected representation of the multidigraph. + + Parameters + ---------- + reciprocal : bool (optional) + If True only keep edges that appear in both directions + in the original digraph. + + Returns + ------- + G : MultiGraph + An undirected graph with the same name and nodes and + with edge (u, v, data) if either (u, v, data) or (v, u, data) + is in the digraph. If both edges exist in digraph and + their edge data is different, only one edge is created + with an arbitrary choice of which edge data to use. + You must check and correct for this manually if desired. + + See Also + -------- + MultiGraph, add_edge, add_edges_from + + Notes + ----- + This returns a "deepcopy" of the edge, node, and + graph attributes which attempts to completely copy + all of the data and references. + + This is in contrast to the similar D=MultiDiGraph(G) which + returns a shallow copy of the data. + + See the Python copy module for more information on shallow + and deep copies, https://docs.python.org/3/library/copy.html. + + Warning: If you have subclassed MultiDiGraph to use dict-like + objects in the data structure, those changes do not transfer + to the MultiGraph created by this method. + + Examples + -------- + >>> G = eg.path_graph(2) # or MultiGraph, etc + >>> H = G.to_directed() + >>> list(H.edges) + [(0, 1), (1, 0)] + >>> G2 = H.to_undirected() + >>> list(G2.edges) + [(0, 1)] + """ + G = eg.MultiGraph() + G.graph.update(deepcopy(self.graph)) + G.add_nodes_from((n, deepcopy(d)) for n, d in self._node.items()) + if reciprocal is True: + G.add_edges_from( + (u, v, key, deepcopy(data)) + for u, nbrs in self._adj.items() + for v, keydict in nbrs.items() + for key, data in keydict.items() + if v in self._pred[u] and key in self._pred[u][v] + ) + else: + G.add_edges_from( + (u, v, key, deepcopy(data)) + for u, nbrs in self._adj.items() + for v, keydict in nbrs.items() + for key, data in keydict.items() + ) + return G + + def reverse(self, copy=True): + """Returns the reverse of the graph. + + The reverse is a graph with the same nodes and edges + but with the directions of the edges reversed. + + Parameters + ---------- + copy : bool optional (default=True) + If True, return a new DiGraph holding the reversed edges. + If False, the reverse graph is created using a view of + the original graph. + """ + if copy: + H = self.__class__() + H.graph.update(deepcopy(self.graph)) + H.add_nodes_from((n, deepcopy(d)) for n, d in self._node.items()) + H.add_edges_from((v, u, k, deepcopy(d)) for u, v, k, d in self.edges) + return H + return eg.graphviews.reverse_view(self) diff --git a/easygraph/classes/graph.py b/easygraph/classes/graph.py new file mode 100644 index 0000000..07caabd --- /dev/null +++ b/easygraph/classes/graph.py @@ -0,0 +1,1579 @@ +import copy +import warnings + +from copy import deepcopy +from typing import Dict +from typing import List +from typing import Tuple + +import easygraph as eg +import easygraph.convert as convert + +from easygraph.utils.exception import EasyGraphError +from easygraph.utils.sparse import sparse_dropout + + +class Graph: + """ + Base class for undirected graphs. + + Nodes are allowed for any hashable Python objects, including int, string, dict, etc. + Edges are stored as Python dict type, with optional key/value attributes. + + Parameters + ---------- + graph_attr : keywords arguments, optional (default : None) + Attributes to add to graph as key=value pairs. + + See Also + -------- + DiGraph + + Examples + -------- + Create an empty undirected graph with no nodes and edges. + + >>> G = eg.Graph() + + Create a deep copy graph *G2* from existing Graph *G1*. + + >>> G2 = G1.copy() + + Create an graph with attributes. + + >>> G = eg.Graph(name='Karate Club', date='2020.08.21') + + **Attributes:** + + Returns the adjacency matrix of the graph. + + >>> G.adj + + Returns all the nodes with their attributes. + + >>> G.nodes + + Returns all the edges with their attributes. + + >>> G.edges + + """ + + gnn_data_dict_factory = dict + raw_selfloop_dict = dict + graph_attr_dict_factory = dict + node_dict_factory = dict + node_attr_dict_factory = dict + adjlist_outer_dict_factory = dict + adjlist_inner_dict_factory = dict + edge_attr_dict_factory = dict + node_index_dict = dict + + def __init__(self, incoming_graph_data=None, extra_selfloop=False, **graph_attr): + self.graph = self.graph_attr_dict_factory() + self._node = self.node_dict_factory() + self._adj = self.adjlist_outer_dict_factory() + self._raw_selfloop_dict = self.raw_selfloop_dict() + self.extra_selfloop = extra_selfloop + self._ndata = self.gnn_data_dict_factory() + self.cache = {} + self._node_index = self.node_index_dict() + self.cflag = 0 + self._id = 0 + self.device = "cpu" + if incoming_graph_data is not None: + convert.to_easygraph_graph(incoming_graph_data, create_using=self) + self.graph.update(graph_attr) + + def __iter__(self): + return iter(self._node) + + def __len__(self): + return len(self._node) + + def __contains__(self, node): + try: + return node in self._node + except TypeError: + return False + + def __getitem__(self, node): + # return list(self._adj[node].keys()) + return self._adj[node] + + @property + def ndata(self): + return self._ndata + + @property + def adj(self): + """ + Return the adjacency matrix + """ + return self._adj + + @property + def nodes(self): + """ + return [node for node in self._node] + """ + return self._node + + @property + def node_index(self): + return self._node_index + + @property + def edges(self): + """ + Return an edge list + """ + if self.cache.get("edges") != None: + return self.cache["edges"] + edge_lst = list() + seen = set() + for u in self._adj: + for v in self._adj[u]: + if (u, v) not in seen: + seen.add((u, v)) + seen.add((v, u)) + edge_lst.append((u, v, self._adj[u][v])) + del seen + self.cache["edge"] = edge_lst + return self.cache["edge"] + + @property + def name(self): + """String identifier of the graph. + + This graph attribute appears in the attribute dict G.graph + keyed by the string `"name"`. as well as an attribute (technically + a property) `G.name`. This is entirely user controlled. + """ + return self.graph.get("name", "") + + @property + def e_both_side(self, weight="weight") -> Tuple[List[List], List[float]]: + r"""Return the list of edges including both directions.""" + if self.cache.get("e_both_side") != None: + return self.cache["e_both_side"] + edges = list() + weights = list() + seen = set() + for u in self._adj: + for v in self._adj[u]: + if (u, v) not in seen: + seen.add((u, v)) + seen.add((v, u)) + edges.append([u, v]) + edges.append([v, u]) + if weight not in self._adj[u][v]: + warnings.warn("There is no property %s,default to 1" % (weight)) + weights.append(1.0) + weights.append(1.0) + else: + weights.append(self._adj[u][v][weight]) + weights.append(self._adj[v][u][weight]) + self.cache["e_both_side"] = (edges, weights) + return self.cache["e_both_side"] + + @property + def A(self): + r"""Return the adjacency matrix :math:`\mathbf{A}` of the sample graph with ``torch.sparse_coo_tensor`` format. Size :math:`(|\mathcal{V}|, |\mathcal{V}|)`. + """ + import torch + + if self.cache.get("A", None) is None: + if len(self.edges) == 0: + self.cache["A"] = torch.sparse_coo_tensor( + size=(len(self.nodes), len(self.nodes)), device=self.device + ) + else: + if self.cache.get("e_both_side") is not None: + e_list, e_weight = self.cache["e_both_side"] + + else: + e_list, e_weight = self.e_both_side + + node_size = len(self.nodes) + self.cache["A"] = torch.sparse_coo_tensor( + indices=torch.tensor(e_list, dtype=torch.int).t(), + values=torch.tensor(e_weight), + size=(node_size, node_size), + device=self.device, + ).coalesce() + return self.cache["A"] + + @property + def D_v_neg_1_2( + self, + ): + r"""Return the normalized diagonal matrix of vertex degree :math:`\mathbf{D}_v^{-\frac{1}{2}}` with ``torch.sparse_coo_tensor`` format. Size :math:`(|\mathcal{V}|, |\mathcal{V}|)`. + """ + import torch + + if self.cache.get("D_v_neg_1_2") is None: + if self.cache.get("D_v_value") is None: + self.cache["D_v_value"] = ( + torch.sparse.sum(self.A, dim=1).to_dense().view(-1) + ) + # self.cache["D_v_value"] = torch.tensor(list(self.degree().values())).float() + + _mat = self.cache["D_v_value"] + # _mat = _tmp + _val = _mat**-0.5 + _val[torch.isinf(_val)] = 0 + nodes_num = len(self.nodes) + self.cache["D_v_neg_1_2"] = torch.sparse_coo_tensor( + torch.arange(0, len(self.nodes)).view(1, -1).repeat(2, 1), + _val, + torch.Size([nodes_num, nodes_num]), + device=self.device, + ).coalesce() + return self.cache["D_v_neg_1_2"] + + @property + def index2node(self): + """ + Assign an integer index for each node (start from 0) + """ + if self.cache.get("index2node", None) is None: + index2node_dict = {} + index = 0 + # for index in range(0, len(self.nodes)): + + for index, n in enumerate(self.nodes): + index2node_dict[index] = n + # index += 1 + self.cache["index2node"] = index2node_dict + return self.cache["index2node"] + + @property + def node_index(self): + """ + Assign an integer index for each node (start from 0) + """ + if self.cache.get("node_index", None) is None: + node2index_dict = {} + index = 0 + for n in self.nodes: + node2index_dict[n] = index + index += 1 + self.cache["node_index"] = node2index_dict + return self.cache["node_index"] + + @property + def e(self) -> Tuple[List[List[int]], List[float]]: + r"""Return the edge list, weight list and property list in the graph.""" + + if self.cache.get("e", None) is None: + node_index = self.node_index + e_list = [ + (node_index[src_idx], node_index[dst_idx]) + for src_idx, dst_idx, d in self.edges + ] + w_list = [] + e_property_list = [] + v_property_list = [] + + node_size = len(self.nodes) + for i in range(0, node_size): + v_property_list.append(self.nodes[self.index2node[i]]) + + for d in self.edges: + if "weight" not in d[2]: + w_list.append(1.0) + e_property_list.append(d[2]) + else: + w_list.append(d[2]["weight"]) + tmp_dict = copy.deepcopy(d[2]) + del tmp_dict["weight"] + e_property_list.append(tmp_dict) + + self.cache["e"] = e_list, w_list, v_property_list, e_property_list + return self.cache["e"] + + @property + def D_v(self): + r"""Return the diagonal matrix of vertex degree :math:`\mathbf{D}_v` with ``torch.sparse_coo_tensor`` format. Size :math:`(|\mathcal{V}|, |\mathcal{V}|)`. + """ + import torch + + if self.cache.get("D_v") is None: + # print("self.A:",self.A) + _tmp = torch.sparse.sum(self.A, dim=1).to_dense().clone().view(-1) + + nodes_num = len(self.nodes) + self.cache["D_v"] = torch.sparse_csr_tensor( + torch.arange(0, nodes_num + 1), + torch.arange(0, nodes_num), + _tmp, + torch.Size([nodes_num, nodes_num]), + device=self.device, + ) + + # self.cache["D_v"] = torch.sparse_coo_tensor( + # torch.arange(0, len(self.nodes)).view(1, -1).repeat(2, 1), + # _tmp, + # torch.Size([len(self.nodes), len(self.nodes)]), + # device=self.device, + # ).coalesce() + return self.cache["D_v"] + + def add_extra_selfloop(self): + r"""Add extra selfloops to the graph.""" + self._has_extra_selfloop = True + self._clear_cache() + + def remove_extra_selfloop(self): + r"""Remove extra selfloops from the graph.""" + self._has_extra_selfloop = False + self._clear_cache() + + def remove_selfloop(self): + r"""Remove all selfloops from the graph.""" + self._raw_selfloop_dict.clear() + self.remove_extra_selfloop() + self._clear_cache() + + def nbr_v(self, v_idx: int) -> Tuple[List[int], List[float]]: + r"""Return a vertex list of the neighbors of the vertex ``v_idx``. + + Args: + ``v_idx`` (``int``): The index of the vertex. + """ + return self.N_v(v_idx).cpu().numpy().tolist() + + def N_v(self, v_idx: int) -> Tuple[List[int], List[float]]: + import torch + + r"""Return the neighbors of the vertex ``v_idx`` with ``torch.Tensor`` format. + + Args: + ``v_idx`` (``int``): The index of the vertex. + """ + sub_v_set = self.A[v_idx]._indices()[0].clone() + return sub_v_set + + def clone(self): + r"""Clone the graph.""" + # _g = Graph(self.num_v, extra_selfloop=self._has_extra_selfloop, device=self.device) + # _g=self.__class__() + # _g.device="cpu" + # _g.extra_selfloop=False + # _g.edges = deepcopy(self.edges) + # _g.cache = deepcopy(self.cache) + return self.copy() + + @name.setter + def name(self, s): + """ + Set graph name + + Parameters + ---------- + s : name + """ + self.graph["name"] = s + + def degree(self, weight="weight"): + """Returns the weighted degree of of each node. + + Parameters + ---------- + weight : string, optional (default: 'weight') + Weight key of the original weighted graph. + + Returns + ------- + degree : dict + Each node's (key) weighted degree (value). + + Notes + ----- + If the graph is not weighted, all the weights will be regarded as 1. + + Examples + -------- + You can call with no attributes, if 'weight' is the weight key: + + >>> G.degree() + + if you have customized weight key 'weight_1'. + + >>> G.degree(weight='weight_1') + + """ + if self.cache.get("degree") != None: + return self.cache["degree"] + degree = dict() + for u, v, d in self.edges: + if u in degree: + degree[u] += d.get(weight, 1) + else: + degree[u] = d.get(weight, 1) + if v in degree: + degree[v] += d.get(weight, 1) + else: + degree[v] = d.get(weight, 1) + + # For isolated nodes + for node in self.nodes: + if node not in degree: + degree[node] = 0 + self.cache["degree"] = degree + return degree + + def order(self): + """Returns the number of nodes in the graph. + + Returns + ------- + nnodes : int + The number of nodes in the graph. + + See Also + -------- + number_of_nodes: identical method + __len__: identical method + + Examples + -------- + >>> G = eg.path_graph(3) # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> G.order() + 3 + """ + return len(self._node) + + def size(self, weight=None): + """Returns the number of edges or total of all edge weights. + + Parameters + ----------- + weight : String or None, optional + The weight key. If None, it will calculate the number of + edges, instead of total of all edge weights. + + Returns + ------- + size : int or float, optional (default: None) + The number of edges or total of all edge weights. + + Examples + -------- + + Returns the number of edges in G: + + >>> G.size() + + Returns the total of all edge weights in G: + + >>> G.size(weight='weight') + + """ + if self.cache.get("size") != None: + return self.cache["size"] + s = sum(d for v, d in self.degree(weight=weight).items()) + self.cache["size"] = s // 2 if weight is None else s / 2 + return self.cache["size"] + + # GCN Laplacian smoothing + @property + def L_GCN(self): + r"""Return the GCN Laplacian matrix :math:`\mathcal{L}_{GCN}` of the graph with ``torch.sparse_coo_tensor`` format. Size :math:`(|\mathcal{V}|, |\mathcal{V}|)`. + + .. math:: + \mathcal{L}_{GCN} = \mathbf{\hat{D}}_v^{-\frac{1}{2}} \mathbf{\hat{A}} \mathbf{\hat{D}}_v^{-\frac{1}{2}} + + """ + import torch + + if self.cache.get("L_GCN") is None: + # self.add_extra_selfloop() + self.cache["L_GCN"] = ( + self.D_v_neg_1_2.mm(self.A).mm(self.D_v_neg_1_2).coalesce() + ) + return self.cache["L_GCN"] + + def smoothing_with_GCN(self, X, drop_rate=0.0): + r"""Return the smoothed feature matrix with GCN Laplacian matrix :math:`\mathcal{L}_{GCN}`. + + Args: + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in adjacency matrix with probability ``drop_rate``. Default: ``0.0``. + """ + import torch + + if drop_rate > 0.0: + L_GCN = sparse_dropout(self.L_GCN, drop_rate) + else: + L_GCN = self.L_GCN + + return torch.sparse.mm(L_GCN, X) + + def number_of_edges(self, u=None, v=None): + """Returns the number of edges between two nodes. + + Parameters + ---------- + u, v : nodes, optional (default=all edges) + If u and v are specified, return the number of edges between + u and v. Otherwise return the total number of all edges. + + Returns + ------- + nedges : int + The number of edges in the graph. If nodes `u` and `v` are + specified return the number of edges between those nodes. If + the graph is directed, this only returns the number of edges + from `u` to `v`. + + See Also + -------- + size + + Examples + -------- + For undirected graphs, this method counts the total number of + edges in the graph: + + >>> G = eg.path_graph(4) + >>> G.number_of_edges() + 3 + + If you specify two nodes, this counts the total number of edges + joining the two nodes: + + >>> G.number_of_edges(0, 1) + 1 + + For directed graphs, this method can count the total number of + directed edges from `u` to `v`: + + >>> G = eg.DiGraph() + >>> G.add_edge(0, 1) + >>> G.add_edge(1, 0) + >>> G.number_of_edges(0, 1) + 1 + + """ + if u is None: + return int(self.size()) + if v in self._adj[u]: + return 1 + return 0 + + def nbunch_iter(self, nbunch=None): + """Returns an iterator over nodes contained in nbunch that are + also in the graph. + + The nodes in nbunch are checked for membership in the graph + and if not are silently ignored. + + Parameters + ---------- + nbunch : single node, container, or all nodes (default= all nodes) + The view will only report edges incident to these nodes. + + Returns + ------- + niter : iterator + An iterator over nodes in nbunch that are also in the graph. + If nbunch is None, iterate over all nodes in the graph. + + Raises + ------ + EasyGraphError + If nbunch is not a node or sequence of nodes. + If a node in nbunch is not hashable. + + See Also + -------- + Graph.__iter__ + + Notes + ----- + When nbunch is an iterator, the returned iterator yields values + directly from nbunch, becoming exhausted when nbunch is exhausted. + + To test whether nbunch is a single node, one can use + "if nbunch in self:", even after processing with this routine. + + If nbunch is not a node or a (possibly empty) sequence/iterator + or None, a :exc:`EasyGraphError` is raised. Also, if any object in + nbunch is not hashable, a :exc:`EasyGraphError` is raised. + """ + if nbunch is None: # include all nodes via iterator + bunch = iter(self._adj) + elif nbunch in self: # if nbunch is a single node + bunch = iter([nbunch]) + else: # if nbunch is a sequence of nodes + + def bunch_iter(nlist, adj): + try: + for n in nlist: + if n in adj: + yield n + except TypeError as err: + exc, message = err, err.args[0] + # capture error for non-sequence/iterator nbunch. + if "iter" in message: + exc = EasyGraphError( + "nbunch is not a node or a sequence of nodes." + ) + # capture error for unhashable node. + if "hashable" in message: + exc = EasyGraphError( + f"Node {n} in sequence nbunch is not a valid node." + ) + raise exc + + bunch = bunch_iter(nbunch, self._adj) + return bunch + + def neighbors(self, node): + """Returns an iterator of a node's neighbors. + + Parameters + ---------- + node : Hashable + The target node. + + Returns + ------- + neighbors : iterator + An iterator of a node's neighbors. + + Examples + -------- + >>> G = eg.Graph() + >>> G.add_edges([(1,2), (2,3), (2,4)]) + >>> for neighbor in G.neighbors(node=2): + ... print(neighbor) + + """ + try: + return iter(self._adj[node]) + except KeyError: + print("No node {}".format(node)) + + all_neighbors = neighbors + + def add_node(self, node_for_adding, **node_attr): + """Add one node + + Add one node, type of which is any hashable Python object, such as int, string, dict, or even Graph itself. + You can add with node attributes using Python dict type. + + Parameters + ---------- + node_for_adding : any hashable Python object + Nodes for adding. + + node_attr : keywords arguments, optional + The node attributes. + You can customize them with different key-value pairs. + + See Also + -------- + add_nodes + + Examples + -------- + >>> G.add_node('a') + >>> G.add_node('hello world') + >>> G.add_node('Jack', age=10) + + >>> G.add_node('Jack', **{ + ... 'age': 10, + ... 'gender': 'M' + ... }) + + """ + if "node_attr" in node_attr: + node_attr = node_attr.get("node_attr") + self._add_one_node(node_for_adding, node_attr) + self._clear_cache() + + def add_nodes(self, nodes_for_adding: list, nodes_attr: List[Dict] = []): + """Add nodes with a list of nodes. + + Parameters + ---------- + nodes_for_adding : list + + nodes_attr : list of dict + The corresponding attribute for each of *nodes_for_adding*. + + See Also + -------- + add_node + + Examples + -------- + Add nodes with a list of nodes. + You can add with node attributes using a list of Python dict type, + each of which is the attribute of each node, respectively. + + >>> G.add_nodes([1, 2, 'a', 'b']) + >>> G.add_nodes(range(1, 200)) + + >>> G.add_nodes(['Jack', 'Tom', 'Lily'], nodes_attr=[ + ... { + ... 'age': 10, + ... 'gender': 'M' + ... }, + ... { + ... 'age': 11, + ... 'gender': 'M' + ... }, + ... { + ... 'age': 10, + ... 'gender': 'F' + ... } + ... ]) + + """ + if not len(nodes_attr) == 0: # Nodes attributes included in input + assert len(nodes_for_adding) == len( + nodes_attr + ), "Nodes and Attributes lists must have same length." + else: # Set empty attribute for each node + nodes_attr = [dict() for i in range(len(nodes_for_adding))] + + for i in range(len(nodes_for_adding)): + try: + self._add_one_node(nodes_for_adding[i], nodes_attr[i]) + except Exception as err: + print(err) + pass + self._clear_cache() + + def add_nodes_from(self, nodes_for_adding, **attr): + """Add multiple nodes. + + Parameters + ---------- + nodes_for_adding : iterable container + A container of nodes (list, dict, set, etc.). + OR + A container of (node, attribute dict) tuples. + Node attributes are updated using the attribute dict. + attr : keyword arguments, optional (default= no attributes) + Update attributes for all nodes in nodes. + Node attributes specified in nodes as a tuple take + precedence over attributes specified via keyword arguments. + + See Also + -------- + add_node + + Examples + -------- + >>> G = eg.Graph() # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> G.add_nodes_from("Hello") + >>> K3 = eg.Graph([(0, 1), (1, 2), (2, 0)]) + >>> G.add_nodes_from(K3) + >>> sorted(G.nodes(), key=str) + [0, 1, 2, 'H', 'e', 'l', 'o'] + + Use keywords to update specific node attributes for every node. + + >>> G.add_nodes_from([1, 2], size=10) + >>> G.add_nodes_from([3, 4], weight=0.4) + + Use (node, attrdict) tuples to update attributes for specific nodes. + + >>> G.add_nodes_from([(1, dict(size=11)), (2, {"color": "blue"})]) + >>> G.nodes[1]["size"] + 11 + >>> H = eg.Graph() + >>> H.add_nodes_from(G.nodes(data=True)) + >>> H.nodes[1]["size"] + 11 + + """ + for n in nodes_for_adding: + try: + newnode = n not in self._node + newdict = attr + except TypeError: + n, ndict = n + newnode = n not in self._node + newdict = attr.copy() + newdict.update(ndict) + if newnode: + if n is None: + raise ValueError("None cannot be a node") + self._adj[n] = self.adjlist_inner_dict_factory() + self._node[n] = self.node_attr_dict_factory() + self._node[n].update(newdict) + self._clear_cache() + + def _add_one_node(self, one_node_for_adding, node_attr: dict = {}): + node = one_node_for_adding + assert node != None, "Nodes can not be None." + hash(node) + if node not in self._node: + self._node_index[node] = self._id + self._id += 1 + self._adj[node] = self.adjlist_inner_dict_factory() + attr_dict = self._node[node] = self.node_attr_dict_factory() + attr_dict.update(node_attr) + else: # If already exists, there is no complain and still updating the node attribute + self._node[node].update(node_attr) + self._clear_cache() + + def add_edge(self, u_of_edge, v_of_edge, **edge_attr): + """Add one edge. + + Parameters + ---------- + u_of_edge : object + One end of this edge + + v_of_edge : object + The other one end of this edge + + edge_attr : keywords arguments, optional + The attribute of the edge. + + Notes + ----- + Nodes of this edge will be automatically added to the graph, if they do not exist. + + See Also + -------- + add_edges + + Examples + -------- + + >>> G.add_edge(1,2) + >>> G.add_edge('Jack', 'Tom', weight=10) + + Add edge with attributes, edge weight, for example, + + >>> G.add_edge(1, 2, **{ + ... 'weight': 20 + ... }) + + """ + if "edge_attr" in edge_attr: + edge_attr = edge_attr.get("edge_attr") + self._add_one_edge(u_of_edge, v_of_edge, edge_attr) + self._clear_cache() + + def add_weighted_edge(self, u_of_edge, v_of_edge, weight): + """Add a weighted edge + + Parameters + ---------- + u_of_edge : start node + + v_of_edge : end node + + weight : weight value + + Examples + -------- + Add a weighted edge + + >>> G.add_weighted_edge( 1 , 3 , 1.0) + + """ + self._add_one_edge(u_of_edge, v_of_edge, edge_attr={"weight": weight}) + self._clear_cache() + + def add_edges(self, edges_for_adding, edges_attr: List[Dict] = []): + """Add a list of edges. + + Parameters + ---------- + edges_for_adding : list of 2-element tuple + The edges for adding. Each element is a (u, v) tuple, and u, v are + two ends of the edge. + + edges_attr : list of dict, optional + The corresponding attributes for each edge in *edges_for_adding*. + + Examples + -------- + Add a list of edges into *G* + + >>> G.add_edges([ + ... (1, 2), + ... (3, 4), + ... ('Jack', 'Tom') + ... ]) + + Add edge with attributes, for example, edge weight, + + >>> G.add_edges([(1,2), (2, 3)], edges_attr=[ + ... { + ... 'weight': 20 + ... }, + ... { + ... 'weight': 15 + ... } + ... ]) + + """ + if edges_attr is None: + edges_attr = [] + if not len(edges_attr) == 0: # Edges attributes included in input + assert len(edges_for_adding) == len( + edges_attr + ), "Edges and Attributes lists must have same length." + else: # Set empty attribute for each edge + edges_attr = [dict() for i in range(len(edges_for_adding))] + + for i in range(len(edges_for_adding)): + try: + edge = edges_for_adding[i] + attr = edges_attr[i] + assert len(edge) == 2, "Edge tuple {} must be 2-tuple.".format(edge) + self._add_one_edge(edge[0], edge[1], attr) + except Exception as err: + print(err) + self._clear_cache() + + def add_edges_from(self, ebunch_to_add, **attr): + """Add all the edges in ebunch_to_add. + + Parameters + ---------- + ebunch_to_add : container of edges + Each edge given in the container will be added to the + graph. The edges must be given as 2-tuples (u, v) or + 3-tuples (u, v, d) where d is a dictionary containing edge data. + attr : keyword arguments, optional + Edge data (or labels or objects) can be assigned using + keyword arguments. + + See Also + -------- + add_edge : add a single edge + add_weighted_edges_from : convenient way to add weighted edges + + Notes + ----- + Adding the same edge twice has no effect but any edge data + will be updated when each duplicate edge is added. + + Edge attributes specified in an ebunch take precedence over + attributes specified via keyword arguments. + + Examples + -------- + >>> G = eg.Graph() # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> G.add_edges_from([(0, 1), (1, 2)]) # using a list of edge tuples + >>> e = zip(range(0, 3), range(1, 4)) + >>> G.add_edges_from(e) # Add the path graph 0-1-2-3 + + Associate data to edges + + >>> G.add_edges_from([(1, 2), (2, 3)], weight=3) + >>> G.add_edges_from([(3, 4), (1, 4)], label="WN2898") + """ + for e in ebunch_to_add: + ne = len(e) + if ne == 3: + u, v, dd = e + elif ne == 2: + u, v = e + dd = {} # doesn't need edge_attr_dict_factory + else: + raise EasyGraphError(f"Edge tuple {e} must be a 2-tuple or 3-tuple.") + if u not in self._node: + if u is None: + raise ValueError("None cannot be a node") + self._adj[u] = self.adjlist_inner_dict_factory() + self._node[u] = self.node_attr_dict_factory() + if v not in self._node: + if v is None: + raise ValueError("None cannot be a node") + self._adj[v] = self.adjlist_inner_dict_factory() + self._node[v] = self.node_attr_dict_factory() + datadict = self._adj[u].get(v, self.edge_attr_dict_factory()) + datadict.update(attr) + datadict.update(dd) + self._adj[u][v] = datadict + self._adj[v][u] = datadict + self._clear_cache() + + def add_weighted_edges_from(self, ebunch_to_add, weight="weight", **attr): + """Add weighted edges in `ebunch_to_add` with specified weight attr + + Parameters + ---------- + ebunch_to_add : container of edges + Each edge given in the list or container will be added + to the graph. The edges must be given as 3-tuples (u, v, w) + where w is a number. + weight : string, optional (default= 'weight') + The attribute name for the edge weights to be added. + attr : keyword arguments, optional (default= no attributes) + Edge attributes to add/update for all edges. + + See Also + -------- + add_edge : add a single edge + add_edges_from : add multiple edges + + Notes + ----- + Adding the same edge twice for Graph/DiGraph simply updates + the edge data. For MultiGraph/MultiDiGraph, duplicate edges + are stored. + + Examples + -------- + >>> G = eg.Graph() # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> G.add_weighted_edges_from([(0, 1, 3.0), (1, 2, 7.5)]) + """ + self.add_edges_from(((u, v, {weight: d}) for u, v, d in ebunch_to_add), **attr) + + def add_weighted_edges_from(self, ebunch_to_add, weight="weight", **attr): + """Add weighted edges in `ebunch_to_add` with specified weight attr + + Parameters + ---------- + ebunch_to_add : container of edges + Each edge given in the list or container will be added + to the graph. The edges must be given as 3-tuples (u, v, w) + where w is a number. + weight : string, optional (default= 'weight') + The attribute name for the edge weights to be added. + attr : keyword arguments, optional (default= no attributes) + Edge attributes to add/update for all edges. + + See Also + -------- + add_edge : add a single edge + add_edges_from : add multiple edges + + Notes + ----- + Adding the same edge twice for Graph/DiGraph simply updates + the edge data. For MultiGraph/MultiDiGraph, duplicate edges + are stored. + + Examples + -------- + >>> G = eg.Graph() # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> G.add_weighted_edges_from([(0, 1, 3.0), (1, 2, 7.5)]) + """ + self.add_edges_from(((u, v, {weight: d}) for u, v, d in ebunch_to_add), **attr) + + def add_edges_from_file(self, file, weighted=False): + """Added edges from file + For example, txt files, + + Each line is in form like: + a b 23.0 + which denotes an edge (a, b) with weight 23.0. + + Parameters + ---------- + file : string + The file path. + + weighted : boolean, optional (default : False) + If the file consists of weight information, set `True`. + The weight key will be set as 'weight'. + + Examples + -------- + + If `./club_network.txt` is: + + Jack Mary 23.0 + + Mary Tom 15.0 + + Tom Ben 20.0 + + Then add them to *G* + + >>> G.add_edges_from_file(file='./club_network.txt', weighted=True) + + + """ + import re + + with open(file, "r") as fp: + edges = fp.readlines() + if weighted: + for edge in edges: + edge = re.sub(",", " ", edge) + edge = edge.split() + try: + self.add_edge(edge[0], edge[1], weight=float(edge[2])) + except: + pass + else: + for edge in edges: + edge = re.sub(",", " ", edge) + edge = edge.split() + try: + self.add_edge(edge[0], edge[1]) + except: + pass + + def remove_nodes_from(self, nodes): + """Remove multiple nodes. + + Parameters + ---------- + nodes : iterable container + A container of nodes (list, dict, set, etc.). If a node + in the container is not in the graph it is silently + ignored. + + See Also + -------- + remove_node + + Examples + -------- + >>> G = eg.path_graph(3) # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> e = list(G.nodes) + >>> e + [0, 1, 2] + >>> G.remove_nodes_from(e) + >>> list(G.nodes) + [] + + """ + adj = self._adj + for n in nodes: + try: + del self._node[n] + for u in list(adj[n]): # list handles self-loops + del adj[u][n] # (allows mutation of dict in loop) + del adj[n] + except KeyError: + pass + + def _add_one_edge(self, u_of_edge, v_of_edge, edge_attr: dict = {}): + u, v = u_of_edge, v_of_edge + # add nodes + if u not in self._node: + self._add_one_node(u) + if v not in self._node: + self._add_one_node(v) + # add the edge + datadict = self._adj[u].get(v, self.edge_attr_dict_factory()) + datadict.update(edge_attr) + self._adj[u][v] = datadict + self._adj[v][u] = datadict + if u == v: + self.extra_selfloop = True + self._raw_selfloop_dict[u] = datadict + self._clear_cache() + + def remove_node(self, node_to_remove): + """Remove one node from your graph. + + Parameters + ---------- + node_to_remove : object + The node you want to remove. + + See Also + -------- + remove_nodes + + Examples + -------- + Remove node *Jack* from *G* + + >>> G.remove_node('Jack') + + """ + assert node_to_remove != None, "Nodes can not be None." + try: + neighbors = list(self._adj[node_to_remove]) + del self._node[node_to_remove] + except KeyError: # Node not exists in self + raise EasyGraphError("No node {} in graph.".format(node_to_remove)) + for neighbor in neighbors: # Remove edges with other nodes + del self._adj[neighbor][node_to_remove] + del self._adj[node_to_remove] # Remove this node + self._clear_cache() + + def remove_nodes(self, nodes_to_remove: list): + """Remove nodes from your graph. + + Parameters + ---------- + nodes_to_remove : list of object + The list of nodes you want to remove. + + See Also + -------- + remove_node + + Examples + -------- + Remove node *[1, 2, 'a', 'b']* from *G* + + >>> G.remove_nodes([1, 2, 'a', 'b']) + + """ + for ( + node + ) in ( + nodes_to_remove + ): # If not all nodes included in graph, give up removing other nodes + assert node in self._node, "Remove Error: No node {} in graph".format(node) + for node in nodes_to_remove: + self.remove_node(node) + self._clear_cache() + + def remove_edge(self, u, v): + """Remove one edge from your graph. + + Parameters + ---------- + u : object + One end of the edge. + + v : object + The other end of the edge. + + See Also + -------- + remove_edges + + Examples + -------- + Remove edge (1,2) from *G* + + >>> G.remove_edge(1,2) + + """ + try: + del self._adj[u][v] + if u != v: # self-loop needs only one entry removed + del self._adj[v][u] + self._clear_cache() + except KeyError: + raise KeyError("No edge {}-{} in graph.".format(u, v)) + + def remove_edges(self, edges_to_remove: [tuple]): + """Remove a list of edges from your graph. + + Parameters + ---------- + edges_to_remove : list of tuple + The list of edges you want to remove, + Each element is (u, v) tuple, which denote the two ends of the edge. + + See Also + -------- + remove_edge + + Examples + -------- + Remove the edges *('Jack', 'Mary')* and *('Mary', 'Tom')* from *G* + + >>> G.remove_edge([ + ... ('Jack', 'Mary'), + ... ('Mary', 'Tom') + ... ]) + + """ + for edge in edges_to_remove: + u, v = edge[:2] + self.remove_edge(u, v) + self._clear_cache() + + def has_node(self, node): + """Returns whether a node exists + + Parameters + ---------- + node + + Returns + ------- + Bool : True (exist) or False (not exists) + + """ + assert node != None, "Nodes can not be None." + return node in self._node + + def has_edge(self, u, v): + """Returns whether an edge exists + + Parameters + ---------- + u : start node + + v: end node + + Returns + ------- + Bool : True (exist) or False (not exists) + + """ + assert u != None and v != None, "Nodes can not be None." + try: + return v in self._adj[u] + except KeyError: + return False + + def number_of_nodes(self): + """Returns the number of nodes. + + Returns + ------- + number_of_nodes : int + The number of nodes. + """ + return len(self._node) + + def is_directed(self): + """Returns True if graph is a directed_graph, False otherwise.""" + return False + + def is_multigraph(self): + """Returns True if graph is a multigraph, False otherwise.""" + return False + + def copy(self): + """Return a deep copy of the graph. + + Returns + ------- + copy : easygraph.Graph + A deep copy of the original graph. + + Examples + -------- + *G2* is a deep copy of *G1* + + >>> G2 = G1.copy() + + """ + G = self.__class__() + G.graph.update(self.graph) + for node, node_attr in self._node.items(): + G.add_node(node, **node_attr) + for u, nbrs in self._adj.items(): + for v, edge_data in nbrs.items(): + G.add_edge(u, v, **edge_data) + + return G + + def nodes_subgraph(self, from_nodes: list): + """Returns a subgraph of some nodes + + Parameters + ---------- + from_nodes : list of object + The nodes in subgraph. + + Returns + ------- + nodes_subgraph : easygraph.Graph + The subgraph consisting of *from_nodes*. + + Examples + -------- + + >>> G = eg.Graph() + >>> G.add_edges([(1,2), (2,3), (2,4), (4,5)]) + >>> G_sub = G.nodes_subgraph(from_nodes= [1,2,3]) + + """ + G = self.__class__() + G.graph.update(self.graph) + from_nodes = set(from_nodes) + for node in from_nodes: + try: + G.add_node(node, **self._node[node]) + except KeyError: + pass + + for v, edge_data in self._adj[node].items(): + if v in from_nodes: + G.add_edge(node, v, **edge_data) + return G + + def ego_subgraph(self, center): + """Returns an ego network graph of a node. + + Parameters + ---------- + center : object + The center node of the ego network graph + + Returns + ------- + ego_subgraph : easygraph.Graph + The ego network graph of *center*. + + + Examples + -------- + >>> G = eg.Graph() + >>> G.add_edges([ + ... ('Jack', 'Maria'), + ... ('Maria', 'Andy'), + ... ('Jack', 'Tom') + ... ]) + >>> G.ego_subgraph(center='Jack') + """ + neighbors_of_center = list(self.all_neighbors(center)) + neighbors_of_center.append(center) + return self.nodes_subgraph(from_nodes=neighbors_of_center) + + def to_index_node_graph(self, begin_index=0): + """Returns a deep copy of graph, with each node switched to its index. + + Considering that the nodes of your graph may be any possible hashable Python object, + you can get an isomorphic graph of the original one, with each node switched to its index. + + Parameters + ---------- + begin_index : int + The begin index of the index graph. + + Returns + ------- + G : easygraph.Graph + Deep copy of graph, with each node switched to its index. + + index_of_node : dict + Index of node + + node_of_index : dict + Node of index + + Examples + -------- + The following method returns this isomorphic graph and index-to-node dictionary + as well as node-to-index dictionary. + + >>> G = eg.Graph() + >>> G.add_edges([ + ... ('Jack', 'Maria'), + ... ('Maria', 'Andy'), + ... ('Jack', 'Tom') + ... ]) + >>> G_index_graph, index_of_node, node_of_index = G.to_index_node_graph() + + """ + G = self.__class__() + G.graph.update(self.graph) + index_of_node = dict() + node_of_index = dict() + for index, (node, node_attr) in enumerate(self._node.items()): + G.add_node(index + begin_index, **node_attr) + index_of_node[node] = index + begin_index + node_of_index[index + begin_index] = node + for u, nbrs in self._adj.items(): + for v, edge_data in nbrs.items(): + G.add_edge(index_of_node[u], index_of_node[v], **edge_data) + + return G, index_of_node, node_of_index + + def _clear_cache(self): + r"""Clear the cache.""" + self.cache = {} + + def to_directed_class(self): + """Returns the class to use for empty directed copies. + + If you subclass the base classes, use this to designate + what directed class to use for `to_directed()` copies. + """ + return eg.DiGraph + + def to_directed(self): + """Creates and returns a directed graph from self. + + Returns + ------- + G : DiGraph + A directed graph with identical name and nodes. Each undirected + edge (u, v, data) in the original graph is replaced by two directed + edges (u, v, data) and (v, u, data). + + Notes + ----- + This function returns a deepcopy of the original graph, including + all nodes, edges, and graph. As a result, it fully duplicates + the data and references in the original graph. + + This function differs from D=DiGraph(G) which returns a + shallow copy. + + For more details on shallow and deep copies, refer to the + Python `copy` module: https://docs.python.org/3/library/copy.html. + + Warning: If the original graph is a subclass of `Graph` using + custom dict-like objects for its data structure, those customizations + will not be preserved in the `DiGraph` created by this function. + + Examples + -------- + Converting an undirected graph to a directed graph: + + >>> G = eg.Graph() # or MultiGraph, etc + >>> G.add_edge(0, 1) + >>> H = G.to_directed() + >>> list(H.edges) + [(0, 1), (1, 0)] + + Creating a deep copy of an already directed graph: + + >>> G = eg.DiGraph() # or MultiDiGraph, etc + >>> G.add_edge(0, 1) + >>> H = G.to_directed() + >>> list(H.edges) + [(0, 1)] + """ + graph_class = self.to_directed_class() + + G = graph_class() + G.graph.update(deepcopy(self.graph)) + G.add_nodes_from((n, deepcopy(d)) for n, d in self._node.items()) + G.add_edges_from( + (u, v, deepcopy(data)) + for u, nbrs in self._adj.items() + for v, data in nbrs.items() + ) + return G + + def cpp(self): + G = GraphC() + G.graph.update(self.graph) + for u, attr in self.nodes.items(): + G.add_node(u, **attr) + for u, v, attr in self.edges: + G.add_edge(u, v, **attr) + G.generate_linkgraph() + return G + + +try: + import cpp_easygraph + + class GraphC(cpp_easygraph.Graph): + cflag = 1 + +except ImportError: + + class GraphC: + def __init__(self, **graph_attr): + print( + "Object cannot be instantiated because C extension has not been" + " successfully compiled and installed. Please refer to" + " https://github.com/easy-graph/Easy-Graph/blob/master/README.rst and" + " reinstall easygraph." + ) + raise RuntimeError diff --git a/easygraph/classes/graphviews.py b/easygraph/classes/graphviews.py new file mode 100644 index 0000000..bbb2a3d --- /dev/null +++ b/easygraph/classes/graphviews.py @@ -0,0 +1,15 @@ +from easygraph.utils import only_implemented_for_Directed_graph + + +__all__ = ["reverse_view"] + + +@only_implemented_for_Directed_graph +def reverse_view(G): + newG = G.__class__() + newG._graph = G + newG.graph = G.graph + newG._node = G._node + newG._succ, newG._pred = G._pred, G._succ + newG._adj = newG._succ + return newG diff --git a/easygraph/classes/hypergraph.py b/easygraph/classes/hypergraph.py new file mode 100644 index 0000000..b973080 --- /dev/null +++ b/easygraph/classes/hypergraph.py @@ -0,0 +1,2633 @@ +import pickle +import random + +from copy import deepcopy +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from typing import Union + +import easygraph as eg +import numpy as np +import torch + +from easygraph.classes.base import BaseHypergraph +from easygraph.functions.drawing import draw_hypergraph +from easygraph.utils.exception import EasyGraphError +from easygraph.utils.sparse import sparse_dropout +from scipy.sparse import csr_array +from scipy.sparse import csr_matrix + + +if TYPE_CHECKING: + from easygraph import Graph + +__all__ = ["Hypergraph"] + + +class Hypergraph(BaseHypergraph): + + """ + The ``Hypergraph`` class is developed for hypergraph structures. + Please notice that node id in hypergraph is in [0, num_v) + + Parameters + ---------- + num_v : (int) The number of vertices in the hypergraph + e_list : (Union[List[int], List[List[int]]], optional) A list of hyperedges describes how the vertices point to the hyperedges. Defaults to ``None`` + v_property: Optional[List[Dict]], A list of node properties. Defaults to ``None`` + e_property: Optional[List[Dict]], A list of hyperedges properties. Defaults to ``None`` + e_weight : (Union[float, List[float]], optional) A list of weights for hyperedges. If set to None, the value ``1`` is used for all hyperedges. Defaults to None + merge_op : (str) The operation to merge those conflicting hyperedges in the same hyperedge group, which can be ``'mean'``, ``'sum'`` or ``'max'``. Defaults to ``'mean'`` + device : (torch.device, optional) The device to store the hypergraph. Defaults to torch.device('cpu') + + + """ + + gnn_data_dict_factory = dict + degree_data_dict = dict + + def __init__( + self, + num_v: int, + v_property: Optional[List[Dict]] = None, + e_list: Optional[Union[List[int], List[List[int]]]] = None, + e_weight: Optional[Union[float, List[float]]] = None, + e_property: Optional[List[Dict]] = None, + merge_op: str = "mean", + device: torch.device = torch.device("cpu"), + ): + super().__init__( + num_v, + e_list=e_list, + v_property=v_property, + e_property=e_property, + device=device, + ) + + self._ndata = self.gnn_data_dict_factory() + self.deg_v_dict = self.degree_data_dict() + self.n_e_dict = {} + self.edge_index = -1 + self.device = device + for i in range(num_v): + self.deg_v_dict[i] = 0 + self.n_e_dict[i] = [] + + if e_list is not None: + self.add_hyperedges( + e_list=e_list, + e_weight=e_weight, + e_property=e_property, + merge_op=merge_op, + ) + edges_col = [] + indptr_list = [] + ptr = 0 + for v in self.n_e_dict.values(): + edges_col.extend(v) + indptr_list.append(ptr) + ptr += len(v) + indptr_list.append(ptr) + e_idx, v_idx = [], [] + for n, e in self.n_e_dict.items(): + v_idx.extend([n] * len(e)) + e_idx.extend(e) + self.cache["e_idx"] = e_idx + self.cache["v_idx"] = v_idx + + self.cache["edges_col"] = np.array(edges_col) + self.cache["indptr_list"] = np.array(indptr_list) + + def __repr__(self) -> str: + r"""Print the hypergraph information.""" + return f"Hypergraph(num_vertex={self.num_v}, num_hyperedge={self.num_e})" + + @property + def ndata(self): + return self._ndata + + @property + def state_dict(self) -> Dict[str, Any]: + r"""Get the state dict of the hypergraph.""" + return { + "num_v": self.num_v, + "v_property": self.v_property, + "e_property": self.e_property, + "raw_groups": self._raw_groups, + "deg_v_dict": self.deg_v_dict, + } + + def unique_edge_sizes(self): + """A function that returns the unique edge sizes. + + Returns + ------- + list() + The unique edge sizes in ascending order by size. + """ + edge_size_set = set() + edge_lst = self.e[0] + for e in edge_lst: + edge_size_set.add(len(e)) + + return sorted(edge_size_set) + + def is_uniform(self): + """Order of uniformity if the hypergraph is uniform, or False. + + A hypergraph is uniform if all its edges have the same order. + + Returns d if the hypergraph is d-uniform, that is if all edges + in the hypergraph (excluding singletons) have the same degree d. + Returns False if not uniform. + + Returns + ------- + d : int or False + If the hypergraph is d-uniform, return d, or False otherwise. + + Examples + -------- + This function can be used as a boolean check: + + >>> import easygraph as eg + >>> H = eg.Hypergraph(v_num = 5, e_list = [(0, 1, 2), (1, 2, 3), (2, 3, 4)]) + >>> H.is_uniform() + 2 + """ + edge_sizes = self.unique_edge_sizes() + if 1 in edge_sizes: + edge_sizes.remove(1) + + if edge_sizes is None or len(edge_sizes) != 1: + return False + + # order of all edges + return edge_sizes.pop() + + def save(self, file_path: Union[str, Path]): + r"""Save the EasyGraph's hypergraph structure a file. + + Parameters: + ``file_path`` (``Union[str, Path]``): The file path to store the EasyGraph's hypergraph structure. + """ + file_path = Path(file_path) + assert file_path.parent.exists(), "The directory does not exist." + data = { + "class": "Hypergraph", + "state_dict": self.state_dict, + } + with open(file_path, "wb") as fp: + pickle.dump(data, fp) + + @staticmethod + def load(file_path: Union[str, Path]): + r"""Load the EasyGraph's hypergraph structure from a file. + + Parameters: + ``file_path`` (``Union[str, Path]``): The file path to load the EasyGraph's hypergraph structure. + """ + file_path = Path(file_path) + assert file_path.exists(), "The file does not exist." + with open(file_path, "rb") as fp: + data = pickle.load(fp) + assert ( + data["class"] == "Hypergraph" + ), "The file is not a EasyGraph's hypergraph file." + return Hypergraph.from_state_dict(data["state_dict"]) + + def draw( + self, + e_style: str = "circle", + v_label: Optional[List[str]] = None, + v_size: Union[float, list] = 1.0, + v_color: Union[str, list] = "r", + v_line_width: Union[str, list] = 1.0, + e_color: Union[str, list] = "gray", + e_fill_color: Union[str, list] = "whitesmoke", + e_line_width: Union[str, list] = 1.0, + font_size: float = 1.0, + font_family: str = "sans-serif", + push_v_strength: float = 1.0, + push_e_strength: float = 1.0, + pull_e_strength: float = 1.0, + pull_center_strength: float = 1.0, + ): + r"""Draw the hypergraph structure. + + Parameters: + ``e_style`` (``str``): The style of hyperedges. The available styles are only ``'circle'``. Defaults to ``'circle'``. + ``v_label`` (``list``): The labels of vertices. Defaults to ``None``. + ``v_size`` (``float`` or ``list``): The size of vertices. Defaults to ``1.0``. + ``v_color`` (``str`` or ``list``): The `color `_ of vertices. Defaults to ``'r'``. + ``v_line_width`` (``float`` or ``list``): The line width of vertices. Defaults to ``1.0``. + ``e_color`` (``str`` or ``list``): The `color `_ of hyperedges. Defaults to ``'gray'``. + ``e_fill_color`` (``str`` or ``list``): The fill `color `_ of hyperedges. Defaults to ``'whitesmoke'``. + ``e_line_width`` (``float`` or ``list``): The line width of hyperedges. Defaults to ``1.0``. + ``font_size`` (``float``): The font size of labels. Defaults to ``1.0``. + ``font_family`` (``str``): The font family of labels. Defaults to ``'sans-serif'``. + ``push_v_strength`` (``float``): The strength of pushing vertices. Defaults to ``1.0``. + ``push_e_strength`` (``float``): The strength of pushing hyperedges. Defaults to ``1.0``. + ``pull_e_strength`` (``float``): The strength of pulling hyperedges. Defaults to ``1.0``. + ``pull_center_strength`` (``float``): The strength of pulling vertices to the center. Defaults to ``1.0``. + """ + draw_hypergraph( + self, + e_style, + v_label, + v_size, + v_color, + v_line_width, + e_color, + e_fill_color, + e_line_width, + font_size, + font_family, + push_v_strength, + push_e_strength, + pull_e_strength, + pull_center_strength, + ) + + def clear(self): + r"""Clear all hyperedges and caches from the hypergraph.""" + + super().clear() + self.deg_v_dict = {} + self._ndata = {} + + def clone(self) -> "Hypergraph": + r"""Return a copy of the hypergraph.""" + hg = Hypergraph(self.num_v, device=self.device) + hg._raw_groups = deepcopy(self._raw_groups) + hg.cache = deepcopy(self.cache) + hg.group_cache = deepcopy(self.group_cache) + hg.deg_v_dict = deepcopy(self.deg_v_dict) + return hg + + def to(self, device: torch.device): + r"""Move the hypergraph to the specified device. + + Parameters: + ``device`` (``torch.device``): The target device. + """ + return super().to(device) + + # ===================================================================================== + # some construction functions + @staticmethod + def from_state_dict(state_dict: dict): + r"""Load the hypergraph from the state dict. + + Parameters: + ``state_dict`` (``dict``): The state dict to load the hypergraph. + """ + _hg = Hypergraph(state_dict["num_v"]) + _hg._raw_groups = deepcopy(state_dict["raw_groups"]) + _hg._e_property = deepcopy(state_dict["e_property"]) + _hg._v_property = deepcopy(state_dict["v_property"]) + _hg.deg_v_dict = deepcopy(state_dict["deg_v_dict"]) + return _hg + + @staticmethod + def _e_list_from_feature_kNN(features: torch.Tensor, k: int): + import scipy + + r"""Construct hyperedges from the feature matrix. Each hyperedge in the hypergraph is constructed by the central vertex and its :math:`k-1` neighbor vertices. + + Parameters: + ``features`` (``torch.Tensor``): The feature matrix. + ``k`` (``int``): The number of nearest neighbors. + """ + features = features.cpu().numpy() + assert features.ndim == 2, "The feature matrix should be 2-D." + assert k <= features.shape[0], ( + "The number of nearest neighbors should be less than or equal to the number" + " of vertices." + ) + tree = scipy.spatial.cKDTree(features) + _, nbr_array = tree.query(features, k=k) + return nbr_array.tolist() + + @staticmethod + def from_feature_kNN( + features: torch.Tensor, k: int, device: torch.device = torch.device("cpu") + ): + r"""Construct the hypergraph from the feature matrix. Each hyperedge in the hypergraph is constructed by the central vertex and its :math:`k-1` neighbor vertices. + + .. note:: + The constructed hypergraph is a k-uniform hypergraph. If the feature matrix has the size :math:`N \times C`, the number of vertices and hyperedges of the constructed hypergraph are both :math:`N`. + + Parameters: + ``features`` (``torch.Tensor``): The feature matrix. + ``k`` (``int``): The number of nearest neighbors. + ``device`` (``torch.device``, optional): The device to store the hypergraph. Defaults to ``torch.device('cpu')``. + """ + e_list = Hypergraph._e_list_from_feature_kNN(features, k) + hg = Hypergraph(num_v=features.shape[0], e_list=e_list, device=device) + return hg + + @staticmethod + def from_graph(graph, device: torch.device = torch.device("cpu")) -> "Hypergraph": + r"""Construct the hypergraph from the graph. Each edge in the graph is treated as a hyperedge in the constructed hypergraph. + + .. note:: + The constructed hypergraph is a 2-uniform hypergraph, and has the same number of vertices and edges/hyperedges as the graph. + + Parameters: + ``graph`` (``eg.Graph``): The graph to construct the hypergraph. + ``device`` (``torch.device``, optional): The device to store the hypergraph. Defaults to ``torch.device('cpu')``. + """ + e_list, e_weight, v_property, e_property = graph.e + hg = Hypergraph( + num_v=len(graph.nodes), + e_list=e_list, + e_weight=e_weight, + v_property=v_property, + e_property=e_property, + device=device, + ) + return hg + + @staticmethod + def _e_list_from_graph_kHop( + graph, + k: int, + only_kHop: bool = False, + ) -> List[tuple]: + r"""Construct the hyperedge list from the graph by k-Hop neighbors. Each hyperedge in the hypergraph is constructed by the central vertex and its :math:`k`-Hop neighbor vertices. + + .. note:: + If the graph have :math:`|\mathcal{V}|` vertices, the constructed hypergraph will have :math:`|\mathcal{V}|` vertices and equal to or less than :math:`|\mathcal{V}|` hyperedges. + + Parameters: + ``graph`` (``eg.Graph``): The graph to construct the hypergraph. + ``k`` (``int``): The number of hop neighbors. + ``only_kHop`` (``bool``, optional): If set to ``True``, only the central vertex and its :math:`k`-th Hop neighbors are used to construct the hyperedges. By default, the constructed hyperedge will include the central vertex and its [ :math:`1`-th, :math:`2`-th, :math:`\cdots`, :math:`k`-th ] Hop neighbors. Defaults to ``False``. + """ + assert ( + k >= 1 + ), "The number of hop neighbors should be larger than or equal to 1." + A_1, A_k = graph.A.clone(), graph.A.clone() + A_history = [] + for _ in range(k - 1): + A_k = torch.sparse.mm(A_k, A_1) + if not only_kHop: + A_history.append(A_k.clone()) + if not only_kHop: + A_k = A_1 + for A_ in A_history: + A_k = A_k + A_ + e_list = [ + tuple(set([v_idx] + A_k[v_idx]._indices().cpu().squeeze(0).tolist())) + for v_idx in range(len(graph.nodes)) + ] + return e_list + + @staticmethod + def from_graph_kHop( + graph, + k: int, + only_kHop: bool = False, + device: torch.device = torch.device("cpu"), + ) -> "Hypergraph": + r"""Construct the hypergraph from the graph by k-Hop neighbors. Each hyperedge in the hypergraph is constructed by the central vertex and its :math:`k`-Hop neighbor vertices. + + .. note:: + If the graph have :math:`|\mathcal{V}|` vertices, the constructed hypergraph will have :math:`|\mathcal{V}|` vertices and equal to or less than :math:`|\mathcal{V}|` hyperedges. + + Parameters: + ``graph`` (``eg.Graph``): The graph to construct the hypergraph. + ``k`` (``int``): The number of hop neighbors. + ``only_kHop`` (``bool``): If set to ``True``, only the central vertex and its :math:`k`-th Hop neighbors are used to construct the hyperedges. By default, the constructed hyperedge will include the central vertex and its [ :math:`1`-th, :math:`2`-th, :math:`\cdots`, :math:`k`-th ] Hop neighbors. Defaults to ``False``. + ``device`` (``torch.device``, optional): The device to store the hypergraph. Defaults to ``torch.device('cpu')``. + """ + e_list = Hypergraph._e_list_from_graph_kHop(graph, k, only_kHop) + hg = Hypergraph(num_v=len(graph.nodes), e_list=e_list, device=device) + return hg + + def isOutRange(self, id): + if id >= self.num_v or id < 0: + return False + return True + + def add_hyperedges( + self, + e_list: Union[List[int], List[List[int]]], + e_weight: Optional[Union[float, List[float]]] = None, + e_property: Optional[Union[Dict, List[Dict]]] = None, + merge_op: str = "sum", + group_name: str = "main", + ): + r"""Add hyperedges to the hypergraph. If the ``group_name`` is not specified, the hyperedges will be added to the default ``main`` hyperedge group. + + Parameters: + ``e_list`` (``Union[List[int], List[List[int]]]``): A list of hyperedges describes how the vertices point to the hyperedges. + ``e_weight`` (``Union[float, List[float]]``, optional): A list of weights for hyperedges. If set to ``None``, the value ``1`` is used for all hyperedges. Defaults to ``None``. + ``merge_op`` (``str``): The merge operation for the conflicting hyperedges. The possible values are ``"mean"``, ``"sum"``, and ``"max"``. Defaults to ``"mean"``. + ``group_name`` (``str``, optional): The target hyperedge group to add these hyperedges. Defaults to the ``main`` hyperedge group. + """ + e_list = self._format_e_list(e_list) + if e_weight is None: + e_weight = [1.0] * len(e_list) + elif type(e_weight) in (int, float): + e_weight = [e_weight] + elif type(e_weight) is list: + pass + else: + raise TypeError( + "The type of e_weight should be float or list, but got" + f" {type(e_weight)}" + ) + assert len(e_list) == len( + e_weight + ), "The number of hyperedges and the number of weights are not equal." + + for _idx in range(len(e_list)): + flag = True + if ( + group_name not in self._raw_groups + or self._hyperedge_code(e_list[_idx], e_list[_idx]) + not in self._raw_groups[group_name] + ): + flag = False + self.edge_index += 1 + for n_id in e_list[_idx]: + if self.isOutRange(n_id) == False: + raise EasyGraphError( + "The node id:" + + str(n_id) + + " in hyperedge is out of range, please ensure that" + " the node is in [0,n)" + ) + self.deg_v_dict[n_id] += 1 + if flag is False: + self.n_e_dict[n_id].append(self.edge_index) + + if e_property != None: + if type(e_property) == dict: + e_property = [e_property] + e_property[_idx].update({"w_e": float(e_weight[_idx])}) + self._add_hyperedge( + self._hyperedge_code(e_list[_idx], e_list[_idx]), + e_property[_idx], + merge_op, + group_name, + ) + else: + self._add_hyperedge( + self._hyperedge_code(e_list[_idx], e_list[_idx]), + {"w_e": float(e_weight[_idx])}, + merge_op, + group_name, + ) + + self._clear_cache(group_name) + + def add_hyperedges_from_feature_kNN( + self, feature: torch.Tensor, k: int, group_name: str = "main" + ): + r"""Add hyperedges from the feature matrix by k-NN. Each hyperedge is constructed by the central vertex and its :math:`k`-Nearest Neighbor vertices. + + Parameters: + ``features`` (``torch.Tensor``): The feature matrix. + ``k`` (``int``): The number of nearest neighbors. + ``group_name`` (``str``, optional): The target hyperedge group to add these hyperedges. Defaults to the ``main`` hyperedge group. + """ + assert feature.shape[0] == self.num_v, ( + "The number of vertices in the feature matrix is not equal to the number of" + " vertices in the hypergraph." + ) + e_list = Hypergraph._e_list_from_feature_kNN(feature, k) + self.add_hyperedges(e_list, group_name=group_name) + + def add_hyperedges_from_graph(self, graph, group_name: str = "main"): + r"""Add hyperedges from edges in the graph. Each edge in the graph is treated as a hyperedge. + + Parameters: + ``graph`` (``eg.Graph``): The graph to join the hypergraph. + ``group_name`` (``str``, optional): The target hyperedge group to add these hyperedges. Defaults to the ``main`` hyperedge group. + """ + assert self.num_v == len( + graph.nodes + ), "The number of vertices in the hypergraph and the graph are not equal." + e_list, e_weight = graph.e_both_side + self.add_hyperedges(e_list, e_weight=e_weight, group_name=group_name) + + def add_hyperedges_from_graph_kHop( + self, graph, k: int, only_kHop: bool = False, group_name: str = "main" + ): + r"""Add hyperedges from vertices and its k-Hop neighbors in the graph. Each hyperedge in the hypergraph is constructed by the central vertex and its :math:`k`-Hop neighbor vertices. + + .. note:: + If the graph have :math:`|\mathcal{V}|` vertices, the constructed hypergraph will have :math:`|\mathcal{V}|` vertices and equal to or less than :math:`|\mathcal{V}|` hyperedges. + + Parameters: + ``graph`` (``eg.Graph``): The graph to join the hypergraph. + ``k`` (``int``): The number of hop neighbors. + ``only_kHop`` (``bool``): If set to ``True``, only the central vertex and its :math:`k`-th Hop neighbors are used to construct the hyperedges. By default, the constructed hyperedge will include the central vertex and its [ :math:`1`-th, :math:`2`-th, :math:`\cdots`, :math:`k`-th ] Hop neighbors. Defaults to ``False``. + ``group_name`` (``str``, optional): The target hyperedge group to add these hyperedges. Defaults to the ``main`` hyperedge group. + """ + assert self.num_v == len( + graph.nodes + ), "The number of vertices in the hypergraph and the graph are not equal." + e_list = Hypergraph._e_list_from_graph_kHop(graph, k, only_kHop=only_kHop) + self.add_hyperedges(e_list, group_name=group_name) + + def remove_hyperedges( + self, + e_list: Union[List[int], List[List[int]]], + group_name: Optional[str] = None, + ): + r"""Remove the specified hyperedges from the hypergraph. + + Parameters: + ``e_list`` (``Union[List[int], List[List[int]]]``): A list of hyperedges describes how the vertices point to the hyperedges. + ``group_name`` (``str``, optional): Remove these hyperedges from the specified hyperedge group. If not specified, the function will + remove those hyperedges from all hyperedge groups. Defaults to the ``None``. + """ + assert ( + group_name is None or group_name in self.group_names + ), "The specified group_name is not in existing hyperedge groups." + e_list = self._format_e_list(e_list) + if group_name is None: + for _idx in range(len(e_list)): + for n_id in e_list[_idx]: + self.deg_v_dict[n_id] -= 1 + if self.isOutRange(n_id) == False: + raise EasyGraphError( + "The node id in hyperedge is out of range, please ensure" + " that the node is in [1,n)" + ) + e_code = self._hyperedge_code(e_list[_idx], e_list[_idx]) + for name in self.group_names: + self._raw_groups[name].pop(e_code, None) + else: + for _idx in range(len(e_list)): + for n_id in e_list[_idx]: + self.deg_v_dict[n_id] -= 1 + if self.isOutRange(n_id) == False: + raise EasyGraphError( + "The node id in hyperedge is out of range, please ensure" + " that the node is in [1,n)" + ) + e_code = self._hyperedge_code(e_list[_idx], e_list[_idx]) + self._raw_groups[group_name].pop(e_code, None) + + self.edge_index = -1 + self.n_e_dict = {i: [] for i in range(self.num_v)} + for e in self.e[0]: + self.edge_index += 1 + for n_id in e: + self.n_e_dict[n_id].append(self.edge_index) + self._clear_cache(group_name) + + def remove_group(self, group_name: str): + r"""Remove the specified hyperedge group from the hypergraph. + + Parameters: + ``group_name`` (``str``): The name of the hyperedge group to remove. + """ + for e_code, e in self._raw_groups[group_name].items(): + e = e_code[0] + for n_id in e: + self.deg_v_dict[n_id] -= 1 + self._raw_groups.pop(group_name, None) + self._clear_cache(group_name) + + def drop_hyperedges(self, drop_rate: float, ord="uniform"): + r"""Randomly drop hyperedges from the hypergraph. This function will return a new hypergraph with non-dropped hyperedges. + + Parameters: + ``drop_rate`` (``float``): The drop rate of hyperedges. + ``ord`` (``str``): The order of dropping edges. Currently, only ``'uniform'`` is supported. Defaults to ``uniform``. + """ + if ord == "uniform": + _raw_groups = {} + for name in self.group_names: + _raw_groups[name] = { + k: v + for k, v in self._raw_groups[name].items() + if random.random() > drop_rate + } + state_dict = { + "num_v": self.num_v, + "raw_groups": _raw_groups, + "e_property": self._e_property, + "v_property": self._v_property, + } + _hg = Hypergraph.from_state_dict(state_dict) + _hg = _hg.to(self.device) + else: + raise ValueError(f"Unknown drop order: {ord}.") + return _hg + + def drop_hyperedges_of_group( + self, group_name: str, drop_rate: float, ord="uniform" + ): + r"""Randomly drop hyperedges from the specified hyperedge group. This function will return a new hypergraph with non-dropped hyperedges. + + Parameters: + ``group_name`` (``str``): The name of the hyperedge group. + ``drop_rate`` (``float``): The drop rate of hyperedges. + ``ord`` (``str``): The order of dropping edges. Currently, only ``'uniform'`` is supported. Defaults to ``uniform``. + """ + if ord == "uniform": + _raw_groups = {} + for name in self.group_names: + if name == group_name: + _raw_groups[name] = { + k: v + for k, v in self._raw_groups[name].items() + if random.random() > drop_rate + } + else: + _raw_groups[name] = self._raw_groups[name] + state_dict = { + "num_v": self.num_v, + "raw_groups": self._raw_groups, + "e_property": self._e_property, + "v_property": self._v_property, + } + _hg = Hypergraph.from_state_dict(state_dict) + _hg = _hg.to(self.device) + else: + raise ValueError(f"Unknown drop order: {ord}.") + return _hg + + # ===================================================================================== + # properties for representation + @property + def v(self) -> List[int]: + r"""Return the list of vertices.""" + return super().v + + @property + def e(self) -> Tuple[List[List[int]], List[float]]: + r"""Return all hyperedges and weights in the hypergraph.""" + if self.cache.get("e", None) is None: + e_list, e_weight, e_property = [], [], [] + for name in self.group_names: + _e = self.e_of_group(name) + e_list.extend(_e[0]) + e_weight.extend(_e[1]) + e_property.extend(_e[2]) + self.cache["e"] = (e_list, e_weight, e_property) + return self.cache["e"] + + def e_of_group(self, group_name: str) -> Tuple[List[List[int]], List[float]]: + r"""Return all hyperedges and weights of the specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("e", None) is None: + e_list = [e_code[0] for e_code in self._raw_groups[group_name].keys()] + e_weight = [ + e_content["w_e"] for e_content in self._raw_groups[group_name].values() + ] + + e_property = [] + for e_content in self._raw_groups[group_name].values(): + properties = {} + for k, v in e_content.items(): + if k != "w_e": + properties[k] = v + e_property.append(properties) + self.group_cache[group_name]["e"] = (e_list, e_weight, e_property) + return self.group_cache[group_name]["e"] + + @property + def num_v(self) -> int: + r"""Return the number of vertices in the hypergraph.""" + return super().num_v + + @property + def num_e(self) -> int: + r"""Return the number of hyperedges in the hypergraph.""" + return super().num_e + + def num_e_of_group(self, group_name: str) -> int: + r"""Return the number of hyperedges of the specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + return super().num_e_of_group(group_name) + + @property + def deg_v(self) -> List[int]: + r"""Return the degree list of each vertex.""" + return self.D_v.to_sparse_coo()._values().cpu().view(-1).numpy().tolist() + + def deg_v_of_group(self, group_name: str) -> List[int]: + r"""Return the degree list of each vertex of the specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + return self.D_v_of_group(group_name)._values().cpu().view(-1).numpy().tolist() + + @property + def deg_e(self) -> List[int]: + r"""Return the degree list of each hyperedge.""" + return self.D_e.to_sparse_coo()._values().cpu().view(-1).numpy().tolist() + + def deg_e_of_group(self, group_name: str) -> List[int]: + r"""Return the degree list of each hyperedge of the specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + return self.D_e_of_group(group_name)._values().cpu().view(-1).numpy().tolist() + + def nbr_e(self, v_idx: int) -> List[int]: + r"""Return the neighbor hyperedge list of the specified vertex. + + Parameters: + ``v_idx`` (``int``): The index of the vertex. + """ + return self.N_e(v_idx).cpu().numpy().tolist() + + def nbr_e_of_group(self, v_idx: int, group_name: str) -> List[int]: + r"""Return the neighbor hyperedge list of the specified vertex of the specified hyperedge group. + + Parameters: + ``v_idx`` (``int``): The index of the vertex. + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + return self.N_e_of_group(v_idx, group_name).cpu().numpy().tolist() + + def nbr_v(self, e_idx: int) -> List[int]: + r"""Return the neighbor vertex list of the specified hyperedge. + + Parameters: + ``e_idx`` (``int``): The index of the hyperedge. + """ + return self.N_v(e_idx).cpu().numpy().tolist() + + def nbr_v_of_group(self, e_idx: int, group_name: str) -> List[int]: + r"""Return the neighbor vertex list of the specified hyperedge of the specified hyperedge group. + + Parameters: + ``e_idx`` (``int``): The index of the hyperedge. + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + return self.N_v_of_group(e_idx, group_name).cpu().numpy().tolist() + + @property + def num_groups(self) -> int: + r"""Return the number of hyperedge groups in the hypergraph.""" + return super().num_groups + + @property + def group_names(self) -> List[str]: + r"""Return the names of all hyperedge groups in the hypergraph.""" + return super().group_names + + # ===================================================================================== + # properties for deep learning + @property + def vars_for_DL(self) -> List[str]: + r"""Return a name list of available variables for deep learning in the hypergraph including + + Sparse Matrices: + + .. math:: + \mathbf{H}, \mathbf{H}^\top, \mathcal{L}_{sym}, \mathcal{L}_{rw} \mathcal{L}_{HGNN}, + + Sparse Diagnal Matrices: + + .. math:: + \mathbf{W}_e, \mathbf{D}_v, \mathbf{D}_v^{-1}, \mathbf{D}_v^{-\frac{1}{2}}, \mathbf{D}_e, \mathbf{D}_e^{-1}, + + Vectors: + + .. math:: + \overrightarrow{v2e}_{src}, \overrightarrow{v2e}_{dst}, \overrightarrow{v2e}_{weight},\\ + \overrightarrow{e2v}_{src}, \overrightarrow{e2v}_{dst}, \overrightarrow{e2v}_{weight} + + """ + return [ + "H", + "H_T", + "L_sym", + "L_rw", + "L_HGNN", + "W_e", + "D_v", + "D_v_neg_1", + "D_v_neg_1_2", + "D_e", + "D_e_neg_1", + "v2e_src", + "v2e_dst", + "v2e_weighte2v_src", + "e2v_dst", + "e2v_weight", + ] + + @property + def v2e_src(self) -> torch.Tensor: + r"""Return the source vertex index vector :math:`\overrightarrow{v2e}_{src}` of the connections (vertices point to hyperedges) in the hypergraph. + """ + return self.H_T._indices()[1].clone() + + def v2e_src_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the source vertex index vector :math:`\overrightarrow{v2e}_{src}` of the connections (vertices point to hyperedges) in the specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + return self.H_T_of_group(group_name)._indices()[1].clone() + + @property + def v2e_dst(self) -> torch.Tensor: + r"""Return the destination hyperedge index vector :math:`\overrightarrow{v2e}_{dst}` of the connections (vertices point to hyperedges) in the hypergraph. + """ + if self.cache.get("v2e_dst") is None: + self.cache["v2e_dst"] = self.H_T._indices()[0] + return self.cache["v2e_dst"] + + def v2e_dst_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the destination hyperedge index vector :math:`\overrightarrow{v2e}_{dst}` of the connections (vertices point to hyperedges) in the specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + return self.H_T_of_group(group_name)._indices()[0].clone() + + @property + def v2e_weight(self) -> torch.Tensor: + r"""Return the weight vector :math:`\overrightarrow{v2e}_{weight}` of the connections (vertices point to hyperedges) in the hypergraph. + """ + return self.H_T._values().clone() + + def v2e_weight_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the weight vector :math:`\overrightarrow{v2e}_{weight}` of the connections (vertices point to hyperedges) in the specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + return self.H_T_of_group(group_name)._values().clone() + + @property + def e2v_src(self) -> torch.Tensor: + r"""Return the source hyperedge index vector :math:`\overrightarrow{e2v}_{src}` of the connections (hyperedges point to vertices) in the hypergraph. + """ + return self.H._indices()[1] + + def e2v_src_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the source hyperedge index vector :math:`\overrightarrow{e2v}_{src}` of the connections (hyperedges point to vertices) in the specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + return self.H_of_group(group_name)._indices()[1].clone() + + @property + def e2v_dst(self) -> torch.Tensor: + r"""Return the destination vertex index vector :math:`\overrightarrow{e2v}_{dst}` of the connections (hyperedges point to vertices) in the hypergraph. + """ + return self.H._indices()[0].clone() + + def e2v_dst_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the destination vertex index vector :math:`\overrightarrow{e2v}_{dst}` of the connections (hyperedges point to vertices) in the specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + return self.H_of_group(group_name)._indices()[0].clone() + + @property + def e2v_weight(self) -> torch.Tensor: + r"""Return the weight vector :math:`\overrightarrow{e2v}_{weight}` of the connections (hyperedges point to vertices) in the hypergraph. + """ + return self.H._values() + + def e2v_weight_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the weight vector :math:`\overrightarrow{e2v}_{weight}` of the connections (hyperedges point to vertices) in the specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + return self.H_of_group(group_name)._values().clone() + + @property + def H(self) -> torch.Tensor: + r"""Return the hypergraph incidence matrix :math:`\mathbf{H}` with ``torch.Tensor`` format. + """ + + if self.cache.get("H") is None: + num_e = len(self._raw_groups["main"]) + if self.cache.get("v_idx") is None or self.cache.get("e_idx") is None: + e_idx, v_idx = [], [] + for n, e in self.n_e_dict.items(): + v_idx.extend([n] * len(e)) + e_idx.extend(e) + self.cache["e_idx"] = e_idx + self.cache["v_idx"] = v_idx + self.cache["H"] = torch.sparse_coo_tensor( + torch.tensor( + [self.cache["v_idx"], self.cache["e_idx"]], dtype=torch.long + ), + torch.ones(len(self.cache["v_idx"])), + torch.Size([self.num_v, num_e]), + ).coalesce() + + return self.cache["H"] + + @property + def e_set(self): + if self.cache.get("e_set") is None: + e_lst = [] + for name in self.group_names: + _e = self.e_of_group(name) + e_lst.extend(_e[0]) + self.cache["e_set"] = e_lst + return self.cache["e_set"] + + @property + def incidence_matrix(self): + if self.cache.get("incidence_matrix") is None: + if ( + self.cache.get("edges_col") is None + or self.cache.get("indptr_list") is None + ): + edges_col = [] + indptr_list = [] + ptr = 0 + for v in self.n_e_dict.values(): + edges_col.extend(v) + indptr_list.append(ptr) + ptr += len(v) + indptr_list.append(ptr) + self.cache["edges_col"] = np.array(edges_col) + self.cache["indptr_list"] = np.array(indptr_list) + H = csr_matrix( + ( + [1] * len(self.cache["edges_col"]), + self.cache["edges_col"], + self.cache["indptr_list"], + ), + shape=(self.num_v, self.num_e), + dtype=int, + ) + self.cache["incidence_matrix"] = H + return self.cache["incidence_matrix"] + + def get_star_expansion(self): + r""" + The star expansion algorithm creates a graph G*(V*, E*) for every hypergraph G(V, E). + The graph G*(V*, E*) introduces a node e∈E for each hyperedge in G(V, E), where V* = V ∪ E. + Each node e is connected to all the nodes belonging to the hyperedge it originates from, i.e., E* = {(u, e): u∈e, e∈E}. + It is worth noting that each hyperedge in the set E corresponds to a star-shaped structure in the graph G*(V*, E*), + and G* is a bipartite graph. The star expansion redistributes the weights of hyperedges to their corresponding ordinary pairwise graph edges. + + $ \omega ^{*}(u,e)=\frac{\omega(e)}{\delta(e)} $ + + References + ---------- + Antelmi, Alessia, et al. "A survey on hypergraph representation learning." ACM Computing Surveys 56.1 (2023): 1-38. + + """ + star_expansion_graph = eg.Graph() + for node in self.v: + star_expansion_graph.add_node(node, type="node") + e_index = len(self.v) + hyperedge_edge_list = self.e[0] + hyperedge_weight_list = self.e[1] + hyperedge_property_list = self.e[2] + for hyperedge_index, e in enumerate(hyperedge_edge_list): + hyperedge_weight = hyperedge_weight_list[hyperedge_index] + star_expansion_graph.add_node(e_index, type="hyperedge") + for index, node in enumerate(e): + star_expansion_graph.add_edge( + e_index, + node, + weight=hyperedge_weight / len(e), + hyperedge_index=hyperedge_index, + edge_property=hyperedge_property_list[index], + ) + e_index = e_index + 1 + return star_expansion_graph + + def neighbor_of_node(self, node): + neighbor_lst = list() + node_adj = self.adjacency_matrix() + if ( + self.cache.get("neighbor") is None + or self.cache["neighbor"].get(node) is None + ): + start = node_adj.indptr[node] + end = node_adj.indptr[node + 1] + + for j in range(start, end): + neighbor_lst.append(node_adj.indices[j]) + + if self.cache.get("neighbor") is None: + self.cache["neighbor"] = {} + self.cache["neighbor"][node] = neighbor_lst + else: + self.cache["neighbor"][node] = neighbor_lst + + return self.cache["neighbor"][node] + + def adjacency_matrix(self, s=1, weight=False): + r""" + The :term:`s-adjacency matrix` for the dual hypergraph. + + Parameters + ---------- + s : int, optional, default 1 + + Returns + ------- + adjacency_matrix : scipy.sparse.csr.csr_matrix + + """ + if self.cache.get("adjacency_matrix") == None: + tmp_H = self.incidence_matrix + A = tmp_H @ tmp_H.T + A[np.diag_indices_from(A)] = 0 + if not weight: + A = (A >= s) * 1 + self.cache["adjacency_matrix"] = csr_matrix(A) + return self.cache["adjacency_matrix"] + + def edge_adjacency_matrix(self, s=1, weight=False): + r""" + The :term:`s-adjacency matrix` for the dual hypergraph. + + Parameters + ---------- + s : int, optional, default 1 + + Returns + ------- + adjacency_matrix : scipy.sparse.csr.csr_matrix + + """ + tmp_H = self.incidence_matrix + A = (tmp_H.T) @ (tmp_H) + A[np.diag_indices_from(A)] = 0 + if not weight: + A = (A >= s) * 1 + return csr_array(A) + + def _fetch_H(self, direction="v2e", group_name="main"): + r"""Fetch the H matrix of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + Args: + ``direction`` (``str``): The direction of hyperedges can be either ``'v2e'`` or ``'e2v'``. + ``group_name`` (``str``): The name of the group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + assert direction in ["v2e", "e2v"], "direction must be one of ['v2e', 'e2v']" + if direction == "v2e": + select_idx = 0 + else: + select_idx = 1 + num_e = len(self._raw_groups[group_name]) + e_idx, v_idx = [], [] + for _e_idx, e in enumerate(self._raw_groups[group_name].keys()): + sub_e = e[select_idx] + v_idx.extend(sub_e) + e_idx.extend([_e_idx] * len(sub_e)) + + H = torch.sparse_coo_tensor( + torch.tensor([v_idx, e_idx], dtype=torch.long), + torch.ones(len(v_idx)), + torch.Size([self.num_v, num_e]), + device=self.device, + ).coalesce() + return H + # if self.cache.get("main_H") is None: + # num_e = len(self._raw_groups[group_name]) + # self.cache["main_H"] = torch.sparse_coo_tensor( + # ([self.cache["v_idx"], self.cache["e_idx"]]), + # torch.ones(len(self.cache["v_idx"])), + # torch.Size([self.num_v, num_e]), + # device=self.device, + # ).coalesce() + # return self.cache["main_H"] + + def H_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the hypergraph incidence matrix :math:`\mathbf{H}` of the specified hyperedge group with ``torch.Tensor`` format. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("H") is None: + self.group_cache[group_name]["H"] = self._fetch_H() + return self.group_cache[group_name]["H"] + + def edge_distance(self, source, target, s=1): + """ + + Parameters + ---------- + source + target + s + + Returns + ------- + s- walk distance : the shortest s-walk edge distance + + Notes + ----- + The s-distance is the shortest s-walk length between the edges. + An s-walk between edges is a sequence of edges such that + consecutive pairwise edges intersect in at least s nodes. The + length of the shortest s-walk is 1 less than the number of edges + in the path sequence. + + """ + l_graph = self.get_clique_expansion(s=s, edge=True) + if source not in l_graph.nodes: + raise EasyGraphError("Please make sure source exist!") + dist = eg.Dijkstra(l_graph, source) + if target in dist: + return dist[target] + raise EasyGraphError("Please make sure target exist!") + + def distance(self, source, target=None, s=1): + """ + + Parameters + ---------- + source : node in the hypergraph + target : node in the hypergraph + s : positive integer + the number of edges + + Returns + ------- + s-walk distance : int + + Notes + ----- + The s-distance is the shortest s-walk length between the nodes. + An s-walk between nodes is a sequence of nodes that pairwise share + at least s edges. The length of the shortest s-walk is 1 less than + the number of nodes in the path sequence. + + Uses the EasyGraph's Dijkstra method on the graph + generated by the s-adjacency matrix. + + """ + + l_graph = self.get_clique_expansion(s=s) + if source not in l_graph.nodes: + raise EasyGraphError("Please make sure source exist!") + if target is not None and target not in l_graph.nodes: + raise EasyGraphError("Please make sure target exist!") + dist = eg.single_source_dijkstra(G=l_graph, source=source, target=target) + return dist[target] if target != None else dist + + def edge_diameter(self, s=1): + """ + Returns the length of the longest shortest s-walk between edges in + hypergraph + + Parameters + ---------- + s : int, optional, default 1 + + Return + ------ + edge_diameter : int + + Raises + ------ + EasyGraphXError + If hypergraph is not s-edge-connected + + Notes + ----- + Two edges are s-adjacent if they share s nodes. + Two nodes e_start and e_end are s-walk connected if there is a + sequence of edges e_start, e_1, e_2, ... e_n-1, e_end such that + consecutive edges are s-adjacent. If the graph is not connected, an + error will be raised. + + """ + l_graph = self.get_clique_expansion(s=s, edge=True) + if eg.is_connected(l_graph): + return eg.diameter(l_graph) + raise EasyGraphError(f"Hypergraph is not s-connected. s={s}") + + def diameter(self, s=1): + """ + Returns the length of the longest shortest s-walk between nodes in + hypergraph + + Parameters + ---------- + s : int, optional, default 1 + + Returns + ------- + diameter : int + Raises + ------ + EasyGraphError + If hypergraph is not s-edge-connected + + Notes + ----- + Two nodes are s-adjacent if they share s edges. + Two nodes v_start and v_end are s-walk connected if there is a + sequence of nodes v_start, v_1, v_2, ... v_n-1, v_end such that + consecutive nodes are s-adjacent. If the graph is not connected, + an error will be raised. + """ + l_graph = self.get_clique_expansion(s=s) + if eg.is_connected(l_graph): + return eg.diameter(l_graph) + raise EasyGraphError(f"Hypergraph is not s-connected. s={s}") + + @property + def H_T(self) -> torch.Tensor: + r"""Return the transpose of the hypergraph incidence matrix :math:`\mathbf{H}^\top` with ``torch.Tensor`` format. + """ + if self.cache.get("H_T") is None: + self.cache["H_T"] = self.H.t() + return self.cache["H_T"] + + def H_T_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the transpose of the hypergraph incidence matrix :math:`\mathbf{H}^\top` of the specified hyperedge group with ``torch.Tensor`` format. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("H_T") is None: + self.group_cache[group_name]["H_T"] = self.H_of_group(group_name).t() + return self.group_cache[group_name]["H_T"] + + @property + def W_e(self) -> torch.Tensor: + r"""Return the weight matrix :math:`\mathbf{W}_e` of hyperedges with ``torch.Tensor`` format. + """ + if self.cache.get("W_e") is None: + _tmp = torch.tensor(self.e[1]) + _num_e = _tmp.size(0) + self.cache["W_e"] = torch.sparse_coo_tensor( + torch.arange(0, _num_e).view(1, -1).repeat(2, 1), + _tmp, + torch.Size([_num_e, _num_e]), + ).coalesce() + + return self.cache["W_e"] + + def W_e_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the weight matrix :math:`\mathbf{W}_e` of hyperedges of the specified hyperedge group with ``torch.Tensor`` format. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("W_e") is None: + w_list = [1.0] * len(self._raw_groups["main"]) + _tmp = torch.tensor(w_list, device=self.device).view((-1, 1)).view(-1) + _num_e = _tmp.size(0) + self.group_cache[group_name]["W_e"] = torch.sparse_coo_tensor( + torch.arange(0, _num_e).view(1, -1).repeat(2, 1), + _tmp, + torch.Size([_num_e, _num_e]), + device=self.device, + ).coalesce() + return self.group_cache[group_name]["W_e"] + + @property + def degree_node(self): + return self.deg_v_dict + + @property + def D_v(self) -> torch.Tensor: + r"""Return the vertex degree matrix :math:`\mathbf{D}_v` with ``torch.sparse_coo_tensor`` format. + """ + if self.cache.get("D_v") is None: + if self.cache.get("D_v_value") is None: + self.cache["D_v_value"] = ( + torch.sparse.sum(self.H, dim=1).to_dense().view(-1) + ) + + self.cache["D_v"] = torch.sparse_coo_tensor( + torch.arange(0, self.num_v).view(1, -1).repeat(2, 1), + self.cache["D_v_value"], + torch.Size([self.num_v, self.num_v]), + ).coalesce() + return self.cache["D_v"] + + def D_v_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the vertex degree matrix :math:`\mathbf{D}_v` of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("D_v") is None: + _tmp = ( + torch.sparse.sum(self.H_of_group(group_name), dim=1) + .to_dense() + .clone() + .view(-1) + ) + _num_v = _tmp.size(0) + self.group_cache[group_name]["D_v"] = torch.sparse_coo_tensor( + torch.arange(0, _num_v).view(1, -1).repeat(2, 1), + _tmp, + torch.Size([_num_v, _num_v]), + # device=self.device, + ).coalesce() + return self.group_cache[group_name]["D_v"] + + @property + def D_v_neg_1(self) -> torch.Tensor: + r"""Return the vertex degree matrix :math:`\mathbf{D}_v^{-1}` with ``torch.sparse_coo_tensor`` format. + """ + if self.cache.get("D_v_neg_1") is None: + if self.cache.get("D_v_value") is None: + self.cache["D_v_value"] = ( + torch.sparse.sum(self.H, dim=1).to_dense().view(-1) + ) + _tmp = self.cache["D_v_value"] + _num_v = _tmp.size(0) + _val = _tmp**-1 + _val[torch.isinf(_val)] = 0 + self.cache["D_v_neg_1"] = torch.sparse_csr_tensor( + torch.arange(0, _num_v + 1), + torch.arange(0, _num_v), + _val, + torch.Size([_num_v, _num_v]), + # device=self.device, + ) + + return self.cache["D_v_neg_1"] + + def D_v_neg_1_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the vertex degree matrix :math:`\mathbf{D}_v^{-1}` of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("D_v_neg_1") is None: + _mat = self.D_v_of_group(group_name).clone() + _val = _mat._values() ** -1 + _val[torch.isinf(_val)] = 0 + self.group_cache[group_name]["D_v_neg_1"] = torch.sparse_coo_tensor( + _mat._indices(), _val, _mat.size(), device=self.device + ).coalesce() + return self.group_cache[group_name]["D_v_neg_1"] + + @property + def D_v_neg_1_2(self) -> torch.Tensor: + r"""Return the vertex degree matrix :math:`\mathbf{D}_v^{-\frac{1}{2}}` with ``torch.sparse_coo_tensor`` format. + """ + if self.cache.get("D_v_neg_1_2") is None: + if self.cache.get("D_v_value") is None: + self.cache["D_v_value"] = ( + torch.sparse.sum(self.H, dim=1).to_dense().view(-1) + ) + _mat = self.cache["D_v_value"] + _mat = _mat**-0.5 + _mat[torch.isinf(_mat)] = 0 + self.cache["D_v_neg_1_2"] = torch.sparse_csr_tensor( + torch.arange(0, self.num_v + 1), + torch.arange(0, self.num_v), + _mat, + torch.Size([self.num_v, self.num_v]), + ) + + return self.cache["D_v_neg_1_2"] + + def D_v_neg_1_2_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the vertex degree matrix :math:`\mathbf{D}_v^{-\frac{1}{2}}` of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("D_v_neg_1_2") is None: + _mat = self.D_v_of_group(group_name).clone() + _val = _mat._values() ** -0.5 + _val[torch.isinf(_val)] = 0 + self.group_cache[group_name]["D_v_neg_1_2"] = torch.sparse_coo_tensor( + _mat._indices(), _val, _mat.size(), device=self.device + ).coalesce() + return self.group_cache[group_name]["D_v_neg_1_2"] + + @property + def D_e(self) -> torch.Tensor: + r"""Return the hyperedge degree matrix :math:`\mathbf{D}_e` with ``torch.sparse_coo_tensor`` format. + """ + if self.cache.get("D_e") is None: + _tmp = torch.sparse.sum(self.H_T, dim=1).to_dense().view(-1) + _num_e = _tmp.size(0) + self.cache["D_e"] = torch.sparse_csr_tensor( + torch.arange(0, _num_e + 1), + torch.arange(0, _num_e), + _tmp, + torch.Size([_num_e, _num_e]), + ) + + return self.cache["D_e"] + + def D_e_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the hyperedge degree matrix :math:`\mathbf{D}_e` of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("D_e") is None: + _tmp = ( + torch.sparse.sum(self._fetch_H().t(), dim=1).to_dense().clone().view(-1) + ) + _num_e = _tmp.size(0) + self.group_cache[group_name]["D_e"] = torch.sparse_coo_tensor( + torch.arange(0, _num_e).view(1, -1).repeat(2, 1), + _tmp, + torch.Size([_num_e, _num_e]), + device=self.device, + ).coalesce() + return self.group_cache[group_name]["D_e"] + + @property + def D_e_neg_1(self) -> torch.Tensor: + r"""Return the hyperedge degree matrix :math:`\mathbf{D}_e^{-1}` with ``torch.sparse_coo_tensor`` format. + """ + if self.cache.get("D_e_neg_1") is None: + _tmp = torch.sparse.sum(self.H_T, dim=1).to_dense().view(-1) + _num_e = _tmp.size(0) + _val = _tmp**-1 + _val[torch.isinf(_val)] = 0 + + self.cache["D_e_neg_1"] = torch.sparse_csr_tensor( + torch.arange(0, _num_e + 1), + torch.arange(0, _num_e), + _val, + torch.Size([_num_e, _num_e]), + ) + + return self.cache["D_e_neg_1"] + + def D_e_neg_1_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the hyperedge degree matrix :math:`\mathbf{D}_e^{-1}` of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("D_e_neg_1") is None: + _mat = self.D_e_of_group(group_name).clone() + _val = _mat._values() ** -1 + _val[torch.isinf(_val)] = 0 + self.group_cache[group_name]["D_e_neg_1"] = torch.sparse_coo_tensor( + _mat._indices(), _val, _mat.size(), device=self.device + ).coalesce() + return self.group_cache[group_name]["D_e_neg_1"] + + def N_e(self, v_idx: int) -> torch.Tensor: + r"""Return the neighbor hyperedges of the specified vertex with ``torch.Tensor`` format. + + .. note:: + The ``v_idx`` must be in the range of [0, :attr:`num_v`). + + Parameters: + ``v_idx`` (``int``): The index of the vertex. + """ + assert v_idx < self.num_v + _tmp, e_bias = [], 0 + for name in self.group_names: + _tmp.append(self.N_e_of_group(v_idx, name) + e_bias) + e_bias += self.num_e_of_group(name) + return torch.cat(_tmp, dim=0) + + def N_e_of_group(self, v_idx: int, group_name: str) -> torch.Tensor: + r"""Return the neighbor hyperedges of the specified vertex of the specified hyperedge group with ``torch.Tensor`` format. + + .. note:: + The ``v_idx`` must be in the range of [0, :attr:`num_v`). + + Parameters: + ``v_idx`` (``int``): The index of the vertex. + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + assert v_idx < self.num_v + e_indices = self.H_of_group(group_name)[v_idx]._indices()[0] + return e_indices.clone() + + def N_v(self, e_idx: int) -> torch.Tensor: + r"""Return the neighbor vertices of the specified hyperedge with ``torch.Tensor`` format. + + .. note:: + The ``e_idx`` must be in the range of [0, :attr:`num_e`). + + Parameters: + ``e_idx`` (``int``): The index of the hyperedge. + """ + assert e_idx < self.num_e + for name in self.group_names: + if e_idx < self.num_e_of_group(name): + return self.N_v_of_group(e_idx, name) + else: + e_idx -= self.num_e_of_group(name) + + def N_v_of_group(self, e_idx: int, group_name: str) -> torch.Tensor: + r"""Return the neighbor vertices of the specified hyperedge of the specified hyperedge group with ``torch.Tensor`` format. + + .. note:: + The ``e_idx`` must be in the range of [0, :func:`num_e_of_group`). + + Parameters: + ``e_idx`` (``int``): The index of the hyperedge. + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + assert e_idx < self.num_e_of_group(group_name) + v_indices = self.H_T_of_group(group_name)[e_idx]._indices()[0] + return v_indices.clone() + + # ===================================================================================== + # spectral-based convolution/smoothing + def smoothing(self, X: torch.Tensor, L: torch.Tensor, lamb: float) -> torch.Tensor: + return super().smoothing(X, L, lamb) + + @property + def L_sym(self) -> torch.Tensor: + r"""Return the symmetric Laplacian matrix :math:`\mathcal{L}_{sym}` of the hypergraph with ``torch.sparse_coo_tensor`` format. + + .. math:: + \mathcal{L}_{sym} = \mathbf{I} - \mathbf{D}_v^{-\frac{1}{2}} \mathbf{H} \mathbf{W}_e \mathbf{D}_e^{-1} \mathbf{H}^\top \mathbf{D}_v^{-\frac{1}{2}} + """ + if self.cache.get("L_sym") is None: + L_HGNN = self.L_HGNN.clone() + self.cache["L_sym"] = torch.sparse_coo_tensor( + torch.hstack( + [ + torch.arange(0, self.num_v).view(1, -1).repeat(2, 1), + L_HGNN.to_sparse_coo()._indices(), + ] + ), + torch.hstack( + [torch.ones(self.num_v), -L_HGNN.to_sparse_coo()._values()] + ), + torch.Size([self.num_v, self.num_v]), + device=self.device, + ).coalesce() + return self.cache["L_sym"] + + def L_sym_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the symmetric Laplacian matrix :math:`\mathcal{L}_{sym}` of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + + .. math:: + \mathcal{L}_{sym} = \mathbf{I} - \mathbf{D}_v^{-\frac{1}{2}} \mathbf{H} \mathbf{W}_e \mathbf{D}_e^{-1} \mathbf{H}^\top \mathbf{D}_v^{-\frac{1}{2}} + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("L_sym") is None: + L_HGNN = self.L_HGNN_of_group(group_name).clone() + self.group_cache[group_name]["L_sym"] = torch.sparse_coo_tensor( + torch.hstack( + [ + torch.arange(0, self.num_v).view(1, -1).repeat(2, 1), + L_HGNN._indices(), + ] + ), + torch.hstack([torch.ones(self.num_v), -L_HGNN._values()]), + torch.Size([self.num_v, self.num_v]), + device=self.device, + ).coalesce() + return self.group_cache[group_name]["L_sym"] + + @property + def L_rw(self) -> torch.Tensor: + r"""Return the random walk Laplacian matrix :math:`\mathcal{L}_{rw}` of the hypergraph with ``torch.sparse_coo_tensor`` format. + + .. math:: + \mathcal{L}_{rw} = \mathbf{I} - \mathbf{D}_v^{-1} \mathbf{H} \mathbf{W}_e \mathbf{D}_e^{-1} \mathbf{H}^\top + """ + if self.cache.get("L_rw") is None: + _tmp = ( + self.D_v_neg_1.mm(self.H).mm(self.W_e).mm(self.D_e_neg_1).mm(self.H_T) + ) + self.cache["L_rw"] = ( + torch.sparse_coo_tensor( + torch.hstack( + [ + torch.arange(0, self.num_v).view(1, -1).repeat(2, 1), + _tmp._indices(), + ] + ), + torch.hstack([torch.ones(self.num_v), -_tmp._values()]), + torch.Size([self.num_v, self.num_v]), + device=self.device, + ) + .coalesce() + .clone() + ) + return self.cache["L_rw"] + + def L_rw_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the random walk Laplacian matrix :math:`\mathcal{L}_{rw}` of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + + .. math:: + \mathcal{L}_{rw} = \mathbf{I} - \mathbf{D}_v^{-1} \mathbf{H} \mathbf{W}_e \mathbf{D}_e^{-1} \mathbf{H}^\top + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("L_rw") is None: + _tmp = ( + self.D_v_neg_1_of_group(group_name) + .mm(self.H_of_group(group_name)) + .mm( + self.W_e_of_group(group_name), + ) + .mm( + self.D_e_neg_1_of_group(group_name), + ) + .mm( + self.H_T_of_group(group_name), + ) + ) + self.group_cache[group_name]["L_rw"] = ( + torch.sparse_coo_tensor( + torch.hstack( + [ + torch.arange(0, self.num_v).view(1, -1).repeat(2, 1), + _tmp._indices(), + ] + ), + torch.hstack([torch.ones(self.num_v), -_tmp._values()]), + torch.Size([self.num_v, self.num_v]), + device=self.device, + ) + .coalesce() + .clone() + ) + return self.group_cache[group_name]["L_rw"] + + ## HGNN Laplacian smoothing + @property + def L_HGNN(self) -> torch.Tensor: + r"""Return the HGNN Laplacian matrix :math:`\mathcal{L}_{HGNN}` of the hypergraph with ``torch.sparse_coo_tensor`` format. + + .. math:: + \mathcal{L}_{HGNN} = \mathbf{D}_v^{-\frac{1}{2}} \mathbf{H} \mathbf{W}_e \mathbf{D}_e^{-1} \mathbf{H}^\top \mathbf{D}_v^{-\frac{1}{2}} + """ + if self.cache.get("L_HGNN") is None: + _d_v_neg_1_2 = self.D_v_neg_1_2.to_sparse_coo() + _tmp = ( + _d_v_neg_1_2 + @ self.H + @ self.W_e + @ self.D_e_neg_1.to_sparse_coo() + @ self.H_T + @ _d_v_neg_1_2 + ) + self.cache["L_HGNN"] = _tmp.to_sparse_csr() + return self.cache["L_HGNN"] + + def L_HGNN_of_group(self, group_name: str) -> torch.Tensor: + r"""Return the HGNN Laplacian matrix :math:`\mathcal{L}_{HGNN}` of the specified hyperedge group with ``torch.sparse_coo_tensor`` format. + + .. math:: + \mathcal{L}_{HGNN} = \mathbf{D}_v^{-\frac{1}{2}} \mathbf{H} \mathbf{W}_e \mathbf{D}_e^{-1} \mathbf{H}^\top \mathbf{D}_v^{-\frac{1}{2}} + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if self.group_cache[group_name].get("L_HGNN") is None: + _tmp = ( + self.D_v_neg_1_2_of_group(group_name) + .mm(self.H_of_group(group_name)) + .mm(self.W_e_of_group(group_name)) + .mm( + self.D_e_neg_1_of_group(group_name), + ) + .mm( + self.H_T_of_group(group_name), + ) + .mm( + self.D_v_neg_1_2_of_group(group_name), + ) + ) + self.group_cache[group_name]["L_HGNN"] = _tmp.coalesce() + return self.group_cache[group_name]["L_HGNN"] + + def smoothing_with_HGNN( + self, X: torch.Tensor, drop_rate: float = 0.0 + ) -> torch.Tensor: + r"""Return the smoothed feature matrix with the HGNN Laplacian matrix :math:`\mathcal{L}_{HGNN}`. + + .. math:: + \mathbf{X} = \mathbf{D}_v^{-\frac{1}{2}} \mathbf{H} \mathbf{W}_e \mathbf{D}_e^{-1} \mathbf{H}^\top \mathbf{D}_v^{-\frac{1}{2}} \mathbf{X} + + Parameters: + ``X`` (``torch.Tensor``): The feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. Default: ``0.0``. + """ + # if self.device != X.device: + # X = X.to(self.device) + + if drop_rate > 0.0: + L_HGNN = sparse_dropout(self.L_HGNN, drop_rate) + else: + L_HGNN = self.L_HGNN + return L_HGNN.mm(X) + + def smoothing_with_HGNN_of_group( + self, group_name: str, X: torch.Tensor, drop_rate: float = 0.0 + ) -> torch.Tensor: + r"""Return the smoothed feature matrix with the HGNN Laplacian matrix :math:`\mathcal{L}_{HGNN}`. + + .. math:: + \mathbf{X} = \mathbf{D}_v^{-\frac{1}{2}} \mathbf{H} \mathbf{W}_e \mathbf{D}_e^{-1} \mathbf{H}^\top \mathbf{D}_v^{-\frac{1}{2}} \mathbf{X} + + Parameters: + ``group_name`` (``str``): The name of the specified hyperedge group. + ``X`` (``torch.Tensor``): The feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. Default: ``0.0``. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if drop_rate > 0.0: + L_HGNN = sparse_dropout(self.L_HGNN_of_group(group_name), drop_rate) + else: + L_HGNN = self.L_HGNN_of_group(group_name) + return L_HGNN.mm(X) + + def smoothing_with_HWNN_approx( + self, + X: torch.Tensor, + par: torch.nn.Parameter, + W_d: torch.nn.Parameter, + K1: int, + K2: int, + W: torch.nn.Parameter, + ) -> torch.Tensor: + r"""Return the smoothed feature matrix with the approximated HWNN Laplacian matrix :math:`\mathcal{L}_{HGNN}`. + + .. math:: + \mathbf{X} = \mathbf{theta}_{sum} \mathbf{Lambda}_{beta} \mathbf{theta'}_{sum} + + Parameters: + ``X`` (``torch.Tensor``): The feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``par`` (``torch.nn.Parameter``): A learnable parameter used in the HWNN approximation. + ``W_d`` (``torch.nn.Parameter``): A trainable weight matrix for feature transformation. + ``K1`` (``int``): The order of approximation for the first transformation step. + ``K2`` (``int``): The order of approximation for the second transformation step. + ``W`` (``torch.nn.Parameter``): A learnable weight matrix applied in the feature transformation step. + """ + # if self.device != X.device: + # X = X.to(self.device) + if self.device != W_d.device: + W_d = W_d.to(self.device) + if self.device != W.device: + W = W.to(self.device) + ncount = X.size()[0] + W_d = torch.diag(W_d) + Theta = self.L_HGNN + Theta_t = torch.transpose(Theta, 0, 1) + poly = par[0] * torch.eye(ncount).to(self.device) + Theta_mul = torch.eye(ncount).to(self.device) + for ind in range(1, K1): + Theta_mul = Theta_mul @ Theta + poly = poly + par[ind] * Theta_mul + poly_t = par[K1] * torch.eye(ncount).to(self.device) + Theta_mul = torch.eye(ncount).to(self.device) + for ind in range(K1 + 1, K1 + K2): + Theta_mul = Theta_mul @ Theta_t + poly_t = poly_t + par[ind] * Theta_mul + return poly @ W_d @ poly_t @ X @ W + + def smoothing_with_HWNN_wavelet( + self, X: torch.Tensor, W_d: torch.nn.Parameter, W: torch.nn.Parameter + ) -> torch.Tensor: + r"""Return the smoothed feature matrix with original HWNN Laplacian matrix : + + + .. math:: + \mathbf{X} = \mathbf{Psi}_{s} \mathbf{Lambda}_{beta} \mathbf{Psi}_{s}^{-1} + + Parameters: + ``X`` (``torch.Tensor``): The feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``par`` (``torch.nn.Parameter``): A learnable parameter used in the HWNN approximation. + ``W_d`` (``torch.nn.Parameter``): A trainable weight matrix for feature transformation. + ``K1`` (``int``): The order of approximation for the first transformation step. + ``K2`` (``int``): The order of approximation for the second transformation step. + ``W`` (``torch.nn.Parameter``): A learnable weight matrix applied in the feature transformation step. + """ + # if self.device != X.device: + # X = X.to(self.device) + if self.device != W_d.device: + W_d = W_d.to(self.device) + if self.device != W.device: + W = W.to(self.device) + W_d = torch.diag(W_d) + Theta = self.L_HGNN + Laplacian = torch.eye(Theta.size()[0]) - Theta + fourier_e, fourier_v = torch.linalg.eigh(Laplacian, UPLO="U") + wavelets = ( + fourier_v + @ torch.diag(torch.exp(-1.0 * fourier_e)) + @ torch.transpose(fourier_v, 0, 1) + ) + wavelets_inv = ( + fourier_v + @ torch.diag(torch.exp(fourier_e)) + @ torch.transpose(fourier_v, 0, 1) + ) + wavelets[wavelets < 0.00001] = 0 + wavelets_inv[wavelets_inv < 0.00001] = 0 + return wavelets @ W_d @ wavelets_inv @ X @ W + + # ===================================================================================== + # spatial-based convolution/message-passing + # general message passing functions + def v2e_aggregation( + self, + X: torch.Tensor, + aggr: str = "mean", + v2e_weight: Optional[torch.Tensor] = None, + drop_rate: float = 0.0, + ): + r"""Message aggregation step of ``vertices to hyperedges``. + + Parameters: + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``v2e_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (vertices point to hyperedges). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. Default: ``0.0``. + """ + # if self.device != X.device: + # self.to(X.device) + if v2e_weight is None: + if drop_rate > 0.0: + P = sparse_dropout(self.H_T, drop_rate) + else: + P = self.H_T + + if aggr == "mean": + X = torch.sparse.mm(P, X) + X = torch.sparse.mm(self.D_e_neg_1, X) + elif aggr == "sum": + X = torch.sparse.mm(P, X) + elif aggr == "softmax_then_sum": + P = torch.sparse.softmax(P, dim=1) + X = torch.sparse.mm(P, X) + else: + raise ValueError(f"Unknown aggregation method {aggr}.") + else: + # init message path + assert ( + v2e_weight.shape[0] == self.v2e_weight.shape[0] + ), "The size of v2e_weight must be equal to the size of self.v2e_weight." + P = torch.sparse_coo_tensor( + self.H_T._indices(), v2e_weight, self.H_T.shape, device=self.device + ) + + if drop_rate > 0.0: + P = sparse_dropout(P, drop_rate) + # message passing + if aggr == "mean": + X = torch.sparse.mm(P, X) + D_e_neg_1 = torch.sparse.sum(P, dim=1).to_dense().view(-1, 1) + D_e_neg_1[torch.isinf(D_e_neg_1)] = 0 + X = torch.sparse.mm(D_e_neg_1, X) + elif aggr == "sum": + X = torch.sparse.mm(P, X) + elif aggr == "softmax_then_sum": + P = torch.sparse.softmax(P, dim=1) + X = torch.sparse.mm(P, X) + else: + raise ValueError(f"Unknown aggregation method {aggr}.") + return X + + def v2e_aggregation_of_group( + self, + group_name: str, + X: torch.Tensor, + aggr: str = "mean", + v2e_weight: Optional[torch.Tensor] = None, + drop_rate: float = 0.0, + ): + r"""Message aggregation step of ``vertices to hyperedges`` in specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``v2e_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (vertices point to hyperedges). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. Default: ``0.0``. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + assert aggr in ["mean", "sum", "softmax_then_sum"] + # if self.device != X.device: + # self.to(X.device) + if v2e_weight is None: + if drop_rate > 0.0: + P = sparse_dropout(self.H_T_of_group(group_name), drop_rate) + else: + P = self.H_T_of_group(group_name) + if aggr == "mean": + X = torch.sparse.mm(P, X) + X = torch.sparse.mm(self.D_e_neg_1_of_group(group_name), X) + elif aggr == "sum": + X = torch.sparse.mm(P, X) + elif aggr == "softmax_then_sum": + P = torch.sparse.softmax(P, dim=1) + X = torch.sparse.mm(P, X) + else: + raise ValueError(f"Unknown aggregation method {aggr}.") + else: + # init message path + assert ( + v2e_weight.shape[0] == self.v2e_weight_of_group(group_name).shape[0] + ), ( + "The size of v2e_weight must be equal to the size of" + f" self.v2e_weight_of_group('{group_name}')." + ) + P = torch.sparse_coo_tensor( + self.H_T_of_group(group_name)._indices(), + v2e_weight, + self.H_T_of_group(group_name).shape, + device=self.device, + ) + if drop_rate > 0.0: + P = sparse_dropout(P, drop_rate) + # message passing + if aggr == "mean": + X = torch.sparse.mm(P, X) + D_e_neg_1 = torch.sparse.sum(P, dim=1).to_dense().view(-1, 1) + D_e_neg_1[torch.isinf(D_e_neg_1)] = 0 + X = D_e_neg_1 * X + elif aggr == "sum": + X = torch.sparse.mm(P, X) + elif aggr == "softmax_then_sum": + P = torch.sparse.softmax(P, dim=1) + X = torch.sparse.mm(P, X) + else: + raise ValueError(f"Unknown aggregation method {aggr}.") + return X + + def v2e_update(self, X: torch.Tensor, e_weight: Optional[torch.Tensor] = None): + r"""Message update step of ``vertices to hyperedges``. + + Parameters: + ``X`` (``torch.Tensor``): Hyperedge feature matrix. Size :math:`(|\mathcal{E}|, C)`. + ``e_weight`` (``torch.Tensor``, optional): The hyperedge weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + # if self.device != X.device: + # self.to(X.device) + if e_weight is None: + X = torch.sparse.mm(self.W_e, X) + else: + e_weight = e_weight.view(-1, 1) + assert ( + e_weight.shape[0] == self.num_e + ), "The size of e_weight must be equal to the size of self.num_e." + X = e_weight * X + return X + + def v2e_update_of_group( + self, group_name: str, X: torch.Tensor, e_weight: Optional[torch.Tensor] = None + ): + r"""Message update step of ``vertices to hyperedges`` in specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Hyperedge feature matrix. Size :math:`(|\mathcal{E}|, C)`. + ``e_weight`` (``torch.Tensor``, optional): The hyperedge weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + # if self.device != X.device: + # self.to(X.device) + if e_weight is None: + X = torch.sparse.mm(self.W_e_of_group(group_name), X) + else: + e_weight = e_weight.view(-1, 1) + assert e_weight.shape[0] == self.num_e_of_group(group_name), ( + "The size of e_weight must be equal to the size of" + f" self.num_e_of_group('{group_name}')." + ) + X = e_weight * X + return X + + def v2e( + self, + X: torch.Tensor, + aggr: str = "mean", + v2e_weight: Optional[torch.Tensor] = None, + e_weight: Optional[torch.Tensor] = None, + drop_rate: float = 0.0, + ): + r"""Message passing of ``vertices to hyperedges``. The combination of ``v2e_aggregation`` and ``v2e_update``. + + Parameters: + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``v2e_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (vertices point to hyperedges). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``e_weight`` (``torch.Tensor``, optional): The hyperedge weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. Default: ``0.0``. + """ + + X = self.v2e_aggregation(X, aggr, v2e_weight, drop_rate=drop_rate) + X = self.v2e_update(X, e_weight) + return X + + def v2e_of_group( + self, + group_name: str, + X: torch.Tensor, + aggr: str = "mean", + v2e_weight: Optional[torch.Tensor] = None, + e_weight: Optional[torch.Tensor] = None, + drop_rate: float = 0.0, + ): + r"""Message passing of ``vertices to hyperedges`` in specified hyperedge group. The combination of ``e2v_aggregation_of_group`` and ``e2v_update_of_group``. + + Parameters: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``v2e_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (vertices point to hyperedges). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``e_weight`` (``torch.Tensor``, optional): The hyperedge weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. Default: ``0.0``. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + X = self.v2e_aggregation_of_group( + group_name, X, aggr, v2e_weight, drop_rate=drop_rate + ) + X = self.v2e_update_of_group(group_name, X, e_weight) + return X + + def e2v_aggregation( + self, + X: torch.Tensor, + aggr: str = "mean", + e2v_weight: Optional[torch.Tensor] = None, + drop_rate: float = 0.0, + ): + r"""Message aggregation step of ``hyperedges to vertices``. + + Parameters: + ``X`` (``torch.Tensor``): Hyperedge feature matrix. Size :math:`(|\mathcal{E}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``e2v_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (hyperedges point to vertices). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. Default: ``0.0``. + """ + # if self.device != X.device: + # self.to(X.device) + if e2v_weight is None: + if drop_rate > 0.0: + P = sparse_dropout(self.H, drop_rate) + else: + P = self.H + if aggr == "mean": + X = torch.sparse.mm(P, X) + X = torch.sparse.mm(self.D_v_neg_1, X) + elif aggr == "sum": + X = torch.sparse.mm(P, X) + elif aggr == "softmax_then_sum": + P = torch.sparse.softmax(P, dim=1) + X = torch.sparse.mm(P, X) + else: + raise ValueError(f"Unknown aggregation method: {aggr}") + else: + # init message path + assert ( + e2v_weight.shape[0] == self.e2v_weight.shape[0] + ), "The size of e2v_weight must be equal to the size of self.e2v_weight." + P = torch.sparse_coo_tensor( + self.H._indices(), + e2v_weight, + self.H.shape, + # device=self.device + ).coalesce() + + if drop_rate > 0.0: + P = sparse_dropout(P, drop_rate) + # message passing + if aggr == "mean": + X = torch.sparse.mm(P, X) + D_v_neg_1 = torch.sparse.sum(P, dim=1).to_dense().view(-1, 1) + D_v_neg_1[torch.isinf(D_v_neg_1)] = 0 + X = D_v_neg_1 * X + elif aggr == "sum": + X = torch.sparse.mm(P, X) + elif aggr == "softmax_then_sum": + P = torch.sparse.softmax(P, dim=1) + X = torch.sparse.mm(P, X) + else: + raise ValueError(f"Unknown aggregation method: {aggr}") + return X + + def e2v_aggregation_of_group( + self, + group_name: str, + X: torch.Tensor, + aggr: str = "mean", + e2v_weight: Optional[torch.Tensor] = None, + drop_rate: float = 0.0, + ): + r"""Message aggregation step of ``hyperedges to vertices`` in specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Hyperedge feature matrix. Size :math:`(|\mathcal{E}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``e2v_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (hyperedges point to vertices). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. Default: ``0.0``. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + assert aggr in ["mean", "sum", "softmax_then_sum"] + # if self.device != X.device: + # self.to(X.device) + if e2v_weight is None: + if drop_rate > 0.0: + P = sparse_dropout(self.H_of_group(group_name), drop_rate) + else: + P = self.H_of_group(group_name) + if aggr == "mean": + X = torch.sparse.mm(P, X) + X = torch.sparse.mm(self.D_v_neg_1_of_group[group_name], X) + elif aggr == "sum": + X = torch.sparse.mm(P, X) + elif aggr == "softmax_then_sum": + P = torch.sparse.softmax(P, dim=1) + X = torch.sparse.mm(P, X) + else: + raise ValueError(f"Unknown aggregation method: {aggr}") + else: + # init message path + assert ( + e2v_weight.shape[0] == self.e2v_weight_of_group[group_name].shape[0] + ), ( + "The size of e2v_weight must be equal to the size of" + f" self.e2v_weight_of_group('{group_name}')." + ) + P = torch.sparse_coo_tensor( + self.H_of_group[group_name]._indices(), + e2v_weight, + self.H_of_group[group_name].shape, + device=self.device, + ) + if drop_rate > 0.0: + P = sparse_dropout(P, drop_rate) + # message passing + if aggr == "mean": + X = torch.sparse.mm(P, X) + D_v_neg_1 = torch.sparse.sum(P, dim=1).to_dense().view(-1, 1) + D_v_neg_1[torch.isinf(D_v_neg_1)] = 0 + X = D_v_neg_1 * X + elif aggr == "sum": + X = torch.sparse.mm(P, X) + elif aggr == "softmax_then_sum": + P = torch.sparse.softmax(P, dim=1) + X = torch.sparse.mm(P, X) + else: + raise ValueError(f"Unknown aggregation method: {aggr}") + return X + + def e2v_update(self, X: torch.Tensor): + r"""Message update step of ``hyperedges to vertices``. + + Parameters: + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + """ + # if self.device != X.device: + # self.to(X.device) + return X + + def e2v_update_of_group(self, group_name: str, X: torch.Tensor): + r"""Message update step of ``hyperedges to vertices`` in specified hyperedge group. + + Parameters: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + # if self.device != X.device: + # self.to(X.device) + return X + + def e2v( + self, + X: torch.Tensor, + aggr: str = "mean", + e2v_weight: Optional[torch.Tensor] = None, + drop_rate: float = 0.0, + ): + r"""Message passing of ``hyperedges to vertices``. The combination of ``e2v_aggregation`` and ``e2v_update``. + + Parameters: + ``X`` (``torch.Tensor``): Hyperedge feature matrix. Size :math:`(|\mathcal{E}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``e2v_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (hyperedges point to vertices). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. Default: ``0.0``. + """ + X = self.e2v_aggregation(X, aggr, e2v_weight, drop_rate=drop_rate) + X = self.e2v_update(X) + return X + + def e2v_of_group( + self, + group_name: str, + X: torch.Tensor, + aggr: str = "mean", + e2v_weight: Optional[torch.Tensor] = None, + drop_rate: float = 0.0, + ): + r"""Message passing of ``hyperedges to vertices`` in specified hyperedge group. The combination of ``e2v_aggregation_of_group`` and ``e2v_update_of_group``. + + Parameters: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Hyperedge feature matrix. Size :math:`(|\mathcal{E}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. + ``e2v_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (hyperedges point to vertices). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. Default: ``0.0``. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + X = self.e2v_aggregation_of_group( + group_name, X, aggr, e2v_weight, drop_rate=drop_rate + ) + X = self.e2v_update_of_group(group_name, X) + return X + + def v2v( + self, + X: torch.Tensor, + aggr: str = "mean", + drop_rate: float = 0.0, + v2e_aggr: Optional[str] = None, + v2e_weight: Optional[torch.Tensor] = None, + v2e_drop_rate: Optional[float] = None, + e_weight: Optional[torch.Tensor] = None, + e2v_aggr: Optional[str] = None, + e2v_weight: Optional[torch.Tensor] = None, + e2v_drop_rate: Optional[float] = None, + ): + r"""Message passing of ``vertices to vertices``. The combination of ``v2e`` and ``e2v``. + + Parameters: + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. If specified, this ``aggr`` will be used to both ``v2e`` and ``e2v``. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. Default: ``0.0``. + ``v2e_aggr`` (``str``, optional): The aggregation method for hyperedges to vertices. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. If specified, it will override the ``aggr`` in ``e2v``. + ``v2e_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (vertices point to hyperedges). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``v2e_drop_rate`` (``float``, optional): Dropout rate for hyperedges to vertices. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. If specified, it will override the ``drop_rate`` in ``e2v``. Default: ``None``. + ``e_weight`` (``torch.Tensor``, optional): The hyperedge weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``e2v_aggr`` (``str``, optional): The aggregation method for vertices to hyperedges. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. If specified, it will override the ``aggr`` in ``v2e``. + ``e2v_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (hyperedges point to vertices). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``e2v_drop_rate`` (``float``, optional): Dropout rate for vertices to hyperedges. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. If specified, it will override the ``drop_rate`` in ``v2e``. Default: ``None``. + """ + if v2e_aggr is None: + v2e_aggr = aggr + if e2v_aggr is None: + e2v_aggr = aggr + if v2e_drop_rate is None: + v2e_drop_rate = drop_rate + if e2v_drop_rate is None: + e2v_drop_rate = drop_rate + + X = self.v2e(X, v2e_aggr, v2e_weight, e_weight, drop_rate=v2e_drop_rate) + X = self.e2v(X, e2v_aggr, e2v_weight, drop_rate=e2v_drop_rate) + + return X + + def v2v_of_group( + self, + group_name: str, + X: torch.Tensor, + aggr: str = "mean", + drop_rate: float = 0.0, + v2e_aggr: Optional[str] = None, + v2e_weight: Optional[torch.Tensor] = None, + v2e_drop_rate: Optional[float] = None, + e_weight: Optional[torch.Tensor] = None, + e2v_aggr: Optional[str] = None, + e2v_weight: Optional[torch.Tensor] = None, + e2v_drop_rate: Optional[float] = None, + ): + r"""Message passing of ``vertices to vertices`` in specified hyperedge group. The combination of ``v2e_of_group`` and ``e2v_of_group``. + + Parameters: + ``group_name`` (``str``): The specified hyperedge group. + ``X`` (``torch.Tensor``): Vertex feature matrix. Size :math:`(|\mathcal{V}|, C)`. + ``aggr`` (``str``): The aggregation method. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. If specified, this ``aggr`` will be used to both ``v2e_of_group`` and ``e2v_of_group``. + ``drop_rate`` (``float``): Dropout rate. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. Default: ``0.0``. + ``v2e_aggr`` (``str``, optional): The aggregation method for hyperedges to vertices. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. If specified, it will override the ``aggr`` in ``e2v_of_group``. + ``v2e_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (vertices point to hyperedges). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``v2e_drop_rate`` (``float``, optional): Dropout rate for hyperedges to vertices. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. If specified, it will override the ``drop_rate`` in ``e2v_of_group``. Default: ``None``. + ``e_weight`` (``torch.Tensor``, optional): The hyperedge weight vector. If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``e2v_aggr`` (``str``, optional): The aggregation method for vertices to hyperedges. Can be ``'mean'``, ``'sum'`` and ``'softmax_then_sum'``. If specified, it will override the ``aggr`` in ``v2e_of_group``. + ``e2v_weight`` (``torch.Tensor``, optional): The weight vector attached to connections (hyperedges point to vertices). If not specified, the function will use the weights specified in hypergraph construction. Defaults to ``None``. + ``e2v_drop_rate`` (``float``, optional): Dropout rate for vertices to hyperedges. Randomly dropout the connections in incidence matrix with probability ``drop_rate``. If specified, it will override the ``drop_rate`` in ``v2e_of_group``. Default: ``None``. + """ + assert ( + group_name in self.group_names + ), f"The specified {group_name} is not in existing hyperedge groups." + if v2e_aggr is None: + v2e_aggr = aggr + if e2v_aggr is None: + e2v_aggr = aggr + if v2e_drop_rate is None: + v2e_drop_rate = drop_rate + if e2v_drop_rate is None: + e2v_drop_rate = drop_rate + X = self.v2e_of_group( + group_name, X, v2e_aggr, v2e_weight, e_weight, drop_rate=v2e_drop_rate + ) + X = self.e2v_of_group( + group_name, X, e2v_aggr, e2v_weight, drop_rate=e2v_drop_rate + ) + return X + + def get_linegraph(self, s=1, weight=True): + """ + Get the linegraph of the hypergraph based on the clique expansion. + The edges will be the vertices of the line + graph. Two vertices are connected by an s-line-graph edge if the + corresponding hypergraph edges intersect in at least s hypergraph nodes. + + + Parameters + ---------- + s : Two vertices are connected if the nodes they correspond to share + at least s incident hyper edges. + edge : If edges=True (default)then the edges will be the vertices of the line + graph. Two vertices are connected by an s-line-graph edge if the + corresponding hypergraph edges intersect in at least s hypergraph nodes. + If edges=False, the hypergraph nodes will be the vertices of the line + graph. + weight : + + Returns + ------- + Graph: easygraph.Graph, the linegraph of the hypergraph. + + """ + edge_adjacency = self.edge_adjacency_matrix(s=s, weight=weight) + graph = eg.from_scipy_sparse_matrix(edge_adjacency) + return graph + + def get_clique_expansion(self, s=1, weight=True): + """ + Get the linegraph of the hypergraph based on the clique expansion. + The hypergraph nodes will be the vertices of the line + graph. Two vertices are connected if the nodes they correspond to share + at least s incident hyper edges. + + Parameters + ---------- + s : Two vertices are connected if the nodes they correspond to share + at least s incident hyper edges. + edge : If edges=True (default)then the edges will be the vertices of the line + graph. Two vertices are connected by an s-line-graph edge if the + corresponding hypergraph edges intersect in at least s hypergraph nodes. + If edges=False, the hypergraph nodes will be the vertices of the line + graph. + weight : + + Returns + ------- + Graph: easygraph.Graph, the clique expansion of the hypergraph. + + """ + + if self.cache.get("clique_expansion") is None: + A = self.adjacency_matrix(s=s, weight=weight) + graph = eg.Graph() + A = np.array(np.nonzero(A)) + e1 = np.array([idx for idx in A[0]]) + e2 = np.array([idx for idx in A[1]]) + A = np.array([e1, e2]).T + graph.add_edges_from(A) + graph.add_nodes(list(range(0, self.num_v))) + self.cache["clique_expansion"] = graph + + return self.cache["clique_expansion"] + + def cluster_coefficient(self): + g = self.get_linegraph() + return eg.clustering(g) + + def s_connected_components(self, s=1, edges=True, return_singletons=False): + """ + Returns a generator for the :term:`s-edge-connected components + ` + or the :term:`s-node-connected components ` of the hypergraph. + + Parameters + ---------- + s : int, optional, default 1 + + edges : boolean, optional, default = True + If True will return edge components, if False will return node + components + return_singletons : bool, optional, default = False + + Notes + ----- + If edges=True, this method returns the s-edge-connected components as + lists of lists of edge uids. + An s-edge-component has the property that for any two edges e1 and e2 + there is a sequence of edges starting with e1 and ending with e2 + such that pairwise adjacent edges in the sequence intersect in at least + s nodes. If s=1 these are the path components of the hypergraph. + + If edges=False this method returns s-node-connected components. + A list of sets of uids of the nodes which are s-walk connected. + Two nodes v1 and v2 are s-walk-connected if there is a + sequence of nodes starting with v1 and ending with v2 such that + pairwise adjacent nodes in the sequence share s edges. If s=1 these + are the path components of the hypergraph. + + Example + ------- + >>> S = {'A':{1,2,3},'B':{2,3,4},'C':{5,6},'D':{6}} + >>> H = Hypergraph(S) + + >>> list(H.s_components(edges=True)) + [{'C', 'D'}, {'A', 'B'}] + >>> list(H.s_components(edges=False)) + [{1, 2, 3, 4}, {5, 6}] + + Yields + ------ + s_connected_components : iterator + Iterator returns sets of uids of the edges (or nodes) in the + s-edge(node) components of hypergraph. + + """ + if not edges: + g = self.get_clique_expansion() + else: + g = self.get_linegraph(s) + for c in eg.connected_components(g): + if not return_singletons and len(c) == 1: + continue + yield c + + @staticmethod + def from_hypergraph_hypergcn( + hypergraph, + feature, + with_mediator=False, + remove_selfloop=True, + ): + r"""Construct a graph from a hypergraph with methods proposed in `HyperGCN: A New Method of Training Graph Convolutional Networks on Hypergraphs `_ paper . + + Args: + ``hypergraph`` (``Hypergraph``): The source hypergraph. + ``feature`` (``torch.Tensor``): The feature of the vertices. + ``with_mediator`` (``str``): Whether to use mediator to transform the hyperedges to edges in the graph. Defaults to ``False``. + ``remove_selfloop`` (``bool``): Whether to remove self-loop. Defaults to ``True``. + ``device`` (``torch.device``): The device to store the graph. Defaults to ``torch.device("cpu")``. + """ + + num_v = hypergraph.num_v + assert ( + num_v == feature.shape[0] + ), "The number of vertices in hypergraph and feature.shape[0] must be equal!" + e_list, new_e_list, new_e_weight = hypergraph.e[0], [], [] + rv = torch.rand((feature.shape[1], 1), device=feature.device) + for e in e_list: + num_v_in_e = len(e) + # assert ( + # num_v_in_e >= 2 + # ), "The number of vertices in an edge must be greater than or equal to 2!" + p = torch.mm(feature[e, :], rv).squeeze() + v_a_idx, v_b_idx = torch.argmax(p), torch.argmin(p) + if not with_mediator: + new_e_list.append((e[v_a_idx], e[v_b_idx])) + new_e_weight.append(1.0 / num_v_in_e) + else: + w = 1.0 / (2 * num_v_in_e - 3) + for mid_v_idx in range(num_v_in_e): + if mid_v_idx != v_a_idx and mid_v_idx != v_b_idx: + new_e_list.append([e[v_a_idx], e[mid_v_idx]]) + new_e_weight.append(w) + new_e_list.append([e[v_b_idx], e[mid_v_idx]]) + new_e_weight.append(w) + # remove selfloop + if remove_selfloop: + new_e_list = torch.tensor(new_e_list, dtype=torch.long) + new_e_weight = torch.tensor(new_e_weight, dtype=torch.float) + e_mask = (new_e_list[:, 0] != new_e_list[:, 1]).bool() + new_e_list = new_e_list[e_mask].numpy().tolist() + new_e_weight = new_e_weight[e_mask].numpy().tolist() + + _g = eg.Graph() + + _g.add_nodes(list(range(0, num_v))) + for ( + e, + w, + ) in zip(new_e_list, new_e_weight): + if _g.has_edge(e[0], e[1]): + _g.add_edge(e[0], e[1], weight=(w + _g.adj[e[0]][e[1]]["weight"])) + else: + _g.add_edge(e[0], e[1], weight=w) + now_edges = [] + now_weight = [] + for e in _g.edges: + now_edges.append((e[0], e[1])) + now_weight.append(e[2]["weight"]) + now_edges.extend([(i, i) for i in range(num_v)]) + now_weight.extend([1.0] * num_v) + _g.cache["e_both_side"] = (now_edges, now_weight) + + return _g diff --git a/easygraph/classes/multigraph.py b/easygraph/classes/multigraph.py new file mode 100644 index 0000000..60fe897 --- /dev/null +++ b/easygraph/classes/multigraph.py @@ -0,0 +1,729 @@ +"""Base class for MultiGraph.""" +from copy import deepcopy +from typing import Dict +from typing import List + +import easygraph as eg +import easygraph.convert as convert + +from easygraph.classes.graph import Graph +from easygraph.utils.exception import EasyGraphError + + +__all__ = ["MultiGraph"] + + +class MultiGraph(Graph): + edge_key_dict_factory = dict + + def __init__(self, incoming_graph_data=None, multigraph_input=None, **attr): + """Initialize a graph with edges, name, or graph attributes. + + Parameters + ---------- + incoming_graph_data : input graph + Data to initialize graph. If incoming_graph_data=None (default) + an empty graph is created. The data can be an edge list, or any + EasyGraph graph object. If the corresponding optional Python + packages are installed the data can also be a NumPy matrix + or 2d ndarray, a SciPy sparse matrix, or a PyGraphviz graph. + + multigraph_input : bool or None (default None) + Note: Only used when `incoming_graph_data` is a dict. + If True, `incoming_graph_data` is assumed to be a + dict-of-dict-of-dict-of-dict structure keyed by + node to neighbor to edge keys to edge data for multi-edges. + A EasyGraphError is raised if this is not the case. + If False, :func:`to_easygraph_graph` is used to try to determine + the dict's graph data structure as either a dict-of-dict-of-dict + keyed by node to neighbor to edge data, or a dict-of-iterable + keyed by node to neighbors. + If None, the treatment for True is tried, but if it fails, + the treatment for False is tried. + + attr : keyword arguments, optional (default= no attributes) + Attributes to add to graph as key=value pairs. + + See Also + -------- + convert + + Examples + -------- + >>> G = eg.Graph() # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> G = eg.Graph(name="my graph") + >>> e = [(1, 2), (2, 3), (3, 4)] # list of edges + >>> G = eg.Graph(e) + + Arbitrary graph attribute pairs (key=value) may be assigned + + >>> G = eg.Graph(e, day="Friday") + >>> G.graph + {'day': 'Friday'} + + """ + self.edge_key_dict_factory = self.edge_key_dict_factory + if isinstance(incoming_graph_data, dict) and multigraph_input is not False: + Graph.__init__(self) + try: + convert.from_dict_of_dicts( + incoming_graph_data, create_using=self, multigraph_input=True + ) + self.graph.update(attr) + except Exception as err: + if multigraph_input is True: + raise eg.EasyGraphError( + f"converting multigraph_input raised:\n{type(err)}: {err}" + ) + Graph.__init__(self, incoming_graph_data, **attr) + else: + Graph.__init__(self, incoming_graph_data, **attr) + + def new_edge_key(self, u, v): + """Returns an unused key for edges between nodes `u` and `v`. + + The nodes `u` and `v` do not need to be already in the graph. + + Notes + ----- + In the standard MultiGraph class the new key is the number of existing + edges between `u` and `v` (increased if necessary to ensure unused). + The first edge will have key 0, then 1, etc. If an edge is removed + further new_edge_keys may not be in this order. + + Parameters + ---------- + u, v : nodes + + Returns + ------- + key : int + """ + try: + keydict = self._adj[u][v] + except KeyError: + return 0 + key = len(keydict) + while key in keydict: + key += 1 + return key + + def add_edge(self, u_for_edge, v_for_edge, key=None, **attr): + """Add an edge between u and v. + + The nodes u and v will be automatically added if they are + not already in the graph. + + Edge attributes can be specified with keywords or by directly + accessing the edge's attribute dictionary. See examples below. + + Parameters + ---------- + u_for_edge, v_for_edge : nodes + Nodes can be, for example, strings or numbers. + Nodes must be hashable (and not None) Python objects. + key : hashable identifier, optional (default=lowest unused integer) + Used to distinguish multiedges between a pair of nodes. + attr : keyword arguments, optional + Edge data (or labels or objects) can be assigned using + keyword arguments. + + Returns + ------- + The edge key assigned to the edge. + + See Also + -------- + add_edges_from : add a collection of edges + + Notes + ----- + To replace/update edge data, use the optional key argument + to identify a unique edge. Otherwise a new edge will be created. + + EasyGraph algorithms designed for weighted graphs cannot use + multigraphs directly because it is not clear how to handle + multiedge weights. Convert to Graph using edge attribute + 'weight' to enable weighted graph algorithms. + + Default keys are generated using the method `new_edge_key()`. + This method can be overridden by subclassing the base class and + providing a custom `new_edge_key()` method. + + Examples + -------- + The following all add the edge e=(1, 2) to graph G: + + >>> G = eg.MultiGraph() + >>> e = (1, 2) + >>> ekey = G.add_edge(1, 2) # explicit two-node form + >>> G.add_edge(*e) # single edge as tuple of two nodes + 1 + >>> G.add_edges_from([(1, 2)]) # add edges from iterable container + [2] + + Associate data to edges using keywords: + + >>> ekey = G.add_edge(1, 2, weight=3) + >>> ekey = G.add_edge(1, 2, key=0, weight=4) # update data for key=0 + >>> ekey = G.add_edge(1, 3, weight=7, capacity=15, length=342.7) + + For non-string attribute keys, use subscript notation. + + >>> ekey = G.add_edge(1, 2) + >>> G[1][2][0].update({0: 5}) + >>> G.edges[1, 2, 0].update({0: 5}) + """ + u, v = u_for_edge, v_for_edge + # add nodes + if u not in self._adj: + if u is None: + raise ValueError("None cannot be a node") + self._adj[u] = self.adjlist_inner_dict_factory() + self._node[u] = self.node_attr_dict_factory() + if v not in self._adj: + if v is None: + raise ValueError("None cannot be a node") + self._adj[v] = self.adjlist_inner_dict_factory() + self._node[v] = self.node_attr_dict_factory() + if key is None: + key = self.new_edge_key(u, v) + if v in self._adj[u]: + keydict = self._adj[u][v] + datadict = keydict.get(key, self.edge_attr_dict_factory()) + datadict.update(attr) + keydict[key] = datadict + else: + # selfloops work this way without special treatment + datadict = self.edge_attr_dict_factory() + datadict.update(attr) + keydict = self.edge_key_dict_factory() + keydict[key] = datadict + self._adj[u][v] = keydict + self._adj[v][u] = keydict + return key + + def add_edges_from(self, ebunch_to_add, **attr): + """Add all the edges in ebunch_to_add. + + Parameters + ---------- + ebunch_to_add : container of edges + Each edge given in the container will be added to the + graph. The edges can be: + + - 2-tuples (u, v) or + - 3-tuples (u, v, d) for an edge data dict d, or + - 3-tuples (u, v, k) for not iterable key k, or + - 4-tuples (u, v, k, d) for an edge with data and key k + + attr : keyword arguments, optional + Edge data (or labels or objects) can be assigned using + keyword arguments. + + Returns + ------- + A list of edge keys assigned to the edges in `ebunch`. + + See Also + -------- + add_edge : add a single edge + add_weighted_edges_from : convenient way to add weighted edges + + Notes + ----- + Adding the same edge twice has no effect but any edge data + will be updated when each duplicate edge is added. + + Edge attributes specified in an ebunch take precedence over + attributes specified via keyword arguments. + + Default keys are generated using the method ``new_edge_key()``. + This method can be overridden by subclassing the base class and + providing a custom ``new_edge_key()`` method. + + Examples + -------- + >>> G = eg.Graph() # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> G.add_edges_from([(0, 1), (1, 2)]) # using a list of edge tuples + >>> e = zip(range(0, 3), range(1, 4)) + >>> G.add_edges_from(e) # Add the path graph 0-1-2-3 + + Associate data to edges + + >>> G.add_edges_from([(1, 2), (2, 3)], weight=3) + >>> G.add_edges_from([(3, 4), (1, 4)], label="WN2898") + """ + keylist = [] + for e in ebunch_to_add: + ne = len(e) + if ne == 4: + u, v, key, dd = e + elif ne == 3: + u, v, dd = e + key = None + elif ne == 2: + u, v = e + dd = {} + key = None + else: + msg = f"Edge tuple {e} must be a 2-tuple, 3-tuple or 4-tuple." + raise EasyGraphError(msg) + ddd = {} + ddd.update(attr) + try: + ddd.update(dd) + except (TypeError, ValueError): + if ne != 3: + raise + key = dd # ne == 3 with 3rd value not dict, must be a key + key = self.add_edge(u, v, key) + self[u][v][key].update(ddd) + keylist.append(key) + return keylist + + def remove_edge(self, u, v, key=None): + """Remove an edge between u and v. + + Parameters + ---------- + u, v : nodes + Remove an edge between nodes u and v. + key : hashable identifier, optional (default=None) + Used to distinguish multiple edges between a pair of nodes. + If None remove a single (arbitrary) edge between u and v. + + Raises + ------ + EasyGraphError + If there is not an edge between u and v, or + if there is no edge with the specified key. + + See Also + -------- + remove_edges_from : remove a collection of edges + + Examples + -------- + For multiple edges + + >>> G = eg.MultiGraph() # or MultiDiGraph, etc + >>> G.add_edges_from([(1, 2), (1, 2), (1, 2)]) # key_list returned + [0, 1, 2] + >>> G.remove_edge(1, 2) # remove a single (arbitrary) edge + + For edges with keys + + >>> G = eg.MultiGraph() # or MultiDiGraph, etc + >>> G.add_edge(1, 2, key="first") + 'first' + >>> G.add_edge(1, 2, key="second") + 'second' + >>> G.remove_edge(1, 2, key="second") + + """ + try: + d = self._adj[u][v] + except KeyError as err: + raise EasyGraphError(f"The edge {u}-{v} is not in the graph.") from err + # remove the edge with specified data + if key is None: + d.popitem() + else: + try: + del d[key] + except KeyError as err: + msg = f"The edge {u}-{v} with key {key} is not in the graph." + raise EasyGraphError(msg) from err + if len(d) == 0: + # remove the key entries if last edge + del self._adj[u][v] + if u != v: # check for selfloop + del self._adj[v][u] + + def remove_edges_from(self, ebunch): + """Remove all edges specified in ebunch. + + Parameters + ---------- + ebunch: list or container of edge tuples + Each edge given in the list or container will be removed + from the graph. The edges can be: + + - 2-tuples (u, v) All edges between u and v are removed. + - 3-tuples (u, v, key) The edge identified by key is removed. + - 4-tuples (u, v, key, data) where data is ignored. + + See Also + -------- + remove_edge : remove a single edge + + Notes + ----- + Will fail silently if an edge in ebunch is not in the graph. + + Examples + -------- + Removing multiple copies of edges + + >>> G = eg.MultiGraph() + >>> keys = G.add_edges_from([(1, 2), (1, 2), (1, 2)]) + >>> G.remove_edges_from([(1, 2), (1, 2)]) + >>> list(G.edges()) + [(1, 2)] + >>> G.remove_edges_from([(1, 2), (1, 2)]) # silently ignore extra copy + >>> list(G.edges) # now empty graph + [] + """ + for e in ebunch: + try: + self.remove_edge(*e[:3]) + except EasyGraphError: + pass + + def has_edge(self, u, v, key=None): + """Returns True if the graph has an edge between nodes u and v. + + This is the same as `v in G[u] or key in G[u][v]` + without KeyError exceptions. + + Parameters + ---------- + u, v : nodes + Nodes can be, for example, strings or numbers. + + key : hashable identifier, optional (default=None) + If specified return True only if the edge with + key is found. + + Returns + ------- + edge_ind : bool + True if edge is in the graph, False otherwise. + + Examples + -------- + Can be called either using two nodes u, v, an edge tuple (u, v), + or an edge tuple (u, v, key). + + >>> G = eg.MultiGraph() # or MultiDiGraph + >>> G = eg.complete_graph(4, create_using=eg.MultiDiGraph) + >>> G.has_edge(0, 1) # using two nodes + True + >>> e = (0, 1) + >>> G.has_edge(*e) # e is a 2-tuple (u, v) + True + >>> G.add_edge(0, 1, key="a") + 'a' + >>> G.has_edge(0, 1, key="a") # specify key + True + >>> e = (0, 1, "a") + >>> G.has_edge(*e) # e is a 3-tuple (u, v, 'a') + True + + The following syntax are equivalent: + + >>> G.has_edge(0, 1) + True + >>> 1 in G[0] # though this gives :exc:`KeyError` if 0 not in G + True + + """ + try: + if key is None: + return v in self._adj[u] + else: + return key in self._adj[u][v] + except KeyError: + return False + + @property + def edges(self): + edges = list() + seen = {} + for n, nbrs in self._adj.items(): + for nbr, kd in nbrs.items(): + if nbr not in seen: + for k, dd in kd.items(): + edges.append((n, nbr, k, dd)) + seen[n] = 1 + del seen + return edges + + def get_edge_data(self, u, v, key=None, default=None): + """Returns the attribute dictionary associated with edge (u, v). + + This is identical to `G[u][v][key]` except the default is returned + instead of an exception is the edge doesn't exist. + + Parameters + ---------- + u, v : nodes + + default : any Python object (default=None) + Value to return if the edge (u, v) is not found. + + key : hashable identifier, optional (default=None) + Return data only for the edge with specified key. + + Returns + ------- + edge_dict : dictionary + The edge attribute dictionary. + + Examples + -------- + >>> G = eg.MultiGraph() # or MultiDiGraph + >>> key = G.add_edge(0, 1, key="a", weight=7) + >>> G[0][1]["a"] # key='a' + {'weight': 7} + >>> G.edges[0, 1, "a"] # key='a' + {'weight': 7} + + Warning: we protect the graph data structure by making + `G.edges` and `G[1][2]` read-only dict-like structures. + However, you can assign values to attributes in e.g. + `G.edges[1, 2, 'a']` or `G[1][2]['a']` using an additional + bracket as shown next. You need to specify all edge info + to assign to the edge data associated with an edge. + + >>> G[0][1]["a"]["weight"] = 10 + >>> G.edges[0, 1, "a"]["weight"] = 10 + >>> G[0][1]["a"]["weight"] + 10 + >>> G.edges[1, 0, "a"]["weight"] + 10 + + >>> G = eg.MultiGraph() # or MultiDiGraph + >>> G = eg.complete_graph(4, create_using=eg.MultiDiGraph) + >>> G.get_edge_data(0, 1) + {0: {}} + >>> e = (0, 1) + >>> G.get_edge_data(*e) # tuple form + {0: {}} + >>> G.get_edge_data("a", "b", default=0) # edge not in graph, return 0 + 0 + """ + try: + if key is None: + return self._adj[u][v] + else: + return self._adj[u][v][key] + except KeyError: + return default + + @property + def degree(self, weight="weight"): + degree = dict() + if weight is None: + for n in self._nodes: + nbrs = self._succ[n] + deg = sum(len(keys) for keys in nbrs.values()) + ( + n in nbrs and len(nbrs[n]) + ) + degree[n] = deg + else: + for n in self._nodes: + nbrs = self._succ[n] + deg = sum( + d.get(weight, 1) + for key_dict in nbrs.values() + for d in key_dict.values() + ) + if n in nbrs: + deg += sum(d.get(weight, 1) for d in nbrs[n].values()) + degree[n] = deg + + def is_multigraph(self): + """Returns True if graph is a multigraph, False otherwise.""" + return True + + def is_directed(self): + """Returns True if graph is directed, False otherwise.""" + return False + + def copy(self): + """Returns a copy of the graph. + + The copy method by default returns an independent shallow copy + of the graph and attributes. That is, if an attribute is a + container, that container is shared by the original an the copy. + Use Python's `copy.deepcopy` for new containers. + + Notes + ----- + All copies reproduce the graph structure, but data attributes + may be handled in different ways. There are four types of copies + of a graph that people might want. + + Deepcopy -- A "deepcopy" copies the graph structure as well as + all data attributes and any objects they might contain. + The entire graph object is new so that changes in the copy + do not affect the original object. (see Python's copy.deepcopy) + + Data Reference (Shallow) -- For a shallow copy the graph structure + is copied but the edge, node and graph attribute dicts are + references to those in the original graph. This saves + time and memory but could cause confusion if you change an attribute + in one graph and it changes the attribute in the other. + EasyGraph does not provide this level of shallow copy. + + Independent Shallow -- This copy creates new independent attribute + dicts and then does a shallow copy of the attributes. That is, any + attributes that are containers are shared between the new graph + and the original. This is exactly what `dict.copy()` provides. + You can obtain this style copy using: + + >>> G = eg.path_graph(5) + >>> H = G.copy() + >>> H = eg.Graph(G) + >>> H = G.__class__(G) + + Fresh Data -- For fresh data, the graph structure is copied while + new empty data attribute dicts are created. The resulting graph + is independent of the original and it has no edge, node or graph + attributes. Fresh copies are not enabled. Instead use: + + >>> H = G.__class__() + >>> H.add_nodes_from(G) + >>> H.add_edges_from(G.edges) + + See the Python copy module for more information on shallow + and deep copies, https://docs.python.org/3/library/copy.html. + + Returns + ------- + G : Graph + A copy of the graph. + + See Also + -------- + to_directed: return a directed copy of the graph. + + Examples + -------- + >>> G = eg.path_graph(4) # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> H = G.copy() + + """ + G = self.__class__() + G.graph.update(self.graph) + G.add_nodes_from((n, d.copy()) for n, d in self._node.items()) + G.add_edges_from( + (u, v, key, datadict.copy()) + for u, nbrs in self._adj.items() + for v, keydict in nbrs.items() + for key, datadict in keydict.items() + ) + return G + + def to_directed(self): + """Returns a directed representation of the graph. + + Returns + ------- + G : MultiDiGraph + A directed graph with the same name, same nodes, and with + each edge (u, v, data) replaced by two directed edges + (u, v, data) and (v, u, data). + + Notes + ----- + This returns a "deepcopy" of the edge, node, and + graph attributes which attempts to completely copy + all of the data and references. + + This is in contrast to the similar D=DiGraph(G) which returns a + shallow copy of the data. + + See the Python copy module for more information on shallow + and deep copies, https://docs.python.org/3/library/copy.html. + + Warning: If you have subclassed MultiGraph to use dict-like objects + in the data structure, those changes do not transfer to the + MultiDiGraph created by this method. + + Examples + -------- + >>> G = eg.Graph() # or MultiGraph, etc + >>> G.add_edge(0, 1) + >>> H = G.to_directed() + >>> list(H.edges) + [(0, 1), (1, 0)] + + If already directed, return a (deep) copy + + >>> G = eg.DiGraph() # or MultiDiGraph, etc + >>> G.add_edge(0, 1) + >>> H = G.to_directed() + >>> list(H.edges) + [(0, 1)] + """ + G = eg.MultiDiGraph() + G.graph.update(deepcopy(self.graph)) + G.add_nodes_from((n, deepcopy(d)) for n, d in self._node.items()) + G.add_edges_from( + (u, v, key, deepcopy(datadict)) + for u, nbrs in self.adj.items() + for v, keydict in nbrs.items() + for key, datadict in keydict.items() + ) + return G + + def number_of_edges(self, u=None, v=None): + """Returns the number of edges between two nodes. + + Parameters + ---------- + u, v : nodes, optional (Gefault=all edges) + If u and v are specified, return the number of edges between + u and v. Otherwise return the total number of all edges. + + Returns + ------- + nedges : int + The number of edges in the graph. If nodes `u` and `v` are + specified return the number of edges between those nodes. If + the graph is directed, this only returns the number of edges + from `u` to `v`. + + See Also + -------- + size + + Examples + -------- + For undirected multigraphs, this method counts the total number + of edges in the graph:: + + >>> G = eg.MultiGraph() + >>> G.add_edges_from([(0, 1), (0, 1), (1, 2)]) + [0, 1, 0] + >>> G.number_of_edges() + 3 + + If you specify two nodes, this counts the total number of edges + joining the two nodes:: + + >>> G.number_of_edges(0, 1) + 2 + + For directed multigraphs, this method can count the total number + of directed edges from `u` to `v`:: + + >>> G = eg.MultiDiGraph() + >>> G.add_edges_from([(0, 1), (0, 1), (1, 0)]) + [0, 1, 0] + >>> G.number_of_edges(0, 1) + 2 + >>> G.number_of_edges(1, 0) + 1 + + """ + if u is None: + return self.size() + try: + edgedata = self._adj[u][v] + except KeyError: + return 0 # no such edge + return len(edgedata) diff --git a/easygraph/classes/operation.py b/easygraph/classes/operation.py new file mode 100644 index 0000000..cb7b2d0 --- /dev/null +++ b/easygraph/classes/operation.py @@ -0,0 +1,447 @@ +from itertools import chain + +import easygraph as eg + +from easygraph.utils import * + + +__all__ = [ + "set_edge_attributes", + "add_path", + "set_node_attributes", + "selfloop_edges", + "topological_sort", + "number_of_selfloops", + "density", +] + + +def set_edge_attributes(G, values, name=None): + """Sets edge attributes from a given value or dictionary of values. + + .. Warning:: The call order of arguments `values` and `name` + switched between v1.x & v2.x. + + Parameters + ---------- + G : EasyGraph Graph + + values : scalar value, dict-like + What the edge attribute should be set to. If `values` is + not a dictionary, then it is treated as a single attribute value + that is then applied to every edge in `G`. This means that if + you provide a mutable object, like a list, updates to that object + will be reflected in the edge attribute for each edge. The attribute + name will be `name`. + + If `values` is a dict or a dict of dict, it should be keyed + by edge tuple to either an attribute value or a dict of attribute + key/value pairs used to update the edge's attributes. + For multigraphs, the edge tuples must be of the form ``(u, v, key)``, + where `u` and `v` are nodes and `key` is the edge key. + For non-multigraphs, the keys must be tuples of the form ``(u, v)``. + + name : string (optional, default=None) + Name of the edge attribute to set if values is a scalar. + + Examples + -------- + After computing some property of the edges of a graph, you may want + to assign a edge attribute to store the value of that property for + each edge:: + + >>> G = eg.path_graph(3) + >>> bb = eg.edge_betweenness_centrality(G, normalized=False) + >>> eg.set_edge_attributes(G, bb, "betweenness") + >>> G.edges[1, 2]["betweenness"] + 2.0 + + If you provide a list as the second argument, updates to the list + will be reflected in the edge attribute for each edge:: + + >>> labels = [] + >>> eg.set_edge_attributes(G, labels, "labels") + >>> labels.append("foo") + >>> G.edges[0, 1]["labels"] + ['foo'] + >>> G.edges[1, 2]["labels"] + ['foo'] + + If you provide a dictionary of dictionaries as the second argument, + the entire dictionary will be used to update edge attributes:: + + >>> G = eg.path_graph(3) + >>> attrs = {(0, 1): {"attr1": 20, "attr2": "nothing"}, (1, 2): {"attr2": 3}} + >>> eg.set_edge_attributes(G, attrs) + >>> G[0][1]["attr1"] + 20 + >>> G[0][1]["attr2"] + 'nothing' + >>> G[1][2]["attr2"] + 3 + + Note that if the dict contains edges that are not in `G`, they are + silently ignored:: + + >>> G = eg.Graph([(0, 1)]) + >>> eg.set_edge_attributes(G, {(1, 2): {"weight": 2.0}}) + >>> (1, 2) in G.edges() + False + + """ + if name is not None: + # `values` does not contain attribute names + try: + # if `values` is a dict using `.items()` => {edge: value} + if G.is_multigraph(): + for (u, v, key), value in values.items(): + try: + G[u][v][key][name] = value + except KeyError: + pass + else: + for (u, v), value in values.items(): + try: + G[u][v][name] = value + except KeyError: + pass + except AttributeError: + # treat `values` as a constant + for u, v, data in G.edges: + data[name] = values + else: + # `values` consists of doct-of-dict {edge: {attr: value}} shape + if G.is_multigraph(): + for (u, v, key), d in values.items(): + try: + G[u][v][key].update(d) + except KeyError: + pass + else: + for (u, v), d in values.items(): + try: + G[u][v].update(d) + except KeyError: + pass + + +def add_path(G_to_add_to, nodes_for_path, **attr): + """Add a path to the Graph G_to_add_to. + + Parameters + ---------- + G_to_add_to : graph + A EasyGraph graph + nodes_for_path : iterable container + A container of nodes. A path will be constructed from + the nodes (in order) and added to the graph. + attr : keyword arguments, optional (default= no attributes) + Attributes to add to every edge in path. + + See Also + -------- + add_star, add_cycle + + Examples + -------- + >>> G = eg.Graph() + >>> eg.add_path(G, [0, 1, 2, 3]) + >>> eg.add_path(G, [10, 11, 12], weight=7) + """ + nlist = iter(nodes_for_path) + try: + first_node = next(nlist) + except StopIteration: + return + G_to_add_to.add_node(first_node) + G_to_add_to.add_edges_from(pairwise(chain((first_node,), nlist)), **attr) + + +def set_node_attributes(G, values, name=None): + """Sets node attributes from a given value or dictionary of values. + + .. Warning:: The call order of arguments `values` and `name` + switched between v1.x & v2.x. + + Parameters + ---------- + G : EasyGraph Graph + + values : scalar value, dict-like + What the node attribute should be set to. If `values` is + not a dictionary, then it is treated as a single attribute value + that is then applied to every node in `G`. This means that if + you provide a mutable object, like a list, updates to that object + will be reflected in the node attribute for every node. + The attribute name will be `name`. + + If `values` is a dict or a dict of dict, it should be keyed + by node to either an attribute value or a dict of attribute key/value + pairs used to update the node's attributes. + + name : string (optional, default=None) + Name of the node attribute to set if values is a scalar. + + Examples + -------- + After computing some property of the nodes of a graph, you may want + to assign a node attribute to store the value of that property for + each node:: + + >>> G = eg.path_graph(3) + >>> bb = eg.betweenness_centrality(G) + >>> isinstance(bb, dict) + True + >>> eg.set_node_attributes(G, bb, "betweenness") + >>> G.nodes[1]["betweenness"] + 1.0 + + If you provide a list as the second argument, updates to the list + will be reflected in the node attribute for each node:: + + >>> G = eg.path_graph(3) + >>> labels = [] + >>> eg.set_node_attributes(G, labels, "labels") + >>> labels.append("foo") + >>> G.nodes[0]["labels"] + ['foo'] + >>> G.nodes[1]["labels"] + ['foo'] + >>> G.nodes[2]["labels"] + ['foo'] + + If you provide a dictionary of dictionaries as the second argument, + the outer dictionary is assumed to be keyed by node to an inner + dictionary of node attributes for that node:: + + >>> G = eg.path_graph(3) + >>> attrs = {0: {"attr1": 20, "attr2": "nothing"}, 1: {"attr2": 3}} + >>> eg.set_node_attributes(G, attrs) + >>> G.nodes[0]["attr1"] + 20 + >>> G.nodes[0]["attr2"] + 'nothing' + >>> G.nodes[1]["attr2"] + 3 + >>> G.nodes[2] + {} + + Note that if the dictionary contains nodes that are not in `G`, the + values are silently ignored:: + + >>> G = eg.Graph() + >>> G.add_node(0) + >>> eg.set_node_attributes(G, {0: "red", 1: "blue"}, name="color") + >>> G.nodes[0]["color"] + 'red' + >>> 1 in G.nodes + False + + """ + # Set node attributes based on type of `values` + if name is not None: # `values` must not be a dict of dict + try: # `values` is a dict + for n, v in values.items(): + try: + G.nodes[n][name] = values[n] + except KeyError: + pass + except AttributeError: # `values` is a constant + for n in G: + G.nodes[n][name] = values + else: # `values` must be dict of dict + for n, d in values.items(): + try: + G.nodes[n].update(d) + except KeyError: + pass + + +def topological_generations(G): + if not G.is_directed(): + raise AssertionError("Topological sort not defined on undirected graphs.") + indegree_map = {v: d for v, d in G.in_degree().items() if d > 0} + zero_indegree = [v for v, d in G.in_degree().items() if d == 0] + while zero_indegree: + this_generation = zero_indegree + zero_indegree = [] + for node in this_generation: + if node not in G: + raise RuntimeError("Graph changed during iteration") + for child in G.neighbors(node): + try: + indegree_map[child] -= 1 + except KeyError as err: + raise RuntimeError("Graph changed during iteration") from err + if indegree_map[child] == 0: + zero_indegree.append(child) + del indegree_map[child] + yield this_generation + + if indegree_map: + raise AssertionError("Graph contains a cycle or graph changed during iteration") + + +def topological_sort(G): + for generation in topological_generations(G): + yield from generation + + +def number_of_selfloops(G): + """Returns the number of selfloop edges. + + A selfloop edge has the same node at both ends. + + Returns + ------- + nloops : int + The number of selfloops. + + See Also + -------- + nodes_with_selfloops, selfloop_edges + + Examples + -------- + >>> G = eg.Graph() # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> G.add_edge(1, 1) + >>> G.add_edge(1, 2) + >>> eg.number_of_selfloops(G) + 1 + """ + return sum(1 for _ in eg.selfloop_edges(G)) + + +def selfloop_edges(G, data=False, keys=False, default=None): + """Returns an iterator over selfloop edges. + + A selfloop edge has the same node at both ends. + + Parameters + ---------- + G : graph + A EasyGraph graph. + data : string or bool, optional (default=False) + Return selfloop edges as two tuples (u, v) (data=False) + or three-tuples (u, v, datadict) (data=True) + or three-tuples (u, v, datavalue) (data='attrname') + keys : bool, optional (default=False) + If True, return edge keys with each edge. + default : value, optional (default=None) + Value used for edges that don't have the requested attribute. + Only relevant if data is not True or False. + + Returns + ------- + edgeiter : iterator over edge tuples + An iterator over all selfloop edges. + + See Also + -------- + nodes_with_selfloops, number_of_selfloops + + Examples + -------- + >>> G = eg.MultiGraph() # or Graph, DiGraph, MultiDiGraph, etc + >>> ekey = G.add_edge(1, 1) + >>> ekey = G.add_edge(1, 2) + >>> list(eg.selfloop_edges(G)) + [(1, 1)] + >>> list(eg.selfloop_edges(G, data=True)) + [(1, 1, {})] + >>> list(eg.selfloop_edges(G, keys=True)) + [(1, 1, 0)] + >>> list(eg.selfloop_edges(G, keys=True, data=True)) + [(1, 1, 0, {})] + """ + if data is True: + if G.is_multigraph(): + if keys is True: + return ( + (n, n, k, d) + for n, nbrs in G.adj.items() + if n in nbrs + for k, d in nbrs[n].items() + ) + else: + return ( + (n, n, d) + for n, nbrs in G.adj.items() + if n in nbrs + for d in nbrs[n].values() + ) + else: + return ((n, n, nbrs[n]) for n, nbrs in G.adj.items() if n in nbrs) + elif data is not False: + if G.is_multigraph(): + if keys is True: + return ( + (n, n, k, d.get(data, default)) + for n, nbrs in G.adj.items() + if n in nbrs + for k, d in nbrs[n].items() + ) + else: + return ( + (n, n, d.get(data, default)) + for n, nbrs in G.adj.items() + if n in nbrs + for d in nbrs[n].values() + ) + else: + return ( + (n, n, nbrs[n].get(data, default)) + for n, nbrs in G.adj.items() + if n in nbrs + ) + else: + if G.is_multigraph(): + if keys is True: + return ( + (n, n, k) for n, nbrs in G.adj.items() if n in nbrs for k in nbrs[n] + ) + else: + return ( + (n, n) + for n, nbrs in G.adj.items() + if n in nbrs + for i in range(len(nbrs[n])) # for easy edge removal (#4068) + ) + else: + return ((n, n) for n, nbrs in G.adj.items() if n in nbrs) + + +@hybrid("cpp_density") +def density(G): + r"""Returns the density of a graph. + + The density for undirected graphs is + + .. math:: + + d = \frac{2m}{n(n-1)}, + + and for directed graphs is + + .. math:: + + d = \frac{m}{n(n-1)}, + + where `n` is the number of nodes and `m` is the number of edges in `G`. + + Notes + ----- + The density is 0 for a graph without edges and 1 for a complete graph. + The density of multigraphs can be higher than 1. + + Self loops are counted in the total number of edges so graphs with self + loops can have density higher than 1. + """ + n = G.number_of_nodes() + m = G.number_of_edges() + if m == 0 or n <= 1: + return 0 + d = m / (n * (n - 1)) + if not G.is_directed(): + d *= 2 + return d diff --git a/easygraph/classes/test_base_graph_class.py b/easygraph/classes/test_base_graph_class.py new file mode 100644 index 0000000..1f9929e --- /dev/null +++ b/easygraph/classes/test_base_graph_class.py @@ -0,0 +1,27 @@ +import os +import sys +import time + +import easygraph as eg + + +print( + os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "../cpp_easygraph") + ) +) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) +import easygraph.classes as cls # Spend 4.9s on importing this damn big lib. + + +def test_iter(): + g = eg.Graph() + # repeated endings test + g.add_edge(None, None) # 1 + g.add_edge(True, False) + + g.add_edge(0b1000, 100) + print(g.edges) + + +test_iter() diff --git a/easygraph/classes/tests/__init__.py b/easygraph/classes/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easygraph/classes/tests/test_base.py b/easygraph/classes/tests/test_base.py new file mode 100644 index 0000000..808a35b --- /dev/null +++ b/easygraph/classes/tests/test_base.py @@ -0,0 +1,145 @@ +import sys + +import pytest + + +np = pytest.importorskip("numpy") +pd = pytest.importorskip("pandas") +sp = pytest.importorskip("scipy") + +import easygraph as eg + +from easygraph.utils.misc import * + + +class TestConvertNumpyArray: + def setup_method(self): + self.G1 = eg.complete_graph(5) + + def assert_equal(self, G1, G2): + assert nodes_equal(G1.nodes, G2.nodes) + assert edges_equal(G1.edges, G2.edges, need_data=False) + + def identity_conversion(self, G, A, create_using): + assert A.sum() > 0 + GG = eg.from_numpy_array(A, create_using=create_using) + self.assert_equal(G, GG) + GW = eg.to_easygraph_graph(A, create_using=create_using) + self.assert_equal(G, GW) + + def test_identity_graph_array(self): + A = eg.to_numpy_array(self.G1) + self.identity_conversion(self.G1, A, eg.Graph()) + + +class TestConvertPandas: + def setup_method(self): + self.rng = np.random.RandomState(seed=5) + ints = self.rng.randint(1, 11, size=(3, 2)) + a = ["A", "B", "C"] + b = ["D", "A", "E"] + df = pd.DataFrame(ints, columns=["weight", "cost"]) + df[0] = a + df["b"] = b + self.df = df + + mdf = pd.DataFrame([[4, 16, "A", "D"]], columns=["weight", "cost", 0, "b"]) + self.mdf = pd.concat([df, mdf]) + + def assert_equal(self, G1, G2): + assert nodes_equal(G1.nodes, G2.nodes) + assert edges_equal(G1.edges, G2.edges, need_data=False) + + def test_from_edgelist_multi_attr(self): + Gtrue = eg.Graph( + [ + ("E", "C", {"cost": 9, "weight": 10}), + ("B", "A", {"cost": 1, "weight": 7}), + ("A", "D", {"cost": 7, "weight": 4}), + ] + ) + G = eg.from_pandas_edgelist(self.df, 0, "b", ["weight", "cost"]) + self.assert_equal(G, Gtrue) + + def test_from_adjacency(self): + Gtrue = eg.DiGraph([("A", "B"), ("B", "C")]) + data = { + "A": {"A": 0, "B": 0, "C": 0}, + "B": {"A": 1, "B": 0, "C": 0}, + "C": {"A": 0, "B": 1, "C": 0}, + } + dftrue = pd.DataFrame(data, dtype=np.intp) + df = dftrue[["A", "C", "B"]] + G = eg.from_pandas_adjacency(df, create_using=eg.DiGraph()) + self.assert_equal(G, Gtrue) + + +class TestConvertScipy: + def setup_method(self): + self.G1 = eg.complete_graph(3) + + def assert_equal(self, G1, G2): + assert nodes_equal(G1.nodes, G2.nodes) + assert edges_equal(G1.edges, G2.edges, need_data=False) + + @pytest.mark.skipif( + sys.version_info < (3, 8), reason="requires python3.8 or higher" + ) + def test_from_scipy(self): + data = sp.sparse.csr_matrix([[0, 1, 1], [1, 0, 1], [1, 1, 0]]) + G = eg.from_scipy_sparse_matrix(data) + self.assert_equal(self.G1, G) + + +def test_from_edgelist(): + edgelist = [(0, 1), (1, 2)] + G = eg.from_edgelist(edgelist) + assert sorted((u, v) for u, v, _ in G.edges) == [(0, 1), (1, 2)] + + +def test_from_dict_of_lists(): + d = {0: [1], 1: [2]} + G = eg.to_easygraph_graph(d) + assert sorted((u, v) for u, v, _ in G.edges) == [(0, 1), (1, 2)] + + +def test_from_dict_of_dicts(): + d = {0: {1: {}}, 1: {2: {}}} + G = eg.to_easygraph_graph(d) + assert sorted((u, v) for u, v, _ in G.edges) == [(0, 1), (1, 2)] + + +def test_from_numpy_array(): + G = eg.complete_graph(3) + A = eg.to_numpy_array(G) + G2 = eg.from_numpy_array(A) + assert sorted((u, v) for u, v, _ in G.edges) == sorted( + (u, v) for u, v, _ in G2.edges + ) + + +def test_from_pandas_edgelist(): + df = pd.DataFrame({"source": [0, 1], "target": [1, 2], "weight": [0.5, 0.7]}) + G = eg.from_pandas_edgelist(df, source="source", target="target", edge_attr=True) + assert sorted((u, v) for u, v, _ in G.edges) == [(0, 1), (1, 2)] + + +def test_from_pandas_adjacency(): + df = pd.DataFrame([[0, 1], [1, 0]], columns=["A", "B"], index=["A", "B"]) + G = eg.from_pandas_adjacency(df) + assert sorted((u, v) for u, v, _ in G.edges) == [("A", "B")] + + +def test_from_scipy_sparse_matrix(): + mat = sp.sparse.csr_matrix([[0, 1, 0], [1, 0, 1], [0, 1, 0]]) + G = eg.from_scipy_sparse_matrix(mat) + expected_edges = [(0, 1), (1, 2)] + assert sorted((u, v) for u, v, _ in G.edges) == expected_edges + + +def test_invalid_dict_type(): + class NotGraph: + pass + + with pytest.raises(eg.EasyGraphError): + eg.to_easygraph_graph(NotGraph()) diff --git a/easygraph/classes/tests/test_directed_graph.py b/easygraph/classes/tests/test_directed_graph.py new file mode 100644 index 0000000..568286f --- /dev/null +++ b/easygraph/classes/tests/test_directed_graph.py @@ -0,0 +1,97 @@ +import os +import unittest + +from easygraph import DiGraph + + +class TestDiGraph(unittest.TestCase): + def setUp(self): + self.G = DiGraph() + + def test_add_node_and_exists(self): + self.G.add_node("A") + self.assertTrue(self.G.has_node("A")) + self.assertIn("A", self.G.nodes) + + def test_add_nodes_with_attrs(self): + self.G.add_nodes(["B", "C"], nodes_attr=[{"age": 30}, {"age": 40}]) + self.assertEqual(self.G.nodes["B"]["age"], 30) + self.assertEqual(self.G.nodes["C"]["age"], 40) + + def test_add_edge_and_attrs(self): + self.G.add_edge("A", "B", weight=5) + self.assertTrue(self.G.has_edge("A", "B")) + self.assertEqual(self.G.adj["A"]["B"]["weight"], 5) + + def test_add_edges_with_attrs(self): + self.G.add_edges([("B", "C"), ("C", "D")], edges_attr=[{"w": 1}, {"w": 2}]) + self.assertEqual(self.G.adj["B"]["C"]["w"], 1) + self.assertEqual(self.G.adj["C"]["D"]["w"], 2) + + def test_remove_node_and_edges(self): + self.G.add_edges([("X", "Y"), ("Y", "Z")]) + self.G.remove_node("Y") + self.assertFalse("Y" in self.G.nodes) + self.assertFalse(self.G.has_edge("Y", "Z")) + + def test_remove_edge(self): + self.G.add_edge("M", "N") + self.G.remove_edge("M", "N") + self.assertFalse(self.G.has_edge("M", "N")) + + def test_degrees(self): + self.G.add_edges( + [("A", "B"), ("C", "B")], edges_attr=[{"weight": 3}, {"weight": 2}] + ) + + in_degrees = self.G.in_degree(weight="weight") + out_degrees = self.G.out_degree(weight="weight") + degrees = self.G.degree(weight="weight") + + self.assertEqual(in_degrees["B"], 5) + self.assertEqual(out_degrees["A"], 3) + self.assertEqual(degrees["B"], 5) + + def test_neighbors_and_preds(self): + self.G.add_edges([("P", "Q"), ("R", "P")]) + self.assertIn("Q", list(self.G.neighbors("P"))) + self.assertIn("R", list(self.G.predecessors("P"))) + all_n = list(self.G.all_neighbors("P")) + self.assertIn("Q", all_n) + self.assertIn("R", all_n) + + def test_size_and_num_edges_nodes(self): + self.G.add_edges([("X", "Y"), ("Y", "Z")]) + self.assertEqual(self.G.size(), 2) + self.assertEqual(self.G.number_of_edges(), 2) + self.assertEqual(self.G.number_of_nodes(), 3) + + def test_subgraph_and_ego(self): + self.G.add_edges([("A", "B"), ("B", "C"), ("C", "D")]) + sub = self.G.nodes_subgraph(["A", "B", "C"]) + self.assertTrue(sub.has_edge("A", "B")) + self.assertFalse(sub.has_edge("C", "D")) + ego = self.G.ego_subgraph("B") + self.assertIn("A", ego.nodes or []) + self.assertIn("C", ego.nodes or []) + + def test_to_index_node_graph(self): + self.G.add_edges([("foo", "bar"), ("bar", "baz")]) + G2, node2idx, idx2node = self.G.to_index_node_graph() + self.assertEqual(len(G2.nodes), 3) + self.assertEqual(node2idx["foo"], 0) + self.assertEqual(idx2node[0], "foo") + + def test_copy(self): + self.G.add_edge("copyA", "copyB", weight=42) + G_copy = self.G.copy() + self.assertEqual(G_copy.adj["copyA"]["copyB"]["weight"], 42) + + def test_file_add_edges(self): + fname = "temp_edges.txt" + with open(fname, "w") as f: + f.write("1 2 3.5\n2 3 4.5\n") + self.G.add_edges_from_file(fname, weighted=True) + os.remove(fname) + self.assertEqual(self.G.adj["1"]["2"]["weight"], 3.5) + self.assertEqual(self.G.adj["2"]["3"]["weight"], 4.5) diff --git a/easygraph/classes/tests/test_graph.py b/easygraph/classes/tests/test_graph.py new file mode 100644 index 0000000..965d1c3 --- /dev/null +++ b/easygraph/classes/tests/test_graph.py @@ -0,0 +1,112 @@ +import os +import sys +import time + +# sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__),'..', '..'))) +import easygraph as eg # Spend 4.9s on importing this damn big lib. + + +""" +def test_iter(): + g = eg.Graph() + # tests of corner cases + + g.add_edge(0, 0) + g.add_edge(True, False) + g.add_edge(False, 1) + g.add_edge(0b1000, 0x00a, edge_attr={"age": 19, "gender": "Male"}) + # g.add_edge(None, None) # this shall result in an AssertionError + # g.add_edge(None, 1) # this shall result in an AssertionError + # g.add_edge(1, None) # this shall result in an AssertionError + + # g.add_edges(None) # Triggers a TypeError saying that len() is not applicable to None + g.add_edges([(True, False), ("Beijing National", "Day School")], [{}, {"Rating": 100}]) + g.add_node("FuDan Univ", node_attr={"faculty": 10000}) # 1. + g.add_edge("Beijing National", "FuDan Univ") + # g.add_node([]) # this shall result in an unhashable error + g.add_node('Jack', node_attr={ + 'age': 10, + 'gender': 'M' + }) + # g.remove_node("Beijing National") + g.remove_edges([('Day School', 'Beijing National')]) + # g.add_edges_from() + + print(g.add_extra_selfloop()) + + + g.nbr_v() + g.nbunch_iter() + g.from_hypergraph_hypergcn() + # print(g._adj[8].get(10)) + print(g.edges) + print(g.nodes) + + +test_iter() +""" + +from easygraph.datasets import get_graph_karateclub + + +G = get_graph_karateclub() +# Calculate five shs(Structural Hole Spanners) in G +shs = eg.common_greedy(G, 5) +# Draw the Graph, and the shs is marked by a red star +eg.draw_SHS_center(G, shs) +# Draw CDF curves of "Number of Followers" of SH spanners and ordinary users in G. +eg.plot_Followers(G, shs) + +import easygraph as eg + + +G = eg.Graph() +G.add_edge(1, 2) # Add a single edge +print(G.edges) + +G.add_edges([(2, 3), (1, 3), (3, 4), (4, 5), ((1, 2), (3, 4))]) # Add edges +print(G.edges) + + +G.add_node("hello world") +G.add_node("Jack", node_attr={"age": 10, "gender": "M"}) +print(G.nodes) + +# G.remove_nodes(['hello world','Tom','Lily','a','b'])#remove edges +G.remove_nodes(["hello world"]) +print(G.nodes) + +G.remove_edge(4, 5) +print(G.edges) + +print(len(G)) # __len__(self) +for x in G: # __iter__(self) + print(x) +print(G[1]) # return list(self._adj[node].keys()) __contains__ __getitem__ + +for neighbor in G.neighbors(node=2): + print(neighbor) + +G.add_edges( + [(1, 2), (2, 3), (1, 3), (3, 4), (4, 5)], + edges_attr=[ + {"weight": 20}, + {"weight": 10}, + {"weight": 15}, + {"weight": 8}, + {"weight": 12}, + ], +) # add weighted edges +G.add_node(6) +print(G.edges) + +print(G.degree()) +print(G.degree(weight="weight")) + +G_index_graph, index_of_node, node_of_index = G.to_index_node_graph() +print(G_index_graph.adj) + +G1 = G.copy() +print(G1.adj) + +print(eg.effective_size(G)) diff --git a/easygraph/classes/tests/test_graphV2.py b/easygraph/classes/tests/test_graphV2.py new file mode 100644 index 0000000..f3a4a23 --- /dev/null +++ b/easygraph/classes/tests/test_graphV2.py @@ -0,0 +1,122 @@ +import unittest + +import easygraph as eg + + +class TestEasyGraph(unittest.TestCase): + def setUp(self): + self.G = eg.Graph() + + def test_add_single_node(self): + self.G.add_node(1) + self.assertIn(1, self.G.nodes) + + def test_add_multiple_nodes(self): + self.G.add_nodes([2, 3, 4]) + for node in [2, 3, 4]: + self.assertIn(node, self.G.nodes) + + def test_add_node_with_attributes(self): + self.G.add_node("node", color="red") + self.assertEqual(self.G.nodes["node"]["color"], "red") + + def test_add_single_edge(self): + self.G.add_edge(1, 2) + self.assertTrue(self.G.has_edge(1, 2)) + self.assertTrue(self.G.has_edge(2, 1)) + + def test_add_edge_with_weight(self): + self.G.add_edge("a", "b", weight=10) + self.assertEqual(self.G["a"]["b"]["weight"], 10) + + def test_add_edges(self): + self.G.add_edges([(1, 2), (2, 3)], edges_attr=[{"weight": 5}, {"weight": 6}]) + self.assertEqual(self.G[1][2]["weight"], 5) + self.assertEqual(self.G[2][3]["weight"], 6) + + def test_remove_node(self): + self.G.add_node(10) + self.G.remove_node(10) + self.assertNotIn(10, self.G.nodes) + + def test_remove_edge(self): + self.G.add_edge(1, 2) + self.G.remove_edge(1, 2) + self.assertFalse(self.G.has_edge(1, 2)) + + def test_neighbors(self): + self.G.add_edges([(1, 2), (1, 3)]) + neighbors = list(self.G.neighbors(1)) + self.assertIn(2, neighbors) + self.assertIn(3, neighbors) + + def test_subgraph(self): + self.G.add_edges([(1, 2), (2, 3), (3, 4)]) + subG = self.G.nodes_subgraph([2, 3]) + self.assertIn(2, subG.nodes) + self.assertIn(3, subG.nodes) + self.assertTrue(subG.has_edge(2, 3)) + self.assertFalse(subG.has_edge(3, 4)) + + def test_ego_subgraph(self): + self.G.add_edges([(1, 2), (2, 3), (2, 4)]) + ego = self.G.ego_subgraph(2) + self.assertIn(2, ego.nodes) + self.assertIn(1, ego.nodes) + self.assertIn(3, ego.nodes) + self.assertIn(4, ego.nodes) + + def test_to_index_node_graph(self): + self.G.add_edges([("a", "b"), ("b", "c")]) + G_index, index_of_node, node_of_index = self.G.to_index_node_graph() + self.assertEqual(len(G_index.nodes), 3) + self.assertTrue(all(isinstance(k, int) for k in G_index.nodes)) + + def test_directed_conversion(self): + self.G.add_edge(1, 2) + H = self.G.to_directed() + self.assertTrue(H.is_directed()) + self.assertTrue(H.has_edge(1, 2)) + self.assertTrue(H.has_edge(2, 1)) + + def test_clone_graph(self): + self.G.add_edges([(1, 2), (2, 3)]) + G_clone = self.G.copy() + self.assertTrue(G_clone.has_edge(1, 2)) + self.assertTrue(G_clone.has_edge(2, 3)) + + def test_degree(self): + self.G.add_edge(1, 2, weight=5) + deg = self.G.degree() + self.assertEqual(deg[1], 5) + self.assertEqual(deg[2], 5) + + def test_size(self): + self.G.add_edges([(1, 2), (2, 3)]) + self.assertEqual(self.G.size(), 2) + + def test_edge_weight_default(self): + self.G.add_edge(4, 5) + self.assertEqual(self.G[4][5].get("weight", 1), 1) + + def test_node_index_mappings(self): + self.G.add_nodes([10, 20, 30]) + index2node = self.G.index2node + node_index = self.G.node_index + for i, node in index2node.items(): + self.assertEqual(node_index[node], i) + + def test_graph_order(self): + self.G.add_nodes([1, 2, 3]) + self.assertEqual(self.G.order(), 3) + + def test_graph_size_with_weight(self): + self.G.add_edges([(1, 2), (2, 3)], edges_attr=[{"weight": 4}, {"weight": 6}]) + self.assertEqual(self.G.size(weight="weight"), 10.0) + + def test_clear_cache(self): + self.G.add_edge(1, 2) + _ = self.G.edges + self.assertIn("edge", self.G.cache) + self.G._clear_cache() + self.assertEqual(len(self.G.cache), 0) diff --git a/easygraph/classes/tests/test_hypergraph.py b/easygraph/classes/tests/test_hypergraph.py new file mode 100644 index 0000000..19f9993 --- /dev/null +++ b/easygraph/classes/tests/test_hypergraph.py @@ -0,0 +1,1001 @@ +import sys + +from copy import deepcopy + +import easygraph as eg +import pytest + + +@pytest.fixture() +def g1(): + e_list = [(0, 1, 2, 5), (0, 1), (2, 3, 4), (3, 2, 4)] + g = eg.Hypergraph(6, e_list=e_list) + return g + + +@pytest.fixture() +def g2(): + e_list = [(1, 2, 3), (0, 1, 3), (0, 1), (2, 4, 3), (2, 3)] + e_weight = [0.5, 1, 0.5, 1, 0.5] + g = eg.Hypergraph(5, e_list=e_list, e_weight=e_weight) + return g + + +@pytest.fixture() +def g3(): + e_list = [[0, 1], [0, 1, 2], [2, 3, 4]] + e_weight = [1, 1, 1] + g = eg.Hypergraph(5, e_list=e_list, e_weight=e_weight) + return g + + +def test_expansion(g3): + star_expansion_graph = g3.get_star_expansion() + node_clique_expansion_graph = g3.get_clique_expansion() + edge_clique_expansion_graph = g3.get_clique_expansion() + print(star_expansion_graph.edges) + print(node_clique_expansion_graph.edges) + print(edge_clique_expansion_graph.edges) + + +def test_property(g1, g2): + assert g2.distance(1, 2) == 1 + assert g2.diameter() == 2 + assert g1.adjacency_matrix != None + assert g1.edge_adjacency_matrix != None + assert g2.adjacency_matrix != None + assert g2.edge_adjacency_matrix != None + + +def test_save(g1, tmp_path): + from easygraph import load_structure + + print("g1:", g1, g1.e) + g1.save(tmp_path / "g1") + g2 = load_structure(tmp_path / "g1") + + for e1, e2 in zip(g1.e[0], g2.e[0]): + assert e1 == e2 + for w1, w2 in zip(g1.e[1], g2.e[1]): + assert w1 == w2 + + +# test construction +def test_from_feature_kNN(): + import numpy as np + import scipy.spatial + import torch + + ft = np.random.rand(32, 8) + cdist = scipy.spatial.distance.cdist(ft, ft) + tk_mat = np.argsort(cdist, axis=1)[:, :3] + hg = eg.Hypergraph.from_feature_kNN(torch.tensor(ft), k=3) + assert tuple(sorted(tk_mat[0].tolist())) in hg.e[0] + assert tuple(sorted(tk_mat[8].tolist())) in hg.e[0] + assert tuple(sorted(tk_mat[13].tolist())) in hg.e[0] + assert tuple(sorted(tk_mat[26].tolist())) in hg.e[0] + + +def test_from_graph(): + g = eg.Graph() + g.add_nodes(list(range(0, 5))) + g.add_edges( + [(0, 1), (0, 3), (1, 4), (2, 3), (3, 4)], + [ + {"weight": 1.0}, + {"weight": 1.0}, + {"weight": 1.0}, + {"weight": 1.0}, + {"weight": 1.0}, + ], + ) + hg = eg.Hypergraph.from_graph(g) + assert hg.num_e == 5 + assert (0, 1) in hg.e[0] + assert (1, 4) in hg.e[0] + + +def test_from_graph_kHop(): + g = eg.Graph() + g.add_nodes(range(0, 5)) + g.add_edges( + [(0, 1), (0, 3), (1, 4), (2, 3)], + [{"weight": 1.0}, {"weight": 1.0}, {"weight": 1.0}, {"weight": 1.0}], + ) + hg = eg.Hypergraph.from_graph_kHop(g, k=1) + assert hg.num_e == 5 + assert (0, 1, 3) in hg.e[0] + assert (0, 1, 4) in hg.e[0] + assert (1, 4) in hg.e[0] + assert (2, 3) in hg.e[0] + assert (0, 2, 3) in hg.e[0] + hg = eg.Hypergraph.from_graph_kHop(g, k=2) + assert hg.num_e == 5 + assert (0, 1, 3, 4) in hg.e[0] + hg = eg.Hypergraph.from_graph_kHop(g, k=2, only_kHop=True) + assert hg.num_e == 4 + + +# test representation +def test_empty(): + g = eg.Hypergraph(10) + assert g.num_v == 10 + assert g.e == ([], [], []) + + +def test_init(g1, g2): + assert g1.num_v == 6 + assert g1.num_e == 3 + assert g1.e[0] == [(0, 1, 2, 5), (0, 1), (2, 3, 4)] + assert g1.e[1] == [1, 1, 1] + assert g2.num_v == 5 + assert g2.num_e == 5 + assert g2.e[0] == [(1, 2, 3), (0, 1, 3), (0, 1), (2, 3, 4), (2, 3)] + assert g2.e[1] == [0.5, 1, 0.5, 1, 0.5] + + +def test_clear(g1): + assert g1.num_e == 3 + g1.clear() + assert g1.num_e == 0 + assert g1.e == ([], [], []) + + +def test_add_and_merge_hyperedges(g1): + assert g1.e[1] == [1, 1, 1] + print("g1:", g1, g1.e) + g1.add_hyperedges(e_list=[0, 1], e_weight=3, merge_op="mean") + assert g1.e[1] == [1, 2, 1] + assert g1.e[0] == [(0, 1, 2, 5), (0, 1), (2, 3, 4)] + g1.add_hyperedges([(2, 4, 3), (1, 0), (3, 4)], [1, 1, 1], merge_op="sum") + assert g1.e[0] == [(0, 1, 2, 5), (0, 1), (2, 3, 4), (3, 4)] + assert g1.e[1] == [1, 3, 2, 1] + + +def test_add_hyperedges_from_feature_kNN(g1): + import numpy as np + import scipy.spatial + import torch + + origin_e = deepcopy(g1.e[0]) + ft = np.random.rand(6, 8) + cdist = scipy.spatial.distance.cdist(ft, ft) + tk_mat = np.argsort(cdist, axis=1)[:, :3] + + g1.add_hyperedges_from_feature_kNN(torch.tensor(ft), k=3, group_name="knn") + assert tuple(sorted(tk_mat[0].tolist())) in g1.e_of_group("knn")[0] + assert tuple(sorted(tk_mat[3].tolist())) in g1.e_of_group("knn")[0] + assert tuple(sorted(tk_mat[4].tolist())) in g1.e_of_group("knn")[0] + assert tuple(sorted(tk_mat[5].tolist())) in g1.e_of_group("knn")[0] + + for e in origin_e: + assert e in g1.e_of_group("main")[0] + + for e in g1.e_of_group("main")[0]: + assert e in origin_e + + +# def test_add_hyperedges_from_graph(g1): +# g = eg.graph_Gnm(6, 3) +# origin_e = deepcopy(g1.e[0]) +# +# g1.add_hyperedges_from_graph(g, group_name="graph") +# g_e = g.e[0] +# g1_e = g1.e_of_group("graph")[0] +# +# for e in g_e: +# assert e in g1_e +# +# for e in origin_e: +# assert e in g1.e_of_group("main")[0] +# +# for e in g1.e[0]: +# assert e in origin_e or e in g_e + + +def test_add_hyperedges_from_graph_kHop(g1): + g = eg.graph_Gnm(6, 5) + + origin_e = deepcopy(g1.e[0]) + for k in range(1, 3): + gg1 = deepcopy(g1) + gg1.add_hyperedges_from_graph_kHop(g, k=k, group_name="kHop") + + khop = [[] for _ in range(6)] + for kk in range(k): + for v in range(6): + if kk == 0: + khop[v] = g.nbr_v(v) + else: + kk_hop_v = [] + for nbr in khop[v]: + kk_hop_v += g.nbr_v(nbr) + khop[v] += kk_hop_v + khop[v] = list(set(khop[v])) + + for v in range(6): + edge = [v] + khop[v] + edge = tuple(set(sorted(edge))) + assert edge in gg1.e_of_group("kHop")[0] + + gg2 = deepcopy(g1) + gg2.add_hyperedges_from_graph_kHop(g, k=k, group_name="kHop", only_kHop=True) + + khop = [[] for _ in range(6)] + for kk in range(k): + for v in range(6): + if len(khop[v]) == 0: + khop[v] = g.nbr_v(v) + else: + kk_hop_v = [] + for nbr in khop[v]: + kk_hop_v += g.nbr_v(nbr) + khop[v] = kk_hop_v + khop[v] = list(set(khop[v])) + + for v in range(6): + edge = [v] + khop[v] + edge = tuple(set(sorted(edge))) + assert edge in gg2.e_of_group("kHop")[0] + + for e in origin_e: + assert e in gg1.e_of_group("main")[0] + assert e in gg2.e_of_group("main")[0] + + for e in gg1.e_of_group("main")[0]: + assert e in origin_e + for e in gg2.e_of_group("main")[0]: + assert e in origin_e + + +def test_remove_hyperedges(g1): + assert g1.e[0] == [(0, 1, 2, 5), (0, 1), (2, 3, 4)] + assert g1.e[1] == [1, 1, 1] + g1.remove_hyperedges([0, 1]) + assert (0, 1) not in g1.e[0] + assert (0, 1, 5) not in g1.e[0] + g1.add_hyperedges([[0, 1, 5], [2, 3, 4]]) + assert (0, 1, 5) in g1.e[0] + g1.remove_hyperedges([[0, 1, 5], (0, 1, 2, 5)]) + assert (0, 1, 5) not in g1.e[0] + assert (0, 1, 2, 5) not in g1.e[0] + g1.clear() + assert g1.num_e == 0 + assert g1.e == ([], [], []) + + +# def test_remove_group(g1): +# origin_e = deepcopy(g1.e[0]) +# +# g1.add_hyperedges(([0, 1, 2, 5], [0, 1]), group_name="test") +# for e in origin_e: +# assert e in g1.e_of_group("main")[0] +# for e in g1.e_of_group("main")[0]: +# assert e in origin_e +# +# # g1.remove_group("none") +# +# g1.remove_group("test") +# assert "test" not in g1.group_names +# +# for e in origin_e: +# assert e in g1.e_of_group("main")[0] +# for e in g1.e_of_group("main")[0]: +# assert e in origin_e +# +# g1.remove_group("main") +# +# assert len(g1.e[0]) == 0 +# assert len(g1.e[1]) == 0 +# +# +# def test_add_and_remove_group(g1): +# assert g1.group_names == ["main"] +# g1.add_hyperedges([0, 2, 3], group_name="knn") +# assert len(g1.group_names) == 2 +# assert "main" in g1.group_names +# assert "knn" in g1.group_names +# assert (0, 2, 3) in g1.e[0] +# assert (0, 2, 3) in g1.e_of_group("knn")[0] +# assert (0, 2, 3) not in g1.e_of_group("main")[0] +# g1.remove_hyperedges([0, 2, 3], group_name="knn") +# assert (0, 2, 3) not in g1.e[0] +# assert (0, 2, 3) not in g1.e_of_group("knn")[0] + + +def test_deg(g1, g2): + assert g1.deg_v == [2, 2, 2, 1, 1, 1] + assert g1.deg_e == [4, 2, 3] + assert g2.deg_v == [2, 3, 3, 4, 1] + assert g2.deg_e == [3, 3, 2, 3, 2] + + +# def test_deg_group(g1): +# assert g1.deg_v == [2, 2, 2, 1, 1, 1] +# assert g1.deg_e == [4, 2, 3] +# g1.add_hyperedges([0, 2], 1, group_name="knn") +# assert g1.deg_v == [3, 2, 3, 1, 1, 1] +# assert g1.deg_e == [4, 2, 3, 2] +# assert g1.deg_v_of_group("main") == [2, 2, 2, 1, 1, 1] +# assert g1.deg_e_of_group("main") == [4, 2, 3] +# assert g1.deg_v_of_group("knn") == [1, 0, 1, 0, 0, 0] +# assert g1.deg_e_of_group("knn") == [2] + + +def test_nbr(g1, g2): + assert g1.nbr_v(0) == [0, 1, 2, 5] + assert g1.nbr_e(1) == [0, 1] + assert g2.nbr_v(2) == [0, 1] + assert g2.nbr_e(4) == [3] + + +# def test_nbr_group(g1): +# print("g1:", g1.e, g1.v) +# assert g1.nbr_v(1) == [0, 1] +# assert g1.nbr_e(0) == [0, 1] +# g1.add_hyperedges([[0, 1]], group_name="knn") +# assert g1.nbr_v(1) == [0, 1] +# assert g1.nbr_e(1) == [0, 1, 3] +# assert g1.nbr_v_of_group(1, "main") == [0, 1] +# assert g1.nbr_e_of_group(2, "main") == [0, 2] +# assert g1.nbr_v_of_group(0, "knn") == [0, 1] +# assert g1.nbr_e_of_group(1, "knn") == [0] + + +def test_clone(g1): + assert g1.num_v == 6 + assert g1.num_e == 3 + g1_clone = g1.clone() + g1_clone.add_hyperedges([0, 2], 1, group_name="knn") + assert g1.num_e == 3 + assert g1_clone.num_e == 4 + + +# test deep learning +def test_v2e_index(g1): + import torch + + v2e_src = g1.v2e_src.view(-1, 1) + v2e_dst = g1.v2e_dst.view(-1, 1) + + index = torch.cat((v2e_src, v2e_dst), dim=1) + index = index.numpy().tolist() + index = list(map(lambda x: tuple(x), index)) + + assert (0, 0) in index + assert (1, 0) in index + assert (2, 0) in index + assert (5, 0) in index + assert (0, 1) in index + assert (1, 1) in index + assert (2, 2) in index + assert (3, 2) in index + assert (4, 2) in index + + +def test_v2e_index_group(g1): + import torch + + v2e_src = g1.v2e_src_of_group("main").view(-1, 1) + v2e_dst = g1.v2e_dst_of_group("main").view(-1, 1) + + index = torch.cat((v2e_src, v2e_dst), dim=1) + index = index.numpy().tolist() + index = list(map(lambda x: tuple(x), index)) + + assert (0, 0) in index + assert (1, 0) in index + assert (2, 0) in index + assert (5, 0) in index + assert (0, 1) in index + assert (1, 1) in index + assert (2, 2) in index + assert (3, 2) in index + assert (4, 2) in index + + +def test_e2v_index(g1): + import torch + + e2v_src = g1.e2v_src.view(-1, 1) + e2v_dst = g1.e2v_dst.view(-1, 1) + + index = torch.cat((e2v_src, e2v_dst), dim=1) + index = index.numpy().tolist() + index = list(map(lambda x: tuple(x), index)) + + assert (0, 0) in index + assert (0, 1) in index + assert (0, 2) in index + assert (0, 5) in index + assert (1, 0) in index + assert (1, 1) in index + assert (2, 2) in index + assert (2, 3) in index + assert (2, 4) in index + + +def test_e2v_index_group(g1): + import torch + + e2v_src = g1.e2v_src_of_group("main").view(-1, 1) + e2v_dst = g1.e2v_dst_of_group("main").view(-1, 1) + + index = torch.cat((e2v_src, e2v_dst), dim=1) + index = index.numpy().tolist() + index = list(map(lambda x: tuple(x), index)) + + assert (0, 0) in index + assert (0, 1) in index + assert (0, 2) in index + assert (0, 5) in index + assert (1, 0) in index + assert (1, 1) in index + assert (2, 2) in index + assert (2, 3) in index + assert (2, 4) in index + + +def test_H(g1): + import torch + + print("g1", g1.H.to_dense()) + assert ( + g1.H.to_dense().cpu() + == torch.tensor( + [[1, 1, 0], [1, 1, 0], [1, 0, 1], [0, 0, 1], [0, 0, 1], [1, 0, 0]] + ) + ).all() + + +# def test_H_group(g1): +# import torch +# +# g1.add_hyperedges([0, 4, 5], group_name="knn") +# assert ( +# g1.H.to_dense().cpu() +# == torch.tensor( +# [ +# [1, 1, 0, 1], +# [1, 1, 0, 0], +# [1, 0, 1, 0], +# [0, 0, 1, 0], +# [0, 0, 1, 1], +# [1, 0, 0, 1], +# ] +# ) +# ).all() +# assert ( +# g1.H_of_group("main").to_dense().cpu() +# == torch.tensor( +# [[1, 1, 0], [1, 1, 0], [1, 0, 1], [0, 0, 1], [0, 0, 1], [1, 0, 0]] +# ) +# ).all() +# assert ( +# g1.H_of_group("knn").to_dense().cpu() +# == torch.tensor([[1], [0], [0], [0], [1], [1]]) +# ).all() + + +def test_H_T(g1): + import torch + + assert ( + g1.H_T.to_dense().cpu() + == torch.tensor( + [[1, 1, 0], [1, 1, 0], [1, 0, 1], [0, 0, 1], [0, 0, 1], [1, 0, 0]] + ).t() + ).all() + + +# def test_H_T_group(g1): +# import torch +# +# g1.add_hyperedges([0, 4, 5], group_name="knn") +# assert ( +# g1.H_T.to_dense().cpu() +# == torch.tensor( +# [ +# [1, 1, 0, 1], +# [1, 1, 0, 0], +# [1, 0, 1, 0], +# [0, 0, 1, 0], +# [0, 0, 1, 1], +# [1, 0, 0, 1], +# ] +# ).t() +# ).all() +# assert ( +# g1.H_T_of_group("main").to_dense().cpu() +# == torch.tensor( +# [[1, 1, 0], [1, 1, 0], [1, 0, 1], [0, 0, 1], [0, 0, 1], [1, 0, 0]] +# ).t() +# ).all() +# assert ( +# g1.H_T_of_group("knn").to_dense().cpu() == torch.tensor([[1, 0, 0, 0, 1, 1]]) +# ).all() + + +def test_W_e(g2): + import torch + + assert ( + g2.W_e.to_sparse_coo().cpu()._values() == torch.tensor([0.5, 1, 0.5, 1, 0.5]) + ).all() + + +# def test_W_e_group(g2): +# import torch +# +# g2.add_hyperedges([0, 4, 5], group_name="knn") +# assert (g2.W_e.cpu()._values() == torch.tensor([0.5, 1, 0.5, 1, 0.5, 1])).all() +# assert ( +# g2.W_e_of_group("main").cpu()._values() == torch.tensor([0.5, 1, 0.5, 1, 0.5]) +# ).all() +# assert (g2.W_e_of_group("knn").cpu()._values() == torch.tensor([1])).all() + + +def test_D(g1, g2): + import torch + + assert (g1.D_v.cpu()._values() == torch.tensor([2, 2, 2, 1, 1, 1])).all() + assert (g1.D_e.to_sparse_coo().cpu()._values() == torch.tensor([4, 2, 3])).all() + assert (g2.D_v.cpu()._values() == torch.tensor([2, 3, 3, 4, 1])).all() + assert ( + g2.D_e.to_sparse_coo().cpu()._values() == torch.tensor([3, 3, 2, 3, 2]) + ).all() + + +# def test_D_group(g1): +# import torch +# +# assert (g1.D_v.cpu()._values() == torch.tensor([2, 2, 2, 1, 1, 1])).all() +# assert (g1.D_e.cpu()._values() == torch.tensor([4, 2, 3])).all() +# g1.add_hyperedges([[0, 2], [1, 2, 3]], group_name="knn") +# assert (g1.D_v.cpu()._values() == torch.tensor([3, 3, 4, 2, 1, 1])).all() +# assert (g1.D_e.cpu()._values() == torch.tensor([4, 2, 3, 2, 3])).all() +# assert ( +# g1.D_v_of_group("main").cpu()._values() == torch.tensor([2, 2, 2, 1, 1, 1]) +# ).all() +# assert (g1.D_e_of_group("main").cpu()._values() == torch.tensor([4, 2, 3])).all() +# assert ( +# g1.D_v_of_group("knn").cpu()._values() == torch.tensor([1, 1, 2, 1, 0, 0]) +# ).all() +# assert (g1.D_e_of_group("knn").cpu()._values() == torch.tensor([2, 3])).all() + + +def test_D_neg(g1, g2): + import torch + + # -1 + assert ( + g1.D_v_neg_1.to_sparse_coo().cpu()._values() + == torch.tensor([2, 2, 2, 1, 1, 1]) ** (-1.0) + ).all() + assert ( + g1.D_e_neg_1.to_sparse_coo().cpu()._values() + == torch.tensor([4, 2, 3]) ** (-1.0) + ).all() + assert ( + g2.D_v_neg_1.to_sparse_coo().cpu()._values() + == torch.tensor([2, 3, 3, 4, 1]) ** (-1.0) + ).all() + assert ( + g2.D_e_neg_1.to_sparse_coo().cpu()._values() + == torch.tensor([3, 3, 2, 3, 2]) ** (-1.0) + ).all() + # -1/2 + assert ( + g1.D_v_neg_1_2.to_sparse_coo().cpu()._values() + == torch.tensor([2, 2, 2, 1, 1, 1]) ** (-0.5) + ).all() + assert ( + g2.D_v_neg_1_2.to_sparse_coo().cpu()._values() + == torch.tensor([2, 3, 3, 4, 1]) ** (-0.5) + ).all() + # isolated vertex + g3 = eg.Hypergraph(num_v=3, e_list=[0, 1]) + assert ( + g3.D_v_neg_1.to_sparse_coo().cpu()._values() == torch.tensor([1, 1, 0]) + ).all() + + +# def test_D_neg_group(g1): +# import torch +# +# # -1 +# assert ( +# g1.D_v_neg_1.cpu()._values() == torch.tensor([2, 2, 2, 1, 1, 1]) ** (-1.0) +# ).all() +# assert (g1.D_e_neg_1.cpu()._values() == torch.tensor([4, 2, 3]) ** (-1.0)).all() +# g1.add_hyperedges([[0, 2], [1, 2, 3]], group_name="knn") +# assert ( +# g1.D_v_neg_1.cpu()._values() == torch.tensor([3, 3, 4, 2, 1, 1]) ** (-1.0) +# ).all() +# assert ( +# g1.D_e_neg_1.cpu()._values() == torch.tensor([4, 2, 3, 2, 3]) ** (-1.0) +# ).all() +# assert ( +# g1.D_v_neg_1_of_group("main").cpu()._values() +# == torch.tensor([2, 2, 2, 1, 1, 1]) ** (-1.0) +# ).all() +# assert ( +# g1.D_e_neg_1_of_group("main").cpu()._values() +# == torch.tensor([4, 2, 3]) ** (-1.0) +# ).all() +# assert ( +# g1.D_v_neg_1_of_group("knn").cpu()._values() +# == torch.tensor([1 / 1, 1 / 1, 1 / 2, 1 / 1, 0, 0]) +# ).all() +# assert ( +# g1.D_e_neg_1_of_group("knn").cpu()._values() == torch.tensor([2, 3]) ** (-1.0) +# ).all() +# # -1/2 +# assert ( +# g1.D_v_neg_1_2.cpu()._values() == torch.tensor([3, 3, 4, 2, 1, 1]) ** (-0.5) +# ).all() +# assert ( +# g1.D_v_neg_1_2_of_group("main").cpu()._values() +# == torch.tensor([2, 2, 2, 1, 1, 1]) ** (-0.5) +# ).all() +# assert ( +# g1.D_v_neg_1_2_of_group("knn").cpu()._values() +# == torch.tensor([1 ** (-0.5), 1 ** (-0.5), 2 ** (-0.5), 1 ** (-0.5), 0, 0]) +# ).all() + + +def test_N(g1, g2): + import torch + + assert (g1.N_v(0).cpu() == torch.tensor([0, 1, 2, 5])).all() + assert (g1.N_e(2).cpu() == torch.tensor([0, 2])).all() + assert (g2.N_v(1).cpu() == torch.tensor([0, 1, 3])).all() + assert (g2.N_e(3).cpu() == torch.tensor([0, 1, 3, 4])).all() + + +# def test_N_group(g1): +# import torch +# +# assert (g1.N_v(1).cpu() == torch.tensor([0, 1])).all() +# assert (g1.N_e(1).cpu() == torch.tensor([0, 1])).all() +# g1.add_hyperedges([[0, 1], [1, 2]], group_name="knn") +# assert (g1.N_v(1).cpu() == torch.tensor([0, 1])).all() +# assert (g1.N_e(1).cpu() == torch.tensor([0, 1, 3, 4])).all() +# assert (g1.N_v_of_group(1, "main").cpu() == torch.tensor([0, 1])).all() +# assert (g1.N_e_of_group(2, "main").cpu() == torch.tensor([0, 2])).all() +# assert (g1.N_v_of_group(1, "knn").cpu() == torch.tensor([1, 2])).all() +# assert (g1.N_e_of_group(1, "knn").cpu() == torch.tensor([0, 1])).all() +# + + +@pytest.mark.skipif( + sys.version_info.major <= 3 and sys.version_info.minor < 7, + reason="python requires >= 3.7", +) +def test_L_HGNN(g1): + import torch + + print("g1:", g1, g1.e) + H = g1.H.to_dense().cpu() + D_v_neg_1_2 = torch.diag(H.sum(dim=1).view(-1) ** (-0.5)) + D_e_neg_1 = torch.diag(H.sum(dim=0).view(-1) ** (-1)) + W_e = g1.W_e.to_dense() + L_HGNN = D_v_neg_1_2 @ H @ W_e @ D_e_neg_1 @ H.t() @ D_v_neg_1_2 + assert (L_HGNN == g1.L_HGNN.to_dense().cpu()).all() + + +@pytest.mark.skipif( + sys.version_info.major <= 3 and sys.version_info.minor < 7, + reason="python requires >= 3.7", +) +# def test_L_HGNN_group(g1): +# import torch +# +# g1.add_hyperedges([[0, 1]], group_name="knn") +# # all +# H = g1.H.to_dense().cpu() +# D_v_neg_1_2 = torch.diag(H.sum(dim=1).view(-1) ** (-0.5)) +# D_e_neg_1 = torch.diag(H.sum(dim=0).view(-1) ** (-1)) +# W_e = g1.W_e.to_dense() +# L_HGNN = D_v_neg_1_2 @ H @ W_e @ D_e_neg_1 @ H.t() @ D_v_neg_1_2 +# assert (L_HGNN == g1.L_HGNN.to_dense().cpu()).all() +# # main group +# H = g1.H_of_group("main").to_dense().cpu() +# D_v_neg_1_2 = torch.diag(H.sum(dim=1).view(-1) ** (-0.5)) +# D_e_neg_1 = torch.diag(H.sum(dim=0).view(-1) ** (-1)) +# W_e = g1.W_e_of_group("main").to_dense() +# L_HGNN = D_v_neg_1_2 @ H @ W_e @ D_e_neg_1 @ H.t() @ D_v_neg_1_2 +# assert (L_HGNN == g1.L_HGNN_of_group("main").to_dense().cpu()).all() +# # knn group +# H = g1.H_of_group("knn").to_dense().cpu() +# D_v_neg_1_2 = H.sum(dim=1).view(-1) ** (-0.5) +# D_v_neg_1_2[torch.isinf(D_v_neg_1_2)] = 0 +# D_v_neg_1_2 = torch.diag(D_v_neg_1_2) +# D_e_neg_1 = torch.diag(H.sum(dim=0).view(-1) ** (-1)) +# W_e = g1.W_e_of_group("knn").to_dense() +# L_HGNN = D_v_neg_1_2 @ H @ W_e @ D_e_neg_1 @ H.t() @ D_v_neg_1_2 +# assert (L_HGNN == g1.L_HGNN_of_group("knn").to_dense().cpu()).all() + + +@pytest.mark.skipif( + sys.version_info.major <= 3 and sys.version_info.minor < 7, + reason="python requires >= 3.7", +) +def test_smoothing(): + import torch + + x = torch.rand(10, 5) + L = torch.rand(10, 10) + g = eg.Hypergraph(10) + lbd = 0.1 + assert pytest.approx(g.smoothing(x, L, lbd)) == x + lbd * L @ x + + +@pytest.mark.skipif( + sys.version_info.major <= 3 and sys.version_info.minor < 7, + reason="python requires >= 3.7", +) +def test_L_sym(g1): + import torch + + H = g1.H.to_sparse_coo().to_dense().cpu() + D_v_neg_1_2 = torch.diag(H.sum(dim=1).view(-1) ** (-0.5)) + D_e_neg_1 = torch.diag(H.sum(dim=0).view(-1) ** (-1)) + W_e = g1.W_e.to_dense() + L_sym = ( + torch.eye(H.shape[0]) + - D_v_neg_1_2.to_sparse_coo() + @ H.to_sparse_coo() + @ W_e + @ D_e_neg_1.to_sparse_coo() + @ H.t().to_sparse_coo() + @ D_v_neg_1_2.to_sparse_coo() + ) + assert (L_sym == g1.L_sym.to_dense().cpu()).all() + + +@pytest.mark.skipif( + sys.version_info.major <= 3 and sys.version_info.minor < 7, + reason="python requires >= 3.7", +) +# def test_L_sym_group(g1): +# import torch +# +# g1.add_hyperedges([[0, 1]], group_name="knn") +# # all +# H = g1.H.to_dense().cpu() +# D_v_neg_1_2 = torch.diag(H.sum(dim=1).view(-1) ** (-0.5)) +# D_e_neg_1 = torch.diag(H.sum(dim=0).view(-1) ** (-1)) +# W_e = g1.W_e.to_dense() +# L_sym = ( +# torch.eye(H.shape[0]) - D_v_neg_1_2 @ H @ W_e @ D_e_neg_1 @ H.t() @ D_v_neg_1_2 +# ) +# assert (L_sym == g1.L_sym.to_dense().cpu()).all() +# # main group +# H = g1.H_of_group("main").to_dense().cpu() +# D_v_neg_1_2 = torch.diag(H.sum(dim=1).view(-1) ** (-0.5)) +# D_e_neg_1 = torch.diag(H.sum(dim=0).view(-1) ** (-1)) +# W_e = g1.W_e_of_group("main").to_dense() +# L_sym = ( +# torch.eye(H.shape[0]) - D_v_neg_1_2 @ H @ W_e @ D_e_neg_1 @ H.t() @ D_v_neg_1_2 +# ) +# assert (L_sym == g1.L_sym_of_group("main").to_dense().cpu()).all() +# # knn group +# H = g1.H_of_group("knn").to_dense().cpu() +# D_v_neg_1_2 = H.sum(dim=1).view(-1) ** (-0.5) +# D_v_neg_1_2[torch.isinf(D_v_neg_1_2)] = 0 +# D_v_neg_1_2 = torch.diag(D_v_neg_1_2) +# D_e_neg_1 = torch.diag(H.sum(dim=0).view(-1) ** (-1)) +# W_e = g1.W_e_of_group("knn").to_dense() +# L_sym = ( +# torch.eye(H.shape[0]) - D_v_neg_1_2 @ H @ W_e @ D_e_neg_1 @ H.t() @ D_v_neg_1_2 +# ) +# assert (L_sym == g1.L_sym_of_group("knn").to_dense().cpu()).all() + + +@pytest.mark.skipif( + sys.version_info.major <= 3 and sys.version_info.minor < 7, + reason="python requires >= 3.7", +) +# def test_L_rw(g1): +# import torch +# +# H = g1.H.to_dense().cpu() +# D_v_neg_1 = torch.diag(H.sum(dim=1).view(-1) ** (-1)) +# D_e_neg_1 = torch.diag(H.sum(dim=0).view(-1) ** (-1)) +# W_e = g1.W_e.to_dense() +# L_rw = torch.eye(H.shape[0]) - D_v_neg_1 @ H @ W_e @ D_e_neg_1 @ H.t() +# assert (L_rw == g1.L_rw.to_dense().cpu()).all() + + +@pytest.mark.skipif( + sys.version_info.major <= 3 and sys.version_info.minor < 7, + reason="python requires >= 3.7", +) +# def test_L_rw_group(g1): +# import torch +# +# g1.add_hyperedges([[0, 1]], group_name="knn") +# # all +# H = g1.H.to_dense().cpu() +# D_v_neg_1 = torch.diag(H.sum(dim=1).view(-1) ** (-1)) +# D_e_neg_1 = torch.diag(H.sum(dim=0).view(-1) ** (-1)) +# W_e = g1.W_e.to_dense() +# L_rw = torch.eye(H.shape[0]) - D_v_neg_1 @ H @ W_e @ D_e_neg_1 @ H.t() +# assert (L_rw == g1.L_rw.to_dense().cpu()).all() +# # main group +# H = g1.H_of_group("main").to_dense().cpu() +# D_v_neg_1 = torch.diag(H.sum(dim=1).view(-1) ** (-1)) +# D_e_neg_1 = torch.diag(H.sum(dim=0).view(-1) ** (-1)) +# W_e = g1.W_e_of_group("main").to_dense() +# L_rw = torch.eye(H.shape[0]) - D_v_neg_1 @ H @ W_e @ D_e_neg_1 @ H.t() +# assert (L_rw == g1.L_rw_of_group("main").to_dense().cpu()).all() +# # knn group +# H = g1.H_of_group("knn").to_dense().cpu() +# D_v_neg_1 = H.sum(dim=1).view(-1) ** (-1) +# D_v_neg_1[torch.isinf(D_v_neg_1)] = 0 +# D_v_neg_1 = torch.diag(D_v_neg_1) +# D_e_neg_1 = torch.diag(H.sum(dim=0).view(-1) ** (-1)) +# W_e = g1.W_e_of_group("knn").to_dense() +# L_rw = torch.eye(H.shape[0]) - D_v_neg_1 @ H @ W_e @ D_e_neg_1 @ H.t() +# assert (L_rw == g1.L_rw_of_group("knn").to_dense().cpu()).all() + + +@pytest.mark.skipif( + sys.version_info.major <= 3 and sys.version_info.minor < 7, + reason="python requires >= 3.7", +) +def test_smoothing_with_HGNN(g1): + import torch + + H = torch.tensor( + [[1, 1, 0], [1, 1, 0], [1, 0, 1], [0, 0, 1], [0, 0, 1], [1, 0, 0]], + dtype=torch.float32, + ) + D_v_inv_1_2 = H.sum(1).view(-1) ** (-0.5) + D_v_inv_1_2[torch.isinf(D_v_inv_1_2)] = 0 + D_v_inv_1_2 = torch.diag(D_v_inv_1_2) + + D_e_inv = H.sum(0).view(-1) ** (-1) + D_e_inv[torch.isinf(D_e_inv)] = 0 + D_e_inv = torch.diag(D_e_inv) + + x = torch.rand(H.shape[0], 8) + + gt = D_v_inv_1_2 @ H @ D_e_inv @ H.t() @ D_v_inv_1_2 @ x + + res = g1.smoothing_with_HGNN(x) + + assert pytest.approx(gt, rel=1e-6) == res.cpu() + + +@pytest.mark.skipif( + sys.version_info.major <= 3 and sys.version_info.minor < 7, + reason="python requires >= 3.7", +) +def test_smoothing_with_HGNN_group(g1): + import torch + + H = torch.tensor( + [[1, 1, 0], [1, 1, 0], [1, 0, 1], [0, 0, 1], [0, 0, 1], [1, 0, 0]], + dtype=torch.float32, + ) + D_v_inv_1_2 = H.sum(1).view(-1) ** (-0.5) + D_v_inv_1_2[torch.isinf(D_v_inv_1_2)] = 0 + D_v_inv_1_2 = torch.diag(D_v_inv_1_2) + + D_e_inv = H.sum(0).view(-1) ** (-1) + D_e_inv[torch.isinf(D_e_inv)] = 0 + D_e_inv = torch.diag(D_e_inv) + + x = torch.rand(H.shape[0], 8) + + gt = D_v_inv_1_2 @ H @ D_e_inv @ H.t() @ D_v_inv_1_2 @ x + + res = g1.smoothing_with_HGNN_of_group("main", x) + + assert pytest.approx(gt, rel=1e-6) == res.cpu() + + +def test_v2e_message_passing(g1): + import torch + + H = torch.tensor( + [[1, 1, 0], [1, 1, 0], [1, 0, 1], [0, 0, 1], [0, 0, 1], [1, 0, 0]], + dtype=torch.float32, + ) + + x = torch.rand(H.shape[0], 8) + + gt_sum = H.t() @ x + res_sum = g1.v2e(x, aggr="sum") + assert pytest.approx(gt_sum, rel=1e-6) == res_sum.cpu() + + D_e_inv = H.sum(0).view(-1) ** (-1) + D_e_inv[torch.isinf(D_e_inv)] = 0 + D_e_inv = torch.diag(D_e_inv) + + gt_mean = D_e_inv @ gt_sum + res_mean = g1.v2e(x, aggr="mean") + assert pytest.approx(gt_mean, rel=1e-6) == res_mean.cpu() + + +def test_e2v_message_passing(g1): + import torch + + H = torch.tensor( + [[1, 1, 0], [1, 1, 0], [1, 0, 1], [0, 0, 1], [0, 0, 1], [1, 0, 0]], + dtype=torch.float32, + ) + + x = torch.rand(3, 8) + + gt_sum = H @ x + res_sum = g1.e2v(x, aggr="sum") + assert pytest.approx(gt_sum, rel=1e-6) == res_sum.cpu() + + D_v_inv = H.sum(1).view(-1) ** (-1) + D_v_inv[torch.isinf(D_v_inv)] = 0 + D_v_inv = torch.diag(D_v_inv) + + gt_mean = D_v_inv @ gt_sum + res_mean = g1.e2v(x, aggr="mean") + assert pytest.approx(gt_mean, rel=1e-6) == res_mean.cpu() + + +def test_v2v_message_passing(g1): + import torch + + H = torch.tensor( + [[1, 1, 0], [1, 1, 0], [1, 0, 1], [0, 0, 1], [0, 0, 1], [1, 0, 0]], + dtype=torch.float32, + ) + + x = torch.rand(6, 8) + + gt_sum = H @ H.t() @ x + res_sum = g1.v2v(x, aggr="sum") + assert pytest.approx(gt_sum, rel=1e-6) == res_sum.cpu() + + D_v_inv = H.sum(1).view(-1) ** (-1) + D_v_inv[torch.isinf(D_v_inv)] = 0 + D_v_inv = torch.diag(D_v_inv) + + D_e_inv = H.sum(0).view(-1) ** (-1) + D_e_inv[torch.isinf(D_e_inv)] = 0 + D_e_inv = torch.diag(D_e_inv) + + gt_mean = D_v_inv @ H @ D_e_inv @ H.t() @ x + res_mean = g1.v2v(x, aggr="mean") + assert pytest.approx(gt_mean, rel=1e-6) == res_mean.cpu() + + +@pytest.mark.skipif( + sys.version_info.major <= 3 and sys.version_info.minor < 7, + reason="python requires >= 3.7", +) +# def test_graph_and_hypergraph(): +# import torch +# +# g = eg.Graph() +# g.add_nodes([0, 1, 2, 3]) +# g.add_edges( +# [(0, 1), (0, 2), (1, 3)], [{"weight": 1.0}, {"weight": 1.0}, {"weight": 1.0}] +# ) +# hg = eg.Hypergraph.from_graph(g) +# _mm = torch.sparse.mm +# est_A = _mm(_mm(g.D_v_neg_1_2, g.A), g.D_v_neg_1_2) + torch.eye(4).to_sparse() +# assert pytest.approx(est_A.to_dense() / 2) == hg.L_HGNN.to_dense() + + +@pytest.mark.skip(reason="skip") +def test_get_linegraph(): + num_v = 5 + e_list = [[0, 1], [1, 2, 3], [0, 3, 4]] + e_weight = [1.0, 0.5, 2.0] + v_weight = [0.2, 0.3, 0.4, 0.5, 0.6] + + hg = eg.Hypergraph(num_v=num_v, e_list=e_list, e_weight=e_weight) + lg = hg.get_clique_expansion() + assert lg.edges == [[0, 1], [0, 2], [1, 2]] + assert lg.nodes == [0, 1, 2] diff --git a/easygraph/classes/tests/test_multidigraph.py b/easygraph/classes/tests/test_multidigraph.py new file mode 100644 index 0000000..7609d52 --- /dev/null +++ b/easygraph/classes/tests/test_multidigraph.py @@ -0,0 +1,114 @@ +import unittest + +import easygraph as eg +import pytest + + +class Test(unittest.TestCase): + def setUp(self): + edges = [(1, 2), (2, 3), ("String", "Bool"), (2, 1), ((1, 2), (3, 4))] + self.g = eg.MultiDiGraph(edges) + + def test_add_edge(self): + self.g.add_edge("from_Beijing", "to_California", key=3, attr=None) + print(self.g.edges) + + def test_remove_edge(self): + self.g.add_edge("from_Beijing", "to_California", key=3, attr=None) + self.g.remove_edge("from_Beijing", "to_California") + print(self.g.edges) + + def test_degree(self): + print(self.g.degree) + print(self.g.in_degree) + print(self.g.out_degree) + + def test_reverse(self): + # error with _succ + print(self.g.reverse(copy=True).edges) + # print(self.g.reverse(copy=False).edges) + + def test_attributes(self): + print(self.g.edges) + print(self.g.in_edges) + + +class TestMultiDiGraph(unittest.TestCase): + def setUp(self): + self.G = eg.MultiDiGraph() + + def test_add_edge_without_key(self): + key1 = self.G.add_edge("A", "B", weight=1) + key2 = self.G.add_edge("A", "B", weight=2) + self.assertNotEqual(key1, key2) + self.assertEqual(len(self.G._adj["A"]["B"]), 2) + + def test_add_edge_with_key(self): + key = self.G.add_edge("A", "B", key="mykey", weight=3) + self.assertEqual(key, "mykey") + self.assertEqual(self.G._adj["A"]["B"]["mykey"]["weight"], 3) + + def test_edge_attributes_update(self): + self.G.add_edge("X", "Y", key=1, color="red") + self.G.add_edge("X", "Y", key=1, shape="circle") + self.assertEqual(self.G._adj["X"]["Y"][1]["color"], "red") + self.assertEqual(self.G._adj["X"]["Y"][1]["shape"], "circle") + + def test_remove_edge_by_key(self): + self.G.add_edge("A", "B", key="k1") + self.G.add_edge("A", "B", key="k2") + self.G.remove_edge("A", "B", key="k1") + self.assertIn("k2", self.G._adj["A"]["B"]) + self.assertNotIn("k1", self.G._adj["A"]["B"]) + + def test_remove_edge_without_key(self): + self.G.add_edge("A", "B", key="auto1") + self.G.add_edge("A", "B", key="auto2") + self.G.remove_edge("A", "B") + # Only one of the keys should remain + self.assertEqual(len(self.G._adj["A"]["B"]), 1) + + def test_remove_nonexistent_edge_raises(self): + with self.assertRaises(eg.EasyGraphError): + self.G.remove_edge("X", "Y", key="doesnotexist") + + def test_edges_property(self): + self.G.add_edge("U", "V", key="k", weight=5) + edges = self.G.edges + self.assertIn(("U", "V", "k", {"weight": 5}), edges) + + def test_in_out_degree(self): + self.G.add_edge("A", "B", weight=3) + self.G.add_edge("C", "B", weight=2) + + in_deg = {} + for n in self.G._node: + preds = self.G._pred[n] + in_deg[n] = sum( + d.get("weight", 1) + for key_dict in preds.values() + for d in key_dict.values() + ) + + self.assertEqual(in_deg["B"], 5) + + def test_to_undirected(self): + self.G.add_edge("A", "B", key="k", weight=10) + UG = self.G.to_undirected() + self.assertTrue(UG.has_edge("A", "B")) + self.assertEqual(UG["A"]["B"]["k"]["weight"], 10) + + def test_reverse_graph(self): + self.G.add_edge("A", "B", key="k", data=99) + RG = self.G.reverse() + self.assertTrue(RG.has_edge("B", "A")) + self.assertEqual(RG["B"]["A"]["k"]["data"], 99) + + def test_is_multigraph_and_directed(self): + self.assertTrue(self.G.is_multigraph()) + self.assertTrue(self.G.is_directed()) + + +if __name__ == "__main__": + unittest.main() +# test() diff --git a/easygraph/classes/tests/test_multigraph.py b/easygraph/classes/tests/test_multigraph.py new file mode 100644 index 0000000..963a061 --- /dev/null +++ b/easygraph/classes/tests/test_multigraph.py @@ -0,0 +1,149 @@ +import easygraph as eg +import pytest + + +class TestMultiGraph: + def setup_method(self): + self.Graph = eg.MultiGraph + # build K3 + ed1, ed2, ed3 = ({0: {}}, {0: {}}, {0: {}}) + self.k3adj = {0: {1: ed1, 2: ed2}, 1: {0: ed1, 2: ed3}, 2: {0: ed2, 1: ed3}} + self.k3edges = [(0, 1), (0, 2), (1, 2)] + self.k3nodes = [0, 1, 2] + self.K3 = self.Graph() + self.K3._adj = self.k3adj + self.K3._node = {} + self.K3._node[0] = {} + self.K3._node[1] = {} + self.K3._node[2] = {} + + def test_data_input(self): + G = self.Graph({1: [2], 2: [1]}, name="test") + assert G.name == "test" + expected = [(1, {2: {0: {}}}), (2, {1: {0: {}}})] + assert sorted(G.adj.items()) == expected + + def test_has_edge(self): + G = self.K3 + assert G.has_edge(0, 1) + assert not G.has_edge(0, -1) + assert G.has_edge(0, 1, 0) + assert not G.has_edge(0, 1, 1) + + def test_get_edge_data(self): + G = self.K3 + assert G.get_edge_data(0, 1) == {0: {}} + assert G[0][1] == {0: {}} + assert G[0][1][0] == {} + assert G.get_edge_data(10, 20) is None + assert G.get_edge_data(0, 1, 0) == {} + + def test_data_multigraph_input(self): + # standard case with edge keys and edge data + edata0 = dict(w=200, s="foo") + edata1 = dict(w=201, s="bar") + keydict = {0: edata0, 1: edata1} + dododod = {"a": {"b": keydict}} + + multiple_edge = [("a", "b", 0, edata0), ("a", "b", 1, edata1)] + single_edge = [("a", "b", 0, keydict)] + + G = self.Graph(dododod, multigraph_input=None) + assert list(G.edges) == multiple_edge + G = self.Graph(dododod, multigraph_input=False) + assert list(G.edges) == single_edge + + def test_remove_node(self): + G = self.K3 + G.remove_node(0) + assert G.adj == {1: {2: {0: {}}}, 2: {1: {0: {}}}} + with pytest.raises(eg.EasyGraphError): + G.remove_node(-1) + + +class TestMultiGraphExtended: + def test_add_multiple_edges_and_keys(self): + G = eg.MultiGraph() + k0 = G.add_edge(1, 2) + k1 = G.add_edge(1, 2) + assert k0 == 0 + assert k1 == 1 + assert G.number_of_edges(1, 2) == 2 + + def test_add_edge_with_key_and_attributes(self): + G = eg.MultiGraph() + k = G.add_edge(1, 2, key="custom", weight=3, label="test") + assert k == "custom" + assert G.get_edge_data(1, 2, "custom") == {"weight": 3, "label": "test"} + + def test_add_edges_from_various_formats(self): + G = eg.MultiGraph() + edges = [ + (1, 2), # 2-tuple + (2, 3, {"weight": 7}), # 3-tuple with attr + (3, 4, "k1", {"color": "red"}), # 4-tuple + ] + keys = G.add_edges_from(edges, capacity=100) + assert len(keys) == 3 + assert G.get_edge_data(3, 4, "k1")["color"] == "red" + assert G.get_edge_data(2, 3, 0)["capacity"] == 100 + + def test_remove_edge_with_key(self): + G = eg.MultiGraph() + G.add_edge(1, 2, key="a") + G.add_edge(1, 2, key="b") + G.remove_edge(1, 2, key="a") + assert not G.has_edge(1, 2, key="a") + assert G.has_edge(1, 2, key="b") + + def test_remove_edge_arbitrary(self): + G = eg.MultiGraph() + G.add_edge(1, 2) + G.add_edge(1, 2) + G.remove_edge(1, 2) + assert G.number_of_edges(1, 2) == 1 + + def test_remove_edges_from_mixed(self): + G = eg.MultiGraph() + keys = G.add_edges_from([(1, 2), (1, 2), (2, 3)]) + G.remove_edges_from([(1, 2), (2, 3)]) + assert G.number_of_edges(1, 2) == 1 + assert G.number_of_edges(2, 3) == 0 + + def test_to_directed_graph(self): + G = eg.MultiGraph() + G.add_edge(0, 1, weight=10) + D = G.to_directed() + assert D.is_directed() + assert D.has_edge(0, 1) + assert D.has_edge(1, 0) + assert D.get_edge_data(0, 1, 0)["weight"] == 10 + + def test_copy_graph(self): + G = eg.MultiGraph() + G.add_edge(1, 2, key="x", weight=9) + H = G.copy() + assert H.get_edge_data(1, 2, "x") == {"weight": 9} + assert H is not G + assert H.get_edge_data(1, 2, "x") is not G.get_edge_data(1, 2, "x") + + def test_has_edge_variants(self): + G = eg.MultiGraph() + G.add_edge(1, 2) + G.add_edge(1, 2, key="z") + assert G.has_edge(1, 2) + assert G.has_edge(1, 2, key="z") + assert not G.has_edge(2, 1, key="nonexistent") + + def test_get_edge_data_defaults(self): + G = eg.MultiGraph() + assert G.get_edge_data(10, 20) is None + assert G.get_edge_data(10, 20, key="any", default="missing") == "missing" + + def test_edge_property_returns_all_edges(self): + G = eg.MultiGraph() + G.add_edge(0, 1, key=5, label="important") + G.add_edge(1, 0, key=3, label="also important") + edges = list(G.edges) + assert any((0, 1, 5, {"label": "important"}) == e for e in edges) + assert any((0, 1, 3, {"label": "also important"}) == e for e in edges) diff --git a/easygraph/classes/tests/test_operation.py b/easygraph/classes/tests/test_operation.py new file mode 100644 index 0000000..2f653fa --- /dev/null +++ b/easygraph/classes/tests/test_operation.py @@ -0,0 +1,131 @@ +import easygraph as eg +import pytest + +from easygraph.classes import operation +from easygraph.utils import edges_equal + + +@pytest.mark.parametrize( + "graph_type", [eg.Graph, eg.DiGraph, eg.MultiGraph, eg.MultiDiGraph] +) +def test_selfloops(graph_type): + G = eg.complete_graph(3, create_using=graph_type) + G.add_edge(0, 0) + assert edges_equal(eg.selfloop_edges(G), [(0, 0)]) + assert edges_equal(eg.selfloop_edges(G, data=True), [(0, 0, {})]) + assert eg.number_of_selfloops(G) == 1 + + +def test_set_edge_attributes_scalar(): + G = eg.path_graph(3) + eg.set_edge_attributes(G, 5, "weight") + for _, _, data in G.edges: + assert data["weight"] == 5 + + +def test_set_edge_attributes_dict(): + G = eg.path_graph(3) + attrs = {(0, 1): 3, (1, 2): 7} + eg.set_edge_attributes(G, attrs, "weight") + assert G[0][1]["weight"] == 3 + assert G[1][2]["weight"] == 7 + + +def test_set_edge_attributes_dict_of_dict(): + G = eg.path_graph(3) + attrs = {(0, 1): {"a": 1}, (1, 2): {"b": 2}} + eg.set_edge_attributes(G, attrs) + assert G[0][1]["a"] == 1 + assert G[1][2]["b"] == 2 + + +def test_set_node_attributes_scalar(): + G = eg.path_graph(3) + eg.set_node_attributes(G, 42, "level") + for n in G.nodes: + assert G.nodes[n]["level"] == 42 + + +def test_set_node_attributes_dict(): + G = eg.path_graph(3) + eg.set_node_attributes(G, {0: "x", 1: "y"}, name="tag") + assert G.nodes[0]["tag"] == "x" + assert G.nodes[1]["tag"] == "y" + + +def test_set_node_attributes_dict_of_dict(): + G = eg.path_graph(3) + eg.set_node_attributes(G, {0: {"foo": 10}, 1: {"bar": 20}}) + assert G.nodes[0]["foo"] == 10 + assert G.nodes[1]["bar"] == 20 + + +def test_add_path_structure_and_attrs(): + G = eg.Graph() + eg.add_path(G, [10, 11, 12], weight=9) + actual_edges = {(u, v) for u, v, _ in G.edges} + assert actual_edges == {(10, 11), (11, 12)} + assert G[10][11]["weight"] == 9 + assert G[11][12]["weight"] == 9 + + +def test_topological_sort_linear(): + G = eg.DiGraph() + G.add_edges_from([(1, 2), (2, 3)]) + assert list(operation.topological_sort(G)) == [1, 2, 3] + + +def test_topological_sort_cycle(): + G = eg.DiGraph([(0, 1), (1, 2), (2, 0)]) + with pytest.raises(AssertionError, match="contains a cycle"): + list(operation.topological_sort(G)) + + +def test_selfloop_edges_variants(): + G = eg.MultiGraph() + G.add_edge(0, 0, key="x", label="loop") + G.add_edge(1, 1, key="y", label="loop2") + basic = list(eg.selfloop_edges(G)) + with_data = list(eg.selfloop_edges(G, data=True)) + with_keys = list(eg.selfloop_edges(G, keys=True)) + full = list(eg.selfloop_edges(G, keys=True, data="label")) + assert (0, 0) in basic and (1, 1) in basic + assert all(len(t) == 3 for t in with_data) + assert all(len(t) == 3 for t in with_keys) + assert "x" in [k for _, _, k, _ in full] + + +def test_number_of_selfloops(): + G = eg.MultiGraph() + G.add_edges_from([(0, 0), (1, 1), (1, 2)]) + assert eg.number_of_selfloops(G) == 2 + + +def test_density_undirected(): + G = eg.complete_graph(5) + d = eg.density(G) + assert pytest.approx(d, 0.01) == 1.0 + + +def test_density_directed(): + G = eg.DiGraph() + G.add_edges_from([(0, 1), (1, 2)]) + d = eg.density(G) + assert pytest.approx(d, 0.01) == 2 / (3 * (3 - 1)) # 2/6 + + +def test_topological_generations_linear(): + G = eg.DiGraph() + G.add_edges_from([(1, 2), (2, 3), (3, 4)]) + generations = list(operation.topological_generations(G)) + assert generations == [[1], [2], [3], [4]] + + +def test_topological_generations_branching(): + G = eg.DiGraph() + G.add_edges_from([(1, 2), (1, 3), (2, 4), (3, 4)]) + generations = list(operation.topological_generations(G)) + # Valid topological generations: [1], [2, 3], [4] + assert generations[0] == [1] + assert set(generations[1]) == {2, 3} + assert generations[2] == [4] diff --git a/easygraph/convert.py b/easygraph/convert.py new file mode 100644 index 0000000..c71d78e --- /dev/null +++ b/easygraph/convert.py @@ -0,0 +1,591 @@ +import warnings + +from collections.abc import Collection +from collections.abc import Generator +from collections.abc import Iterator +from copy import deepcopy +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable +from typing import List +from typing import Optional +from typing import Union + +import easygraph as eg + +from easygraph.utils.exception import EasyGraphError + + +if TYPE_CHECKING: + import dgl + import networkx as nx + import torch_geometric + + from easygraph import DiGraph + from easygraph import Graph + +__all__ = [ + "from_dict_of_dicts", + "to_easygraph_graph", + "from_edgelist", + "from_dict_of_lists", + "from_networkx", + "from_dgl", + "from_pyg", + "to_networkx", + "to_dgl", + "to_pyg", + "dict_to_hypergraph", +] + + +def to_easygraph_graph(data, create_using=None, multigraph_input=False): + """Make a EasyGraph graph from a known data structure. + + The preferred way to call this is automatically + from the class constructor + + >>> d = {0: {1: {"weight": 1}}} # dict-of-dicts single edge (0,1) + >>> G = eg.Graph(d) + + instead of the equivalent + + >>> G = eg.from_dict_of_dicts(d) + + Parameters + ---------- + data : object to be converted + + Current known types are: + any EasyGraph graph + dict-of-dicts + dict-of-lists + container (e.g. set, list, tuple) of edges + iterator (e.g. itertools.chain) that produces edges + generator of edges + Pandas DataFrame (row per edge) + numpy matrix + numpy ndarray + scipy sparse matrix + pygraphviz agraph + + create_using : EasyGraph graph constructor, optional (default=eg.Graph) + Graph type to create. If graph instance, then cleared before populated. + + multigraph_input : bool (default False) + If True and data is a dict_of_dicts, + try to create a multigraph assuming dict_of_dict_of_lists. + If data and create_using are both multigraphs then create + a multigraph from a multigraph. + + """ + + # EasyGraph graph type + if hasattr(data, "adj"): + try: + result = from_dict_of_dicts( + data.adj, + create_using=create_using, + multigraph_input=data.is_multigraph(), + ) + # data.graph should be dict-like + result.graph.update(data.graph) + # data.nodes should be dict-like + # result.add_node_from(data.nodes.items()) possible but + # for custom node_attr_dict_factory which may be hashable + # will be unexpected behavior + for n, dd in data.nodes.items(): + result._node[n].update(dd) + return result + except Exception as err: + raise eg.EasyGraphError("Input is not a correct EasyGraph graph.") from err + + # pygraphviz agraph + if hasattr(data, "is_strict"): + try: + return eg.from_pyGraphviz_agraph(data, create_using=create_using) + except Exception as err: + raise eg.EasyGraphError("Input is not a correct pygraphviz graph.") from err + + # dict of dicts/lists + if isinstance(data, dict): + try: + return from_dict_of_dicts( + data, create_using=create_using, multigraph_input=multigraph_input + ) + except Exception as err: + if multigraph_input is True: + raise eg.EasyGraphError( + f"converting multigraph_input raised:\n{type(err)}: {err}" + ) + try: + return from_dict_of_lists(data, create_using=create_using) + except Exception as err: + raise TypeError("Input is not known type.") from err + + # Pandas DataFrame + try: + import pandas as pd + + if isinstance(data, pd.DataFrame): + if data.shape[0] == data.shape[1]: + try: + return eg.from_pandas_adjacency(data, create_using=create_using) + except Exception as err: + msg = "Input is not a correct Pandas DataFrame adjacency matrix." + raise eg.EasyGraphError(msg) from err + else: + try: + return eg.from_pandas_edgelist( + data, edge_attr=True, create_using=create_using + ) + except Exception as err: + msg = "Input is not a correct Pandas DataFrame adjacency edge-list." + raise eg.EasyGraphError(msg) from err + except ImportError: + warnings.warn("pandas not found, skipping conversion test.", ImportWarning) + + # numpy matrix or ndarray + try: + import numpy as np + + if isinstance(data, np.ndarray): + try: + return eg.from_numpy_array(data, create_using=create_using) + except Exception as err: + raise eg.EasyGraphError( + "Input is not a correct numpy matrix or array." + ) from err + except ImportError: + warnings.warn("numpy not found, skipping conversion test.", ImportWarning) + + # scipy sparse matrix - any format + try: + if hasattr(data, "format"): + try: + return eg.from_scipy_sparse_matrix(data, create_using=create_using) + except Exception as err: + raise eg.EasyGraphError( + "Input is not a correct scipy sparse matrix type." + ) from err + except ImportError: + warnings.warn("scipy not found, skipping conversion test.", ImportWarning) + + # Note: most general check - should remain last in order of execution + # Includes containers (e.g. list, set, dict, etc.), generators, and + # iterators (e.g. itertools.chain) of edges + + if isinstance(data, (Collection, Generator, Iterator)): + try: + return from_edgelist(data, create_using=create_using) + except Exception as err: + raise eg.EasyGraphError("Input is not a valid edge list") from err + + raise eg.EasyGraphError("Input is not a known data type for conversion.") + + +def from_dict_of_lists(d, create_using=None): + G = eg.empty_graph(0, create_using) + G.add_nodes_from(d) + if G.is_multigraph() and not G.is_directed(): + # a dict_of_lists can't show multiedges. BUT for undirected graphs, + # each edge shows up twice in the dict_of_lists. + # So we need to treat this case separately. + seen = {} + for node, nbrlist in d.items(): + for nbr in nbrlist: + if nbr not in seen: + G.add_edge(node, nbr) + seen[node] = 1 # don't allow reverse edge to show up + else: + G.add_edges_from( + ((node, nbr) for node, nbrlist in d.items() for nbr in nbrlist) + ) + return G + + +def from_dict_of_dicts(d, create_using=None, multigraph_input=False): + G = eg.empty_graph(0, create_using) + G.add_nodes_from(d) + # does dict d represent a MultiGraph or MultiDiGraph? + if multigraph_input: + if G.is_directed(): + if G.is_multigraph(): + G.add_edges_from( + (u, v, key, data) + for u, nbrs in d.items() + for v, datadict in nbrs.items() + for key, data in datadict.items() + ) + else: + G.add_edges_from( + (u, v, data) + for u, nbrs in d.items() + for v, datadict in nbrs.items() + for key, data in datadict.items() + ) + else: # Undirected + if G.is_multigraph(): + seen = set() # don't add both directions of undirected graph + for u, nbrs in d.items(): + for v, datadict in nbrs.items(): + if (u, v) not in seen: + G.add_edges_from( + (u, v, key, data) for key, data in datadict.items() + ) + seen.add((v, u)) + else: + seen = set() # don't add both directions of undirected graph + for u, nbrs in d.items(): + for v, datadict in nbrs.items(): + if (u, v) not in seen: + G.add_edges_from( + (u, v, data) for key, data in datadict.items() + ) + seen.add((v, u)) + + else: # not a multigraph to multigraph transfer + if G.is_multigraph() and not G.is_directed(): + # d can have both representations u-v, v-u in dict. Only add one. + # We don't need this check for digraphs since we add both directions, + # or for Graph() since it is done implicitly (parallel edges not allowed) + seen = set() + for u, nbrs in d.items(): + for v, data in nbrs.items(): + if (u, v) not in seen: + G.add_edge(u, v, key=0) + G[u][v][0].update(data) + seen.add((v, u)) + else: + G.add_edges_from( + ((u, v, data) for u, nbrs in d.items() for v, data in nbrs.items()) + ) + return G + + +def from_edgelist(edgelist, create_using=None): + """Returns a graph from a list of edges. + + Parameters + ---------- + edgelist : list or iterator + Edge tuples + + create_using : EasyGraph graph constructor, optional (default=eg.Graph) + Graph type to create. If graph instance, then cleared before populated. + + Examples + -------- + >>> edgelist = [(0, 1)] # single edge (0,1) + >>> G = eg.from_edgelist(edgelist) + + or + + >>> G = eg.Graph(edgelist) # use Graph constructor + + """ + G = eg.empty_graph(0, create_using) + G.add_edges_from(edgelist) + return G + + +def to_networkx(g: "Union[Graph, DiGraph]") -> "Union[nx.Graph, nx.DiGraph]": + """Convert an EasyGraph to a NetworkX graph. + + Args: + g (Union[Graph, DiGraph]): An EasyGraph graph + + Raises: + ImportError is raised if NetworkX is not installed. + + Returns: + Union[nx.Graph, nx.DiGraph]: Converted NetworkX graph + """ + # if load_func_name in di_load_functions_name: + try: + import networkx as nx + except ImportError: + raise ImportError("NetworkX not found. Please install it.") + if g.is_directed(): + G = nx.DiGraph() + else: + G = nx.Graph() + + # copy attributes + G.graph = deepcopy(g.graph) + + nodes_with_edges = set() + for v1, v2, _ in g.edges: + G.add_edge(v1, v2) + nodes_with_edges.add(v1) + nodes_with_edges.add(v2) + for node in set(g.nodes) - nodes_with_edges: + G.add_node(node) + return G + + +def from_networkx(g: "Union[nx.Graph, nx.DiGraph]") -> "Union[Graph, DiGraph]": + """Convert a NetworkX graph to an EasyGraph graph. + + Args: + g (Union[nx.Graph, nx.DiGraph]): A NetworkX graph + + Returns: + Union[Graph, DiGraph]: Converted EasyGraph graph + """ + # try: + # import networkx as nx + # except ImportError: + # raise ImportError("NetworkX not found. Please install it.") + if g.is_directed(): + G = eg.DiGraph() + else: + G = eg.Graph() + + # copy attributes + G.graph = deepcopy(g.graph) + + nodes_with_edges = set() + for v1, v2 in g.edges: + G.add_edge(v1, v2) + nodes_with_edges.add(v1) + nodes_with_edges.add(v2) + for node in set(g.nodes) - nodes_with_edges: + G.add_node(node) + return G + + +def to_dgl(g: "Union[Graph, DiGraph]"): + """Convert an EasyGraph graph to a DGL graph. + + Args: + g (Union[Graph, DiGraph]): An EasyGraph graph + + Raises: + ImportError: If DGL is not installed. + + Returns: + DGLGraph: Converted DGL graph + """ + try: + import dgl + except ImportError: + raise ImportError("DGL not found. Please install it.") + g_nx = to_networkx(g) + g_dgl = dgl.from_networkx(g_nx) + return g_dgl + + +def from_dgl(g) -> "Union[Graph, DiGraph]": + """Convert a DGL graph to an EasyGraph graph. + + Args: + g (DGLGraph): A DGL graph + + Raises: + ImportError: If DGL is not installed. + + Returns: + Union[Graph, DiGraph]: Converted EasyGraph graph + """ + try: + import dgl + except ImportError: + raise ImportError("DGL not found. Please install it.") + g_nx = dgl.to_networkx(g) + g_eg = from_networkx(g_nx) + return g_eg + + +def to_pyg( + G: Any, + group_node_attrs: Optional[Union[List[str], all]] = None, # type: ignore + group_edge_attrs: Optional[Union[List[str], all]] = None, # type: ignore +) -> "torch_geometric.data.Data": # type: ignore + r"""Converts a :obj:`easygraph.Graph` or :obj:`easygraph.DiGraph` to a + :class:`torch_geometric.data.Data` instance. + + Args: + G (easygraph.Graph or easygraph.DiGraph): A easygraph graph. + group_node_attrs (List[str] or all, optional): The node attributes to + be concatenated and added to :obj:`data.x`. (default: :obj:`None`) + group_edge_attrs (List[str] or all, optional): The edge attributes to + be concatenated and added to :obj:`data.edge_attr`. + (default: :obj:`None`) + + .. note:: + + All :attr:`group_node_attrs` and :attr:`group_edge_attrs` values must + be numeric. + + Examples: + + >>> import torch_geometric as pyg + + >>> pyg_to_networkx = pyg.utils.convert.to_networkx # type: ignore + >>> networkx_to_pyg = pyg.utils.convert.from_networkx # type: ignore + >>> Data = pyg.data.Data # type: ignore + >>> edge_index = torch.tensor([ + ... [0, 1, 1, 2, 2, 3], + ... [1, 0, 2, 1, 3, 2], + ... ]) + >>> data = Data(edge_index=edge_index, num_nodes=4) + >>> g = pyg_to_networkx(data) + >>> # A `Data` object is returned + >>> to_pyg(g) + Data(edge_index=[2, 6], num_nodes=4) + """ + try: + import torch_geometric as pyg + + pyg_to_networkx = pyg.utils.convert.to_networkx # type: ignore + networkx_to_pyg = pyg.utils.convert.from_networkx # type: ignore + except ImportError: + raise ImportError("pytorch_geometric not found. Please install it.") + + g_nx = to_networkx(G) + g_pyg = networkx_to_pyg(g_nx, group_node_attrs, group_edge_attrs) + return g_pyg + + +def from_pyg( + data: "torch_geometric.data.Data", # type: ignore + node_attrs: Optional[Iterable[str]] = None, + edge_attrs: Optional[Iterable[str]] = None, + graph_attrs: Optional[Iterable[str]] = None, + to_undirected: Optional[Union[bool, str]] = False, + remove_self_loops: bool = False, +) -> Any: + r"""Converts a :class:`torch_geometric.data.Data` instance to a + :obj:`easygraph.Graph` if :attr:`to_undirected` is set to :obj:`True`, or + a directed :obj:`easygraph.DiGraph` otherwise. + + Args: + data (torch_geometric.data.Data): The data object. + node_attrs (iterable of str, optional): The node attributes to be + copied. (default: :obj:`None`) + edge_attrs (iterable of str, optional): The edge attributes to be + copied. (default: :obj:`None`) + graph_attrs (iterable of str, optional): The graph attributes to be + copied. (default: :obj:`None`) + to_undirected (bool or str, optional): If set to :obj:`True` or + "upper", will return a :obj:`easygraph.Graph` instead of a + :obj:`easygraph.DiGraph`. The undirected graph will correspond to + the upper triangle of the corresponding adjacency matrix. + Similarly, if set to "lower", the undirected graph will correspond + to the lower triangle of the adjacency matrix. (default: + :obj:`False`) + remove_self_loops (bool, optional): If set to :obj:`True`, will not + include self loops in the resulting graph. (default: :obj:`False`) + + Examples: + + >>> import torch_geometric as pyg + + >>> Data = pyg.data.Data # type: ignore + >>> edge_index = torch.tensor([ + ... [0, 1, 1, 2, 2, 3], + ... [1, 0, 2, 1, 3, 2], + ... ]) + >>> data = Data(edge_index=edge_index, num_nodes=4) + >>> from_pyg(data) + + + """ + + try: + import torch_geometric as pyg + + pyg_to_networkx = pyg.utils.convert.to_networkx # type: ignore + networkx_to_pyg = pyg.utils.convert.from_networkx # type: ignore + except ImportError: + raise ImportError("pytorch_geometric not found. Please install it.") + g_nx = pyg_to_networkx( + data, node_attrs, edge_attrs, graph_attrs, to_undirected, remove_self_loops + ) + g_eg = from_networkx(g_nx) + return g_eg + + +def dict_to_hypergraph(data, max_order=None, is_dynamic=False): + """ + A function to read a file in a standardized JSON format. + + Parameters + ---------- + data: dict + A dictionary in the hypergraph JSON format + max_order: int, optional + Maximum order of edges to add to the hypergraph + + Returns + ------- + A Hypergraph object + The loaded hypergraph + + Raises + ------ + EasyGraphError + If the JSON is not in a format that can be loaded. + + See Also + -------- + read_json + + """ + + timestamp_lst = list() + node_data = data["node-data"] + node_num = len(node_data) + G = eg.Hypergraph(num_v=node_num) + try: + # print(len(data["node-data"])) + for index, dd in data["node-data"].items(): + id = int(index) - 1 + G.v_property[id] = dd + except KeyError: + raise EasyGraphError("Failed to import node attributes.") + + # try: + # import time + rows = [] + cols = [] + edge_flag_dict = {} + e_property_dict = data["edge-data"] + edge_id = 0 + for index, edge in data["edge-dict"].items(): + # print("id:",id) + if max_order and len(edge) > max_order + 1: + continue + + try: + id = int(index) + except ValueError as e: + raise TypeError( + f"Failed to convert the edge with ID {id} to type int." + ) from e + + try: + edge = [int(n) - 1 for n in edge] + if tuple(edge) not in edge_flag_dict: + edge_flag_dict[tuple(edge)] = 1 + rows.extend(edge) + cols.extend(len(edge) * [edge_id]) + edge_id += 1 + + except ValueError as e: + raise TypeError(f"Failed to convert nodes to type int.") from e + + if is_dynamic: + G.add_hyperedges( + e_list=edge, + e_property=e_property_dict[str(id)], + group_name=e_property_dict[str(id)]["timestamp"], + ) + + timestamp_lst.append(e_property_dict[str(id)]["timestamp"]) + else: + G.add_hyperedges(e_list=edge, e_property=e_property_dict[str(id)]) + G._rows = rows + G._cols = cols + return G, timestamp_lst diff --git a/easygraph/datapipe/__init__.py b/easygraph/datapipe/__init__.py new file mode 100644 index 0000000..cdc747f --- /dev/null +++ b/easygraph/datapipe/__init__.py @@ -0,0 +1,29 @@ +try: + from .common import compose_pipes + from .common import to_bool_tensor + from .common import to_long_tensor + from .common import to_tensor + from .normalize import min_max_scaler + from .normalize import norm_ft +except: + print( + "Warning raise in module:datapipe. Please install Pytorch before you use" + " functions related to nueral network" + ) + +from .loader import load_from_json +from .loader import load_from_pickle +from .loader import load_from_txt + + +# __all__ = [ +# "compose_pipes", +# "norm_ft", +# "min_max_scaler", +# "to_tensor", +# "to_bool_tensor", +# "to_long_tensor", +# "load_from_pickle", +# "load_from_json", +# "load_from_txt", +# ] diff --git a/easygraph/datapipe/common.py b/easygraph/datapipe/common.py new file mode 100644 index 0000000..4a4cb7b --- /dev/null +++ b/easygraph/datapipe/common.py @@ -0,0 +1,106 @@ +from typing import Any +from typing import Callable +from typing import List +from typing import Union + +import numpy as np +import scipy.sparse +import torch + + +def to_tensor( + X: Union[list, np.ndarray, torch.Tensor, scipy.sparse.csr_matrix] +) -> torch.Tensor: + r"""Convert ``List``, ``numpy.ndarray``, ``scipy.sparse.csr_matrix`` to ``torch.Tensor``. + + Args: + ``X`` (``Union[List, np.ndarray, torch.Tensor, scipy.sparse.csr_matrix]``): Input. + + Examples: + >>> import easygraph.datapipe as dd + >>> X = [[0.1, 0.2, 0.5], + [0.5, 0.2, 0.3], + [0.3, 0.2, 0]] + >>> dd.to_tensor(X) + tensor([[0.1000, 0.2000, 0.5000], + [0.5000, 0.2000, 0.3000], + [0.3000, 0.2000, 0.0000]]) + """ + if isinstance(X, list): + X = torch.tensor(X) + elif isinstance(X, scipy.sparse.csr_matrix): + X = X.todense() + X = torch.tensor(X) + elif isinstance(X, scipy.sparse.coo_matrix): + X = X.todense() + X = torch.tensor(X) + elif isinstance(X, np.ndarray): + X = torch.tensor(X) + else: + X = torch.tensor(X) + return X.float() + + +def to_bool_tensor(X: Union[List, np.ndarray, torch.Tensor]) -> torch.BoolTensor: + r"""Convert ``List``, ``numpy.ndarray``, ``torch.Tensor`` to ``torch.BoolTensor``. + + Args: + ``X`` (``Union[List, np.ndarray, torch.Tensor]``): Input. + + Examples: + >>> import easygraph.datapipe as dd + >>> X = [[0.1, 0.2, 0.5], + [0.5, 0.2, 0.3], + [0.3, 0.2, 0]] + >>> dd.to_bool_tensor(X) + tensor([[ True, True, True], + [ True, True, True], + [ True, True, False]]) + """ + if isinstance(X, list): + X = torch.tensor(X) + elif isinstance(X, np.ndarray): + X = torch.tensor(X) + else: + X = torch.tensor(X) + return X.bool() + + +def to_long_tensor(X: Union[List, np.ndarray, torch.Tensor]) -> torch.LongTensor: + r"""Convert ``List``, ``numpy.ndarray``, ``torch.Tensor`` to ``torch.LongTensor``. + + Args: + ``X`` (``Union[List, np.ndarray, torch.Tensor]``): Input. + + Examples: + >>> import easygraph.datapipe as dd + >>> X = [[1, 2, 5], + [5, 2, 3], + [3, 2, 0]] + >>> dd.to_long_tensor(X) + tensor([[1, 2, 5], + [5, 2, 3], + [3, 2, 0]]) + """ + if isinstance(X, list): + X = torch.tensor(X) + elif isinstance(X, np.ndarray): + X = torch.tensor(X) + else: + X = torch.tensor(X) + return X.long() + + +def compose_pipes(*pipes: Callable) -> Callable: + r"""Compose datapipe functions. + + Args: + ``pipes`` (``Callable``): Datapipe functions to compose. + """ + + def composed_pipes(X: Any) -> torch.Tensor: + for pipe in pipes: + X = pipe(X) + return X + + return composed_pipes diff --git a/easygraph/datapipe/loader.py b/easygraph/datapipe/loader.py new file mode 100644 index 0000000..9a053bd --- /dev/null +++ b/easygraph/datapipe/loader.py @@ -0,0 +1,90 @@ +import json +import pickle as pkl +import re + +from pathlib import Path +from typing import Callable +from typing import List +from typing import Optional +from typing import Union + + +def load_from_pickle( + file_path: Path, keys: Optional[Union[str, List[str]]] = None, **kwargs +): + r"""Load data from a pickle file. + + Args: + ``file_path`` (``Path``): The local path of the file. + ``keys`` (``Union[str, List[str]]``, optional): The keys of the data. Defaults to ``None``. + """ + if isinstance(file_path, list): + raise ValueError("This function only support loading data from a single file.") + with open(file_path, "rb") as f: + data = pkl.load(f, **kwargs) + if keys is None: + return data + elif isinstance(keys, str): + return data[keys] + else: + return {key: data[key] for key in keys} + + +def load_from_json(file_path: Path, **kwargs): + r"""Load data from a json file. + + Args: + ``file_path`` (``Path``): The local path of the file. + """ + with open(file_path, "r") as f: + data = json.load(f, **kwargs) + return data + + +def load_from_txt( + file_path: Path, + dtype: Union[str, Callable], + sep: str = ",| |\t", + ignore_header: int = 0, +): + r"""Load data from a txt file. + + .. note:: + The separator is a regular expression of ``re`` module. Multiple separators can be separated by ``|``. More details can refer to `re.split `_. + + Args: + ``file_path`` (``Path``): The local path of the file. + ``dtype`` (``Union[str, Callable]``): The data type of the data can be either a string or a callable function. + ``sep`` (``str``, optional): The separator of each line in the file. Defaults to ``",| |\t"``. + ``ignore_header`` (``int``, optional): The number of lines to ignore in the header of the file. Defaults to ``0``. + """ + cast_fun = ret_cast_fun(dtype) + file_path = Path(file_path) + assert file_path.exists(), f"{file_path} does not exist." + data = [] + with open(file_path, "r") as f: + for _ in range(ignore_header): + f.readline() + data = [ + list(map(cast_fun, re.split(sep, line.strip()))) for line in f.readlines() + ] + return data + + +def ret_cast_fun(dtype: Union[str, Callable]): + r"""Return the cast function of the data type. The supported data types are: ``int``, ``float``, ``str``. + + Args: + ``dtype`` (``Union[str, Callable]``): The data type of the data can be either a string or a callable function. + """ + if isinstance(dtype, str): + if dtype == "int": + return int + elif dtype == "float": + return float + elif dtype == "str": + return str + else: + raise ValueError("dtype must be one of 'int', 'float', 'str'.") + else: + return dtype diff --git a/easygraph/datapipe/normalize.py b/easygraph/datapipe/normalize.py new file mode 100644 index 0000000..9639d8f --- /dev/null +++ b/easygraph/datapipe/normalize.py @@ -0,0 +1,74 @@ +from typing import Optional +from typing import Union + +import torch + + +def norm_ft(X: torch.Tensor, ord: Optional[Union[int, float]] = None) -> torch.Tensor: + r"""Normalize the input feature matrix with specified ``ord`` refer to pytorch's `torch.linalg.norm `_ function. + + .. note:: + The input feature matrix is expected to be a 1D vector or a 2D tensor with shape (num_samples, num_features). + + Args: + ``X`` (``torch.Tensor``): The input feature. + ``ord`` (``Union[int, float]``, optional): The order of the norm can be either an ``int``, ``float``. If ``ord`` is ``None``, the norm is computed with the 2-norm. Defaults to ``None``. + + Examples: + >>> import easygraph.datapipe as dd + >>> import torch + >>> X = torch.tensor([ + [0.1, 0.2, 0.5], + [0.5, 0.2, 0.3], + [0.3, 0.2, 0] + ]) + >>> dd.norm_ft(X) + tensor([[0.1826, 0.3651, 0.9129], + [0.8111, 0.3244, 0.4867], + [0.8321, 0.5547, 0.0000]]) + """ + if X.dim() == 1: + X_norm = 1 / torch.linalg.norm(X, ord=ord) + X_norm[torch.isinf(X_norm)] = 0 + return X * X_norm + elif X.dim() == 2: + X_norm = 1 / torch.linalg.norm(X, ord=ord, dim=1, keepdim=True) + X_norm[torch.isinf(X_norm)] = 0 + return X * X_norm + else: + raise ValueError( + "The input feature matrix is expected to be a 1D verter or a 2D tensor with" + " shape (num_samples, num_features)." + ) + + +def min_max_scaler(X: torch.Tensor, ft_min: float, ft_max: float) -> torch.Tensor: + r"""Normalize the input feature matrix with min-max scaling. + + Args: + ``X`` (``torch.Tensor``): The input feature. + ``ft_min`` (``float``): The minimum value of the output feature. + ``ft_max`` (``float``): The maximum value of the output feature. + + Examples: + >>> import easygraph.datapipe as dd + >>> import torch + >>> X = torch.tensor([ + [0.1, 0.2, 0.5], + [0.5, 0.2, 0.3], + [0.3, 0.2, 0.0] + ]) + >>> dd.min_max_scaler(X, -1, 1) + tensor([[-0.6000, -0.2000, 1.0000], + [ 1.0000, -0.2000, 0.2000], + [ 0.2000, -0.2000, -1.0000]]) + """ + assert ( + ft_min < ft_max + ), "The minimum value of the feature should be less than the maximum value." + X_min, X_max = X.min().item(), X.max().item() + X_range = X_max - X_min + scale_ = (ft_max - ft_min) / X_range + min_ = ft_min - X_min * scale_ + X = X * scale_ + min_ + return X diff --git a/easygraph/datasets/__init__.py b/easygraph/datasets/__init__.py new file mode 100644 index 0000000..035ada5 --- /dev/null +++ b/easygraph/datasets/__init__.py @@ -0,0 +1,35 @@ +# risky imports +try: + from easygraph.datasets.get_sample_graph import * + from easygraph.datasets.gnn_benchmark import * + from easygraph.datasets.hypergraph.coauthorship import * + from easygraph.datasets.hypergraph.contact_primary_school import * + from easygraph.datasets.hypergraph.cooking_200 import Cooking200 + from easygraph.datasets.hypergraph.House_Committees import House_Committees + from easygraph.datasets.karate import KarateClubDataset + from easygraph.datasets.mathoverflow_answers import mathoverflow_answers + + from .ppi import LegacyPPIDataset + from .ppi import PPIDataset +except Exception as e: + print( + " Please install Pytorch before use graph-related datasets and" + " hypergraph-related datasets." + ) + +from .amazon_photo import AmazonPhotoDataset +from .arxiv import ArxivHEPTHDataset +from .citation_graph import CitationGraphDataset +from .citation_graph import CiteseerGraphDataset +from .citation_graph import CoraBinary +from .citation_graph import CoraGraphDataset +from .citation_graph import PubmedGraphDataset +from .coauthor import CoauthorCSDataset +from .facebook_ego import FacebookEgoNetDataset +from .flickr import FlickrDataset +from .github import GitHubUsersDataset +from .reddit import RedditDataset +from .roadnet import RoadNetCADataset +from .twitter_ego import TwitterEgoDataset +from .web_google import WebGoogleDataset +from .wiki_topcats import WikiTopCatsDataset diff --git a/easygraph/datasets/amazon_photo.py b/easygraph/datasets/amazon_photo.py new file mode 100644 index 0000000..a9295a2 --- /dev/null +++ b/easygraph/datasets/amazon_photo.py @@ -0,0 +1,110 @@ +import os + +import easygraph as eg +import numpy as np +import scipy.sparse as sp + +from easygraph.classes.graph import Graph + +from .graph_dataset_base import EasyGraphBuiltinDataset +from .utils import data_type_dict +from .utils import download +from .utils import extract_archive +from .utils import tensor + + +class AmazonPhotoDataset(EasyGraphBuiltinDataset): + r"""Amazon Electronics Photo co-purchase graph dataset. + + Nodes represent products, and edges link products frequently co-purchased. + Node features are bag-of-words of product reviews. The task is to classify + the product category. + + Statistics: + + - Nodes: 7,650 + - Edges: 119,081 + - Number of Classes: 8 + - Features: 745 + + Parameters + ---------- + raw_dir : str, optional + Raw file directory to download/contains the input data directory. Default: None + force_reload : bool, optional + Whether to reload the dataset. Default: False + verbose : bool, optional + Whether to print out progress information. Default: True + transform : callable, optional + A transform that takes in a :class:`~easygraph.Graph` object and returns + a transformed version. The :class:`~easygraph.Graph` object will be + transformed before every access. + + Examples + -------- + >>> from easygraph.datasets import AmazonPhotoDataset + >>> dataset = AmazonPhotoDataset() + >>> g = dataset[0] + >>> print(g.number_of_nodes()) + >>> print(g.number_of_edges()) + >>> print(g.nodes[0]['feat'].shape) + >>> print(g.nodes[0]['label']) + >>> print(dataset.num_classes) + """ + + def __init__(self, raw_dir=None, force_reload=False, verbose=True, transform=None): + name = "amazon_photo" + url = "https://data.dgl.ai/dataset/amazon_co_buy_photo.zip" + super(AmazonPhotoDataset, self).__init__( + name=name, + url=url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def process(self): + path = os.path.join(self.raw_path, "amazon_co_buy_photo.npz") + data = np.load(path) + + adj = sp.csr_matrix( + (data["adj_data"], data["adj_indices"], data["adj_indptr"]), + shape=data["adj_shape"], + ) + + features = sp.csr_matrix( + (data["attr_data"], data["attr_indices"], data["attr_indptr"]), + shape=data["attr_shape"], + ).todense() + + labels = data["labels"] + + g = eg.Graph() + g.add_edges_from(list(zip(*adj.nonzero()))) + + for i in range(features.shape[0]): + g.add_node(i, feat=np.array(features[i]).squeeze(), label=int(labels[i])) + + self._g = g + self._num_classes = len(np.unique(labels)) + + if self.verbose: + print("Finished loading AmazonPhoto dataset.") + print(f" NumNodes: {g.number_of_nodes()}") + print(f" NumEdges: {g.number_of_edges()}") + print(f" NumFeats: {features.shape[1]}") + print(f" NumClasses: {self._num_classes}") + + def __getitem__(self, idx): + assert idx == 0, "AmazonPhotoDataset only contains one graph" + if self._g is None: + raise ValueError("Graph has not been loaded or processed correctly.") + return self._g if self._transform is None else self._transform(self._g) + + def __len__(self): + return 1 + + @property + def num_classes(self): + return self._num_classes diff --git a/easygraph/datasets/arxiv.py b/easygraph/datasets/arxiv.py new file mode 100644 index 0000000..cfce499 --- /dev/null +++ b/easygraph/datasets/arxiv.py @@ -0,0 +1,106 @@ +"""Arxiv HEP-TH Citation Network + +This dataset represents the citation network of preprints from the High Energy Physics - Theory (HEP-TH) category on arXiv, covering the period from January 1993 to April 2003. + +Each node corresponds to a paper, and a directed edge from paper A to paper B indicates that A cites B. + +No features or labels are included in this dataset. + +Statistics: +- Nodes: 27,770 +- Edges: 352,807 +- Features: None +- Labels: None + +Reference: +J. Leskovec, J. Kleinberg and C. Faloutsos, "Graphs over Time: Densification Laws, Shrinking Diameters and Possible Explanations," +in KDD 2005. Dataset: https://snap.stanford.edu/data/cit-HepTh.html +""" + +import gzip +import os +import shutil + +import easygraph as eg + +from easygraph.classes.graph import Graph + +from .graph_dataset_base import EasyGraphBuiltinDataset +from .utils import download + + +class ArxivHEPTHDataset(EasyGraphBuiltinDataset): + r"""Arxiv HEP-TH citation network dataset. + + Parameters + ---------- + raw_dir : str, optional + Directory to store the raw downloaded files. Default: None + force_reload : bool, optional + Whether to re-download and process the dataset. Default: False + verbose : bool, optional + Whether to print detailed processing logs. Default: True + transform : callable, optional + Optional transform to apply on the graph. + + Examples + -------- + >>> from easygraph.datasets import ArxivHEPTHDataset + >>> dataset = ArxivHEPTHDataset() + >>> g = dataset[0] + >>> print("Nodes:", g.number_of_nodes()) + >>> print("Edges:", g.number_of_edges()) + """ + + def __init__(self, raw_dir=None, force_reload=False, verbose=True, transform=None): + name = "cit-HepTh" + url = "https://snap.stanford.edu/data/cit-HepTh.txt.gz" + super(ArxivHEPTHDataset, self).__init__( + name=name, + url=url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def download(self): + r"""Download and decompress the .txt.gz file.""" + compressed_path = os.path.join(self.raw_dir, self.name + ".txt.gz") + extracted_path = os.path.join(self.raw_path, self.name + ".txt") + + download(self.url, path=compressed_path) + + if not os.path.exists(self.raw_path): + os.makedirs(self.raw_path) + + with gzip.open(compressed_path, "rb") as f_in: + with open(extracted_path, "wb") as f_out: + shutil.copyfileobj(f_in, f_out) + + def process(self): + graph = eg.DiGraph() # Citation network is directed + edge_list_path = os.path.join(self.raw_path, self.name + ".txt") + + with open(edge_list_path, "r") as f: + for line in f: + if line.startswith("#") or line.strip() == "": + continue + u, v = map(int, line.strip().split()) + graph.add_edge(u, v) + + self._g = graph + self._num_nodes = graph.number_of_nodes() + self._num_edges = graph.number_of_edges() + + if self.verbose: + print("Finished loading Arxiv HEP-TH dataset.") + print(f" NumNodes: {self._num_nodes}") + print(f" NumEdges: {self._num_edges}") + + def __getitem__(self, idx): + assert idx == 0, "ArxivHEPTHDataset only contains one graph" + return self._g if self._transform is None else self._transform(self._g) + + def __len__(self): + return 1 diff --git a/easygraph/datasets/citation_graph.py b/easygraph/datasets/citation_graph.py new file mode 100644 index 0000000..3795d67 --- /dev/null +++ b/easygraph/datasets/citation_graph.py @@ -0,0 +1,875 @@ +"""Cora, citeseer, pubmed dataset.""" + +from __future__ import absolute_import + +import os +import pickle as pkl +import sys + +import easygraph as eg +import numpy as np +import scipy.sparse as sp + +from easygraph.classes.graph import Graph + +from .graph_dataset_base import EasyGraphBuiltinDataset +from .utils import _get_dgl_url +from .utils import data_type_dict +from .utils import deprecate_property +from .utils import generate_mask_tensor +from .utils import nonzero_1d +from .utils import tensor + + +def _pickle_load(pkl_file): + if sys.version_info > (3, 0): + return pkl.load(pkl_file, encoding="latin1") + else: + return pkl.load(pkl_file) + + +class CitationGraphDataset(EasyGraphBuiltinDataset): + r"""The citation graph dataset, including Cora, CiteSeer and PubMed. + Nodes mean authors and edges mean citation relationships. + + Parameters + ----------- + name: str + name can be 'Cora', 'CiteSeer' or 'PubMed'. + raw_dir : str + Raw file directory to download/contains the input data directory. + Default: ~/.dgl/ + force_reload : bool + Whether to reload the dataset. Default: False + verbose : bool + Whether to print out progress information. Default: True. + reverse_edge : bool + Whether to add reverse edges in graph. Default: True. + transform : callable, optional + A transform that takes in a :class:`~eg.Graph` object and returns + a transformed version. The :class:`~eg.Graph` object will be + transformed before every access. + reorder : bool + Whether to reorder the graph using :func:`~eg.reorder_graph`. Default: False. + """ + + _urls = { + "cora_v2": "dataset/cora_v2.zip", + "citeseer": "dataset/citeseer.zip", + "pubmed": "dataset/pubmed.zip", + } + + def __init__( + self, + name, + raw_dir=None, + force_reload=False, + verbose=True, + reverse_edge=True, + transform=None, + reorder=False, + ): + assert name.lower() in ["cora", "citeseer", "pubmed"] + + # Previously we use the pre-processing in pygcn (https://github.com/tkipf/pygcn) + # for Cora, which is slightly different from the one used in the GCN paper + if name.lower() == "cora": + name = "cora_v2" + + url = _get_dgl_url(self._urls[name]) + self._reverse_edge = reverse_edge + self._reorder = reorder + + super(CitationGraphDataset, self).__init__( + name, + url=url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def process(self): + """Loads input data from data directory and reorder graph for better locality + + ind.name.x => the feature vectors of the training instances as scipy.sparse.csr.csr_matrix object; + ind.name.tx => the feature vectors of the test instances as scipy.sparse.csr.csr_matrix object; + ind.name.allx => the feature vectors of both labeled and unlabeled training instances + (a superset of ind.name.x) as scipy.sparse.csr.csr_matrix object; + ind.name.y => the one-hot labels of the labeled training instances as numpy.ndarray object; + ind.name.ty => the one-hot labels of the test instances as numpy.ndarray object; + ind.name.ally => the labels for instances in ind.name.allx as numpy.ndarray object; + ind.name.graph => a dict in the format {index: [index_of_neighbor_nodes]} as collections.defaultdict + object; + ind.name.test.index => the indices of test instances in graph, for the inductive setting as list object. + """ + root = self.raw_path + objnames = ["x", "y", "tx", "ty", "allx", "ally", "graph"] + objects = [] + for i in range(len(objnames)): + with open("{}/ind.{}.{}".format(root, self.name, objnames[i]), "rb") as f: + objects.append(_pickle_load(f)) + + x, y, tx, ty, allx, ally, graph = tuple(objects) + test_idx_reorder = _parse_index_file( + "{}/ind.{}.test.index".format(root, self.name) + ) + test_idx_range = np.sort(test_idx_reorder) + + if self.name == "citeseer": + # Fix CiteSeer dataset (there are some isolated nodes in the graph) + # Find isolated nodes, add them as zero-vecs into the right position + test_idx_range_full = range( + min(test_idx_reorder), max(test_idx_reorder) + 1 + ) + tx_extended = sp.lil_matrix((len(test_idx_range_full), x.shape[1])) + tx_extended[test_idx_range - min(test_idx_range), :] = tx + tx = tx_extended + ty_extended = np.zeros((len(test_idx_range_full), y.shape[1])) + ty_extended[test_idx_range - min(test_idx_range), :] = ty + ty = ty_extended + + features = sp.vstack((allx, tx)).tolil() + features[test_idx_reorder, :] = features[test_idx_range, :] + + if self.reverse_edge: + g = eg.DiGraph(eg.from_dict_of_lists(graph)) + # g = from_networkx(graph) + else: + graph = eg.Graph(eg.from_dict_of_lists(graph)) + # edges = list(graph.edges()) + # u, v = map(list, zip(*edges)) + # g = dgl_graph((u, v)) + + onehot_labels = np.vstack((ally, ty)) + onehot_labels[test_idx_reorder, :] = onehot_labels[test_idx_range, :] + labels = np.argmax(onehot_labels, 1) + + idx_test = test_idx_range.tolist() + idx_train = range(len(y)) + idx_val = range(len(y), len(y) + 500) + + train_mask = generate_mask_tensor(_sample_mask(idx_train, labels.shape[0])) + val_mask = generate_mask_tensor(_sample_mask(idx_val, labels.shape[0])) + test_mask = generate_mask_tensor(_sample_mask(idx_test, labels.shape[0])) + + g.ndata["train_mask"] = train_mask + g.ndata["val_mask"] = val_mask + g.ndata["test_mask"] = test_mask + g.ndata["label"] = tensor(labels) + g.ndata["feat"] = tensor( + _preprocess_features(features), dtype=data_type_dict()["float32"] + ) + self._num_classes = onehot_labels.shape[1] + self._labels = labels + # if self._reorder: + # self._g = reorder_graph( + # g, node_permute_algo='rcmk', edge_permute_algo='dst', store_ids=False) + # else: + self._g = g + + if self.verbose: + print("Finished data loading and preprocessing.") + print(" NumNodes: {}".format(self._g.number_of_nodes())) + print(" NumEdges: {}".format(self._g.number_of_edges())) + print(" NumFeats: {}".format(self._g.ndata["feat"].shape[1])) + print(" NumClasses: {}".format(self.num_classes)) + print( + " NumTrainingSamples: {}".format( + nonzero_1d(self._g.ndata["train_mask"]).shape[0] + ) + ) + print( + " NumValidationSamples: {}".format( + nonzero_1d(self._g.ndata["val_mask"]).shape[0] + ) + ) + print( + " NumTestSamples: {}".format( + nonzero_1d(self._g.ndata["test_mask"]).shape[0] + ) + ) + + def has_cache(self): + graph_path = os.path.join(self.save_path, self.save_name + ".bin") + info_path = os.path.join(self.save_path, self.save_name + ".pkl") + if os.path.exists(graph_path) and os.path.exists(info_path): + return True + + return False + + # def save(self): + # """save the graph list and the labels""" + # graph_path = os.path.join(self.save_path, + # self.save_name + '.bin') + # info_path = os.path.join(self.save_path, + # self.save_name + '.pkl') + # save_graphs(str(graph_path), self._g) + # save_info(str(info_path), {'num_classes': self.num_classes}) + # + # def load(self): + # graph_path = os.path.join(self.save_path, + # self.save_name + '.bin') + # info_path = os.path.join(self.save_path, + # self.save_name + '.pkl') + # graphs, _ = load_graphs(str(graph_path)) + # + # info = load_info(str(info_path)) + # graph = graphs[0] + # self._g = graph + # # for compatibility + # graph = graph.clone() + # graph.ndata.pop('train_mask') + # graph.ndata.pop('val_mask') + # graph.ndata.pop('test_mask') + # graph.ndata.pop('feat') + # graph.ndata.pop('label') + # graph = to_networkx(graph) + # + # self._num_classes = info['num_classes'] + # self._g.ndata['train_mask'] = generate_mask_tensor(F.asnumpy(self._g.ndata['train_mask'])) + # self._g.ndata['val_mask'] = generate_mask_tensor(F.asnumpy(self._g.ndata['val_mask'])) + # self._g.ndata['test_mask'] = generate_mask_tensor(F.asnumpy(self._g.ndata['test_mask'])) + # # hack for mxnet compatibility + # + # if self.verbose: + # print(' NumNodes: {}'.format(self._g.number_of_nodes())) + # print(' NumEdges: {}'.format(self._g.number_of_edges())) + # print(' NumFeats: {}'.format(self._g.ndata['feat'].shape[1])) + # print(' NumClasses: {}'.format(self.num_classes)) + # print(' NumTrainingSamples: {}'.format( + # F.nonzero_1d(self._g.ndata['train_mask']).shape[0])) + # print(' NumValidationSamples: {}'.format( + # F.nonzero_1d(self._g.ndata['val_mask']).shape[0])) + # print(' NumTestSamples: {}'.format( + # F.nonzero_1d(self._g.ndata['test_mask']).shape[0])) + + def __getitem__(self, idx): + assert idx == 0, "This dataset has only one graph" + if self._transform is None: + return self._g + else: + return self._transform(self._g) + + def __len__(self): + return 1 + + @property + def save_name(self): + return self.name + "_dgl_graph" + + @property + def num_labels(self): + deprecate_property("dataset.num_labels", "dataset.num_classes") + return self.num_classes + + @property + def num_classes(self): + return self._num_classes + + """ Citation graph is used in many examples + We preserve these properties for compatibility. + """ + + @property + def reverse_edge(self): + return self._reverse_edge + + +def _preprocess_features(features): + """Row-normalize feature matrix and convert to tuple representation""" + rowsum = np.asarray(features.sum(1)) + r_inv = np.power(rowsum, -1).flatten() + r_inv[np.isinf(r_inv)] = 0.0 + r_mat_inv = sp.diags(r_inv) + features = r_mat_inv.dot(features) + return np.asarray(features.todense()) + + +def _parse_index_file(filename): + """Parse index file.""" + index = [] + for line in open(filename): + index.append(int(line.strip())) + return index + + +def _sample_mask(idx, l): + """Create mask.""" + mask = np.zeros(l) + mask[idx] = 1 + return mask + + +class CoraGraphDataset(CitationGraphDataset): + r"""Cora citation network dataset. + + Nodes mean paper and edges mean citation + relationships. Each node has a predefined + feature with 1433 dimensions. The dataset is + designed for the node classification task. + The task is to predict the category of + certain paper. + + Statistics: + + - Nodes: 2708 + - Edges: 10556 + - Number of Classes: 7 + - Label split: + + - Train: 140 + - Valid: 500 + - Test: 1000 + + Parameters + ---------- + raw_dir : str + Raw file directory to download/contains the input data directory. + Default: ~/.dgl/ + force_reload : bool + Whether to reload the dataset. Default: False + verbose : bool + Whether to print out progress information. Default: True. + reverse_edge : bool + Whether to add reverse edges in graph. Default: True. + transform : callable, optional + A transform that takes in a :class:`~dgl.DGLGraph` object and returns + a transformed version. The :class:`~dgl.DGLGraph` object will be + transformed before every access. + reorder : bool + Whether to reorder the graph using :func:`~dgl.reorder_graph`. Default: False. + + Attributes + ---------- + num_classes: int + Number of label classes + + Notes + ----- + The node feature is row-normalized. + + Examples + -------- + >>> dataset = CoraGraphDataset() + >>> g = dataset[0] + >>> num_class = dataset.num_classes + >>> + >>> # get node feature + >>> feat = g.ndata['feat'] + >>> + >>> # get data split + >>> train_mask = g.ndata['train_mask'] + >>> val_mask = g.ndata['val_mask'] + >>> test_mask = g.ndata['test_mask'] + >>> + >>> # get labels + >>> label = g.ndata['label'] + + """ + + def __init__( + self, + raw_dir=None, + force_reload=False, + verbose=True, + reverse_edge=True, + transform=None, + reorder=False, + ): + name = "cora" + + super(CoraGraphDataset, self).__init__( + name, raw_dir, force_reload, verbose, reverse_edge, transform, reorder + ) + + def __getitem__(self, idx): + r"""Gets the graph object + + Parameters + ----------- + idx: int + Item index, CoraGraphDataset has only one graph object + + Return + ------ + :class:`dgl.DGLGraph` + + graph structure, node features and labels. + + - ``ndata['train_mask']``: mask for training node set + - ``ndata['val_mask']``: mask for validation node set + - ``ndata['test_mask']``: mask for test node set + - ``ndata['feat']``: node feature + - ``ndata['label']``: ground truth labels + """ + return super(CoraGraphDataset, self).__getitem__(idx) + + def __len__(self): + r"""The number of graphs in the dataset.""" + return super(CoraGraphDataset, self).__len__() + + +class CiteseerGraphDataset(CitationGraphDataset): + r"""Citeseer citation network dataset. + + Nodes mean scientific publications and edges + mean citation relationships. Each node has a + predefined feature with 3703 dimensions. The + dataset is designed for the node classification + task. The task is to predict the category of + certain publication. + + Statistics: + + - Nodes: 3327 + - Edges: 9228 + - Number of Classes: 6 + - Label Split: + + - Train: 120 + - Valid: 500 + - Test: 1000 + + Parameters + ----------- + raw_dir : str + Raw file directory to download/contains the input data directory. + Default: ~/.dgl/ + force_reload : bool + Whether to reload the dataset. Default: False + verbose : bool + Whether to print out progress information. Default: True. + reverse_edge : bool + Whether to add reverse edges in graph. Default: True. + transform : callable, optional + A transform that takes in a :class:`~dgl.DGLGraph` object and returns + a transformed version. The :class:`~dgl.DGLGraph` object will be + transformed before every access. + reorder : bool + Whether to reorder the graph using :func:`~dgl.reorder_graph`. Default: False. + + Attributes + ---------- + num_classes: int + Number of label classes + + Notes + ----- + The node feature is row-normalized. + + In citeseer dataset, there are some isolated nodes in the graph. + These isolated nodes are added as zero-vecs into the right position. + + Examples + -------- + >>> dataset = CiteseerGraphDataset() + >>> g = dataset[0] + >>> num_class = dataset.num_classes + >>> + >>> # get node feature + >>> feat = g.ndata['feat'] + >>> + >>> # get data split + >>> train_mask = g.ndata['train_mask'] + >>> val_mask = g.ndata['val_mask'] + >>> test_mask = g.ndata['test_mask'] + >>> + >>> # get labels + >>> label = g.ndata['label'] + + """ + + def __init__( + self, + raw_dir=None, + force_reload=False, + verbose=True, + reverse_edge=True, + transform=None, + reorder=False, + ): + name = "citeseer" + + super(CiteseerGraphDataset, self).__init__( + name, raw_dir, force_reload, verbose, reverse_edge, transform, reorder + ) + + def __getitem__(self, idx): + r"""Gets the graph object + + Parameters + ----------- + idx: int + Item index, CiteseerGraphDataset has only one graph object + + Return + ------ + :class:`dgl.DGLGraph` + + graph structure, node features and labels. + + - ``ndata['train_mask']``: mask for training node set + - ``ndata['val_mask']``: mask for validation node set + - ``ndata['test_mask']``: mask for test node set + - ``ndata['feat']``: node feature + - ``ndata['label']``: ground truth labels + """ + return super(CiteseerGraphDataset, self).__getitem__(idx) + + def __len__(self): + r"""The number of graphs in the dataset.""" + return super(CiteseerGraphDataset, self).__len__() + + +class PubmedGraphDataset(CitationGraphDataset): + r"""Pubmed citation network dataset. + + Nodes mean scientific publications and edges + mean citation relationships. Each node has a + predefined feature with 500 dimensions. The + dataset is designed for the node classification + task. The task is to predict the category of + certain publication. + + Statistics: + + - Nodes: 19717 + - Edges: 88651 + - Number of Classes: 3 + - Label Split: + + - Train: 60 + - Valid: 500 + - Test: 1000 + + Parameters + ----------- + raw_dir : str + Raw file directory to download/contains the input data directory. + Default: ~/.dgl/ + force_reload : bool + Whether to reload the dataset. Default: False + verbose : bool + Whether to print out progress information. Default: True. + reverse_edge : bool + Whether to add reverse edges in graph. Default: True. + transform : callable, optional + A transform that takes in a :class:`~dgl.DGLGraph` object and returns + a transformed version. The :class:`~dgl.DGLGraph` object will be + transformed before every access. + reorder : bool + Whether to reorder the graph using :func:`~dgl.reorder_graph`. Default: False. + + Attributes + ---------- + num_classes: int + Number of label classes + + Notes + ----- + The node feature is row-normalized. + + Examples + -------- + >>> dataset = PubmedGraphDataset() + >>> g = dataset[0] + >>> num_class = dataset.num_of_class + >>> + >>> # get node feature + >>> feat = g.ndata['feat'] + >>> + >>> # get data split + >>> train_mask = g.ndata['train_mask'] + >>> val_mask = g.ndata['val_mask'] + >>> test_mask = g.ndata['test_mask'] + >>> + >>> # get labels + >>> label = g.ndata['label'] + + """ + + def __init__( + self, + raw_dir=None, + force_reload=False, + verbose=True, + reverse_edge=True, + transform=None, + reorder=False, + ): + name = "pubmed" + + super(PubmedGraphDataset, self).__init__( + name, raw_dir, force_reload, verbose, reverse_edge, transform, reorder + ) + + def __getitem__(self, idx): + r"""Gets the graph object + + Parameters + ----------- + idx: int + Item index, PubmedGraphDataset has only one graph object + + Return + ------ + :class:`dgl.DGLGraph` + + graph structure, node features and labels. + + - ``ndata['train_mask']``: mask for training node set + - ``ndata['val_mask']``: mask for validation node set + - ``ndata['test_mask']``: mask for test node set + - ``ndata['feat']``: node feature + - ``ndata['label']``: ground truth labels + """ + return super(PubmedGraphDataset, self).__getitem__(idx) + + def __len__(self): + r"""The number of graphs in the dataset.""" + return super(PubmedGraphDataset, self).__len__() + + +def load_cora( + raw_dir=None, force_reload=False, verbose=True, reverse_edge=True, transform=None +): + """Get CoraGraphDataset + + Parameters + ----------- + raw_dir : str + Raw file directory to download/contains the input data directory. + Default: ~/.dgl/ + force_reload : bool + Whether to reload the dataset. Default: False + verbose : bool + Whether to print out progress information. Default: True. + reverse_edge : bool + Whether to add reverse edges in graph. Default: True. + transform : callable, optional + A transform that takes in a :class:`~dgl.DGLGraph` object and returns + a transformed version. The :class:`~dgl.DGLGraph` object will be + transformed before every access. + + Return + ------- + CoraGraphDataset + """ + data = CoraGraphDataset(raw_dir, force_reload, verbose, reverse_edge, transform) + return data + + +def load_citeseer( + raw_dir=None, force_reload=False, verbose=True, reverse_edge=True, transform=None +): + """Get CiteseerGraphDataset + + Parameters + ----------- + raw_dir : str + Raw file directory to download/contains the input data directory. + Default: ~/.dgl/ + force_reload : bool + Whether to reload the dataset. Default: False + verbose : bool + Whether to print out progress information. Default: True. + reverse_edge : bool + Whether to add reverse edges in graph. Default: True. + transform : callable, optional + A transform that takes in a :class:`~dgl.DGLGraph` object and returns + a transformed version. The :class:`~dgl.DGLGraph` object will be + transformed before every access. + + Return + ------- + CiteseerGraphDataset + """ + data = CiteseerGraphDataset(raw_dir, force_reload, verbose, reverse_edge, transform) + return data + + +def load_pubmed( + raw_dir=None, force_reload=False, verbose=True, reverse_edge=True, transform=None +): + """Get PubmedGraphDataset + + Parameters + ----------- + raw_dir : str + Raw file directory to download/contains the input data directory. + Default: ~/.dgl/ + force_reload : bool + Whether to reload the dataset. Default: False + verbose : bool + Whether to print out progress information. Default: True. + reverse_edge : bool + Whether to add reverse edges in graph. Default: True. + transform : callable, optional + A transform that takes in a :class:`~dgl.DGLGraph` object and returns + a transformed version. The :class:`~dgl.DGLGraph` object will be + transformed before every access. + + Return + ------- + PubmedGraphDataset + """ + data = PubmedGraphDataset(raw_dir, force_reload, verbose, reverse_edge, transform) + return data + + +class CoraBinary(EasyGraphBuiltinDataset): + """A mini-dataset for binary classification task using Cora. + + After loaded, it has following members: + + graphs : list of :class:`~dgl.DGLGraph` + pmpds : list of :class:`scipy.sparse.coo_matrix` + labels : list of :class:`numpy.ndarray` + + Parameters + ----------- + raw_dir : str + Raw file directory to download/contains the input data directory. + Default: ~/.dgl/ + force_reload : bool + Whether to reload the dataset. Default: False + verbose: bool + Whether to print out progress information. Default: True. + transform : callable, optional + A transform that takes in a :class:`~dgl.DGLGraph` object and returns + a transformed version. The :class:`~dgl.DGLGraph` object will be + transformed before every access. + """ + + def __init__(self, raw_dir=None, force_reload=False, verbose=True, transform=None): + name = "cora_binary" + url = _get_dgl_url("dataset/cora_binary.zip") + super(CoraBinary, self).__init__( + name, + url=url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def process(self): + root = self.raw_path + # load graphs + self.graphs = [] + with open("{}/graphs.txt".format(root), "r") as f: + elist = [] + for line in f.readlines(): + if line.startswith("graph"): + if len(elist) != 0: + self.graphs.append(Graph(elist)) + elist = [] + else: + u, v = line.strip().split(" ") + elist.append((int(u), int(v))) + if len(elist) != 0: + self.graphs.append(Graph(tuple(zip(*elist)))) + with open("{}/pmpds.pkl".format(root), "rb") as f: + self.pmpds = _pickle_load(f) + self.labels = [] + with open("{}/labels.txt".format(root), "r") as f: + cur = [] + for line in f.readlines(): + if line.startswith("graph"): + if len(cur) != 0: + self.labels.append(np.asarray(cur)) + cur = [] + else: + cur.append(int(line.strip())) + if len(cur) != 0: + self.labels.append(np.asarray(cur)) + # sanity check + assert len(self.graphs) == len(self.pmpds) + assert len(self.graphs) == len(self.labels) + + def has_cache(self): + graph_path = os.path.join(self.save_path, self.save_name + ".bin") + if os.path.exists(graph_path): + return True + + return False + + # def save(self): + # """save the graph list and the labels""" + # graph_path = os.path.join(self.save_path, + # self.save_name + '.bin') + # labels = {} + # for i, label in enumerate(self.labels): + # labels['{}'.format(i)] = F.tensor(label) + # save_graphs(str(graph_path), self.graphs, labels) + # if self.verbose: + # print('Done saving data into cached files.') + # + # def load(self): + # graph_path = os.path.join(self.save_path, + # self.save_name + '.bin') + # self.graphs, labels = load_graphs(str(graph_path)) + # + # self.labels = [] + # for i in range(len(labels)): + # self.labels.append(F.asnumpy(labels['{}'.format(i)])) + # # load pmpds under self.raw_path + # with open("{}/pmpds.pkl".format(self.raw_path), 'rb') as f: + # self.pmpds = _pickle_load(f) + # if self.verbose: + # print('Done loading data into cached files.') + # # sanity check + # assert len(self.graphs) == len(self.pmpds) + # assert len(self.graphs) == len(self.labels) + + def __len__(self): + return len(self.graphs) + + def __getitem__(self, i): + r"""Gets the idx-th sample. + + Parameters + ----------- + idx : int + The sample index. + + Returns + ------- + (dgl.DGLGraph, scipy.sparse.coo_matrix, int) + The graph, scipy sparse coo_matrix and its label. + """ + if self._transform is None: + g = self.graphs[i] + else: + g = self._transform(self.graphs[i]) + return (g, self.pmpds[i], self.labels[i]) + + @property + def save_name(self): + return self.name + "_dgl_graph" + + # @staticmethod + # def collate_fn(cur): + # graphs, pmpds, labels = zip(*cur) + # batched_graphs = batch.batch(graphs) + # batched_pmpds = sp.block_diag(pmpds) + # batched_labels = np.concatenate(labels, axis=0) + # return batched_graphs, batched_pmpds, batched_labels + + +def _normalize(mx): + """Row-normalize sparse matrix""" + rowsum = np.asarray(mx.sum(1)) + r_inv = np.power(rowsum, -1).flatten() + r_inv[np.isinf(r_inv)] = 0.0 + r_mat_inv = sp.diags(r_inv) + mx = r_mat_inv.dot(mx) + return mx + + +def _encode_onehot(labels): + classes = list(sorted(set(labels))) + classes_dict = {c: np.identity(len(classes))[i, :] for i, c in enumerate(classes)} + labels_onehot = np.asarray(list(map(classes_dict.get, labels)), dtype=np.int32) + return labels_onehot diff --git a/easygraph/datasets/coauthor.py b/easygraph/datasets/coauthor.py new file mode 100644 index 0000000..fe90f73 --- /dev/null +++ b/easygraph/datasets/coauthor.py @@ -0,0 +1,118 @@ +"""CoauthorCS Dataset + +This dataset contains a co-authorship network of authors who submitted papers to CS category. +Each node represents an author and edges represent co-authorships. +Node features are bag-of-words representations of keywords in the author's papers. +The task is node classification, with labels indicating the primary field of study. + +Statistics: +- Nodes: 18333 +- Edges: 81894 +- Feature Dim: 6805 +- Classes: 15 + +Source: https://github.com/dmlc/dgl/tree/master/examples/pytorch/cluster_gcn +""" + +import os + +import easygraph as eg +import numpy as np +import scipy.sparse as sp + +from easygraph.classes.graph import Graph + +from .graph_dataset_base import EasyGraphBuiltinDataset +from .utils import data_type_dict +from .utils import download +from .utils import extract_archive +from .utils import tensor + + +class CoauthorCSDataset(EasyGraphBuiltinDataset): + r"""CoauthorCS citation network dataset. + + Nodes are authors, and edges indicate co-authorship relationships. Each node + has a bag-of-words feature vector and a label denoting the primary research field. + + Parameters + ---------- + raw_dir : str, optional + Directory to store the raw downloaded files. Default: None + force_reload : bool, optional + Whether to re-download and process the dataset. Default: False + verbose : bool, optional + Whether to print detailed processing logs. Default: True + transform : callable, optional + Transform to apply to the graph on access. + + Examples + -------- + >>> from easygraph.datasets import CoauthorCSDataset + >>> dataset = CoauthorCSDataset() + >>> g = dataset[0] + >>> print("Nodes:", g.number_of_nodes()) + >>> print("Edges:", g.number_of_edges()) + >>> print("Feature shape:", g.nodes[0]['feat'].shape) + >>> print("Label:", g.nodes[0]['label']) + >>> print("Number of classes:", dataset.num_classes) + """ + + def __init__(self, raw_dir=None, force_reload=False, verbose=True, transform=None): + name = "coauthor_cs" + url = "https://data.dgl.ai/dataset/coauthor_cs.zip" + super(CoauthorCSDataset, self).__init__( + name=name, + url=url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def process(self): + path = os.path.join(self.raw_path, "coauthor_cs.npz") + data = np.load(path) + + # Reconstruct adjacency matrix + adj = sp.csr_matrix( + (data["adj_data"], data["adj_indices"], data["adj_indptr"]), + shape=data["adj_shape"], + ) + + # Reconstruct feature matrix + features = sp.csr_matrix( + (data["attr_data"], data["attr_indices"], data["attr_indptr"]), + shape=data["attr_shape"], + ).todense() + + labels = data["labels"] + + g = eg.Graph() + g.add_edges_from(list(zip(*adj.nonzero()))) + + for i in range(features.shape[0]): + g.add_node(i, feat=np.array(features[i]).squeeze(), label=int(labels[i])) + + self._g = g + self._num_classes = len(np.unique(labels)) + + if self.verbose: + print("Finished loading CoauthorCS dataset.") + print(f" NumNodes: {g.number_of_nodes()}") + print(f" NumEdges: {g.number_of_edges()}") + print(f" NumFeats: {features.shape[1]}") + print(f" NumClasses: {self._num_classes}") + + def __getitem__(self, idx): + assert idx == 0, "CoauthorCSDataset only contains one graph" + if self._g is None: + raise ValueError("Graph has not been loaded or processed correctly.") + return self._g if self._transform is None else self._transform(self._g) + + def __len__(self): + return 1 + + @property + def num_classes(self): + return self._num_classes diff --git a/easygraph/datasets/dynamic/__init__.py b/easygraph/datasets/dynamic/__init__.py new file mode 100644 index 0000000..6c5e1ba --- /dev/null +++ b/easygraph/datasets/dynamic/__init__.py @@ -0,0 +1,4 @@ +from .email_enron import * +from .email_eu import * +from .hospital_lyon import * +from .load_dataset import * diff --git a/easygraph/datasets/dynamic/email_enron.py b/easygraph/datasets/dynamic/email_enron.py new file mode 100644 index 0000000..0fb24f7 --- /dev/null +++ b/easygraph/datasets/dynamic/email_enron.py @@ -0,0 +1,86 @@ +import json +import os + +from easygraph.convert import dict_to_hypergraph +from easygraph.datasets.dynamic.load_dataset import request_json_from_url +from easygraph.datasets.graph_dataset_base import EasyGraphDataset +from easygraph.datasets.utils import _get_eg_url +from easygraph.datasets.utils import tensor + + +class Email_Enron(EasyGraphDataset): + _urls = { + "email-enron": ( + "easygraph-data-email-enron/-/raw/main/email-enron.json?inline=false" + ), + "email-eu": "easygraph-data-email-eu/-/raw/main/email-eu.json?inline=false", + } + + def __init__( + self, + raw_dir=None, + force_reload=False, + verbose=True, + transform=None, + save_dir="./", + ): + name = "email-enron" + self.url = _get_eg_url(self._urls[name]) + super(Email_Enron, self).__init__( + name=name, + url=self.url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + save_dir=save_dir, + ) + + @property + def url(self): + return self._url + + @property + def save_name(self): + return self.name + + def __getitem__(self, idx): + assert idx == 0, "This dataset has only one graph" + if self._transform is None: + return self._g + else: + return self._transform(self._g) + + def load(self): + graph_path = os.path.join(self.save_path, self.save_name + ".json") + with open(graph_path, "r") as f: + self.load_data = json.load(f) + + def has_cache(self): + graph_path = os.path.join(self.save_path, self.save_name + ".json") + if os.path.exists(graph_path): + return True + return False + + def download(self): + if self.has_cache(): + self.load() + else: + root = self.raw_dir + data = request_json_from_url(self.url) + with open(os.path.join(root, self.save_name + ".json"), "w") as f: + json.dump(data, f) + self.load_data = data + + def process(self): + """Loads input data from data directory and transfer to target graph for better analysis""" + + self._g, edge_feature_list = dict_to_hypergraph(self.load_data, is_dynamic=True) + + self._g.ndata["hyperedge_feature"] = tensor( + range(1, len(edge_feature_list) + 1) + ) + + @url.setter + def url(self, value): + self._url = value diff --git a/easygraph/datasets/dynamic/email_eu.py b/easygraph/datasets/dynamic/email_eu.py new file mode 100644 index 0000000..51c150e --- /dev/null +++ b/easygraph/datasets/dynamic/email_eu.py @@ -0,0 +1,81 @@ +import json +import os + +from easygraph.convert import dict_to_hypergraph +from easygraph.datasets.dynamic.load_dataset import request_json_from_url +from easygraph.datasets.graph_dataset_base import EasyGraphDataset +from easygraph.datasets.utils import _get_eg_url +from easygraph.datasets.utils import tensor + + +class Email_Eu(EasyGraphDataset): + _urls = { + "email-eu": "easygraph-data-email-eu/-/raw/main/email-eu.json?inline=false", + } + + def __init__( + self, + raw_dir=None, + force_reload=False, + verbose=True, + transform=None, + save_dir="./", + ): + name = "email-eu" + self.url = _get_eg_url(self._urls[name]) + super(Email_Eu, self).__init__( + name=name, + url=self.url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + save_dir=save_dir, + ) + + @property + def url(self): + return self._url + + @property + def save_name(self): + return self.name + + def __getitem__(self, idx): + assert idx == 0, "This dataset has only one graph" + if self._transform is None: + return self._g + else: + return self._transform(self._g) + + def load(self): + graph_path = os.path.join(self.save_path, self.save_name + ".json") + with open(graph_path, "r") as f: + self.load_data = json.load(f) + + def has_cache(self): + graph_path = os.path.join(self.save_path, self.save_name + ".json") + if os.path.exists(graph_path): + return True + return False + + def download(self): + if self.has_cache(): + self.load() + else: + root = self.raw_dir + data = request_json_from_url(self.url) + with open(os.path.join(root, self.save_name + ".json"), "w") as f: + json.dump(data, f) + self.load_data = data + + def process(self): + """Loads input data from data directory and transfer to target graph for better analysis""" + self._g, edge_feature_list = dict_to_hypergraph(self.load_data, is_dynamic=True) + self._g.ndata["hyperedge_feature"] = tensor( + range(1, len(edge_feature_list) + 1) + ) + + @url.setter + def url(self, value): + self._url = value diff --git a/easygraph/datasets/dynamic/hospital_lyon.py b/easygraph/datasets/dynamic/hospital_lyon.py new file mode 100644 index 0000000..e7f9356 --- /dev/null +++ b/easygraph/datasets/dynamic/hospital_lyon.py @@ -0,0 +1,133 @@ +import json +import os + +from easygraph.classes.hypergraph import Hypergraph +from easygraph.datasets.dynamic.load_dataset import request_json_from_url +from easygraph.datasets.graph_dataset_base import EasyGraphDataset +from easygraph.datasets.utils import _get_eg_url +from easygraph.datasets.utils import tensor + + +class Hospital_Lyon(EasyGraphDataset): + _urls = { + "hospital_lyon": ( + "easygraph-data-hospital-lyon/-/raw/main/hospital-lyon.json?ref_type=heads&inline=false" + ), + } + + def __init__( + self, + raw_dir=None, + force_reload=False, + verbose=True, + transform=None, + save_dir="./", + ): + name = "hospital_lyon" + self.url = _get_eg_url(self._urls[name]) + super(Hospital_Lyon, self).__init__( + name=name, + url=self.url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + save_dir=save_dir, + ) + + def preprocess(self, data, max_order=None, is_dynamic=True): + # The index of the nodes in this dataset are not continuous and therefore require special processing + timestamp_lst = list() + node_data = data["node-data"] + node_num = len(node_data) + G = Hypergraph(num_v=node_num) + id = 0 + name_dict = {} + for k, v in data["node-data"].items(): + name_dict[k] = id + v["name"] = k + G.v_property[id] = v + id = id + 1 + e_property_dict = data["edge-data"] + rows = [] + cols = [] + edge_flag_dict = {} + edge_id = 0 + for id, edge in data["edge-dict"].items(): + if max_order and len(edge) > max_order + 1: + continue + + try: + id = int(id) + except ValueError as e: + raise TypeError( + f"Failed to convert the edge with ID {id} to type int." + ) from e + + try: + edge = [name_dict[n] for n in edge] + rows.extend(edge) + cols.extend(len(edge) * [edge_id]) + edge_id += 1 + except ValueError as e: + raise TypeError(f"Failed to convert nodes to type int.") from e + if is_dynamic: + G.add_hyperedges( + e_list=edge, + e_property=e_property_dict[str(id)], + group_name=e_property_dict[str(id)]["timestamp"], + ) + timestamp_lst.append(e_property_dict[str(id)]["timestamp"]) + else: + G.add_hyperedges(e_list=edge, e_property=e_property_dict[str(id)]) + G._rows = rows + G._cols = cols + return G, timestamp_lst + + @property + def url(self): + return self._url + + @property + def save_name(self): + return self.name + + def __getitem__(self, idx): + assert idx == 0, "This dataset has only one graph" + if self._transform is None: + return self._g + else: + return self._transform(self._g) + + def load(self): + graph_path = os.path.join(self.save_path, self.save_name + ".json") + with open(graph_path, "r") as f: + self.load_data = json.load(f) + + def has_cache(self): + graph_path = os.path.join(self.save_path, self.save_name + ".json") + if os.path.exists(graph_path): + return True + return False + + def download(self): + if self.has_cache(): + self.load() + else: + root = self.raw_dir + data = request_json_from_url(self.url) + with open(os.path.join(root, self.save_name + ".json"), "w") as f: + json.dump(data, f) + self.load_data = data + + def process(self): + """Loads input data from data directory and transfer to target graph for better analysis""" + + self._g, edge_feature_list = self.preprocess(self.load_data, is_dynamic=True) + self._g.ndata["hyperedge_feature"] = tensor( + range(1, len(edge_feature_list) + 1) + ) + + @url.setter + def url(self, value): + self._url = value diff --git a/easygraph/datasets/dynamic/load_dataset.py b/easygraph/datasets/dynamic/load_dataset.py new file mode 100644 index 0000000..980356c --- /dev/null +++ b/easygraph/datasets/dynamic/load_dataset.py @@ -0,0 +1,94 @@ +import json +import os + +from warnings import warn + +import requests + +from easygraph.convert import dict_to_hypergraph +from easygraph.utils.exception import EasyGraphError + + +__all__ = [ + "load_dynamic_hypergraph_dataset", +] + +dataset_index_url = "https://gitlab.com/easy-graph/easygraph-data/-/raw/main/dataset_index.json?inline=false" + + +def request_json_from_url(url): + try: + r = requests.get(url) + except requests.ConnectionError: + raise EasyGraphError("Connection Error!") + + if r.ok: + return r.json() + else: + raise EasyGraphError(f"Error: HTTP response {r.status_code}") + + +def _request_from_eg_data(dataset=None, cache=True): + """Request a dataset from eg-data. + + Parameters + ---------- + dataset : str, optional + Dataset name. Valid options are the top-level tags of the + index.json file in the xgi-data repository. If None, prints + the list of available datasets. + cache : bool, optional + Whether or not to cache the output + + Returns + ------- + Data + The requested data loaded from a json file. + + Raises + ------ + EasyGraphError + If the HTTP request is not successful or the dataset does not exist. + + + """ + + index_data = request_json_from_url(dataset_index_url) + + key = dataset.lower() + if key not in index_data: + print("Valid dataset names:") + print(*index_data, sep="\n") + raise EasyGraphError("Must choose a valid dataset name!") + + return request_json_from_url(index_data[key]["url"]) + + +def load_dynamic_hypergraph_dataset( + dataset=None, + local_read=False, + path="", + max_order=None, +): + index_datasets = request_json_from_url(dataset_index_url) + if dataset is None: + print("Please refer to available list") + + print(*index_datasets, sep="\n") + return + + if local_read: + cfp = os.path.join(path, dataset + ".json") + if os.path.exists(cfp): + data = json.load(open(cfp, "r")) + return dict_to_hypergraph(data, max_order=max_order) + else: + warn( + f"No local copy was found at {cfp}. The data is requested " + "from the xgi-data repository instead. To download a local " + "copy, use `download_xgi_data`." + ) + data = _request_from_eg_data(dataset) + return dict_to_hypergraph( + data, max_order=max_order, is_dynamic=index_datasets[dataset]["is_dynamic"] + ) diff --git a/easygraph/datasets/facebook_ego.py b/easygraph/datasets/facebook_ego.py new file mode 100644 index 0000000..33eabf3 --- /dev/null +++ b/easygraph/datasets/facebook_ego.py @@ -0,0 +1,109 @@ +"""Facebook Ego-Net Dataset + +This dataset contains a subset of Facebook’s social network collected from +survey participants in the SNAP EgoNet project. Nodes represent users, and +edges indicate friendship links between them. + +Each ego network is centered on a user and includes their friend connections +and friend-to-friend connections. The `.circles` files contain labeled groups +(i.e., communities) of friends identified by the ego user. + +This version processes all ego-nets as a single undirected graph. Node features +are not provided. Labels (circles) are optional and not included by default. + +Statistics (based on merged graph): +- Nodes: ~4,000+ +- Edges: ~88,000+ +- Features: None +- Classes: None + +Reference: +J. McAuley and J. Leskovec, “Learning to Discover Social Circles in Ego Networks,” +in NIPS, 2012. [https://snap.stanford.edu/data/egonets-Facebook.html] +""" + +import os + +import easygraph as eg + +from easygraph.classes.graph import Graph + +from .graph_dataset_base import EasyGraphBuiltinDataset +from .utils import download +from .utils import extract_archive + + +class FacebookEgoNetDataset(EasyGraphBuiltinDataset): + r"""Facebook Ego-Net social network dataset. + + Each node is a user, and edges represent friendship. The dataset + includes 10 ego networks centered on different users. + + Parameters + ---------- + raw_dir : str, optional + Directory to store the raw downloaded files. Default: None + force_reload : bool, optional + Whether to re-download and process the dataset. Default: False + verbose : bool, optional + Whether to print detailed processing logs. Default: True + transform : callable, optional + Optional transform to apply on the graph. + + Examples + -------- + >>> from easygraph.datasets import FacebookEgoNetDataset + >>> dataset = FacebookEgoNetDataset() + >>> g = dataset[0] + >>> print("Nodes:", g.number_of_nodes()) + >>> print("Edges:", g.number_of_edges()) + """ + + def __init__(self, raw_dir=None, force_reload=False, verbose=True, transform=None): + name = "facebook" + url = "https://snap.stanford.edu/data/facebook.tar.gz" + super(FacebookEgoNetDataset, self).__init__( + name=name, + url=url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def process(self): + parent_dir = os.path.join(self.raw_path, "facebook") + g = eg.Graph() + + # Iterate over all .edges files in the subdirectory + for filename in os.listdir(parent_dir): + if filename.endswith(".edges"): + edge_file = os.path.join(parent_dir, filename) + + with open(edge_file, "r") as f: + for line in f: + u, v = map(int, line.strip().split()) + g.add_edge(u, v) + + self._g = g + self._num_nodes = g.number_of_nodes() + self._num_edges = g.number_of_edges() + + if self.verbose: + print("Finished loading Facebook Ego-Net dataset.") + print(f" NumNodes: {self._num_nodes}") + print(f" NumEdges: {self._num_edges}") + + def __getitem__(self, idx): + assert idx == 0, "FacebookEgoNetDataset only contains one merged graph" + return self._g if self._transform is None else self._transform(self._g) + + def __len__(self): + return 1 + + def download(self): + r"""Automatically download data and extract it.""" + if self.url is not None: + archive_path = os.path.join(self.raw_dir, self.name + ".tar.gz") + download(self.url, path=archive_path) + extract_archive(archive_path, self.raw_path) diff --git a/easygraph/datasets/flickr.py b/easygraph/datasets/flickr.py new file mode 100644 index 0000000..022308a --- /dev/null +++ b/easygraph/datasets/flickr.py @@ -0,0 +1,129 @@ +import json +import os + +import easygraph as eg +import numpy as np +import scipy.sparse as sp + +from easygraph.classes.graph import Graph + +from .graph_dataset_base import EasyGraphBuiltinDataset +from .utils import data_type_dict +from .utils import tensor + + +class FlickrDataset(EasyGraphBuiltinDataset): + r"""Flickr dataset for node classification. + + Nodes are images and edges represent social tags co-occurrence. + Node features are precomputed image embeddings. Labels indicate image categories. + + Statistics: + - Nodes: 89,250 + - Edges: 899,756 + - Classes: 7 + - Feature dim: 500 + + Source: GraphSAINT (https://arxiv.org/abs/1907.04931) + + Parameters + ---------- + raw_dir : str, optional + Custom directory to download the dataset. Default: None (uses standard cache dir). + force_reload : bool, optional + Whether to re-download and reprocess. Default: False. + verbose : bool, optional + Whether to print loading progress. Default: False. + transform : callable, optional + A transform applied to the graph on access. + reorder : bool, optional + Whether to apply graph reordering for locality (requires torch). Default: False. + + Examples + -------- + >>> from easygraph.datasets import FlickrDataset + >>> ds = FlickrDataset(verbose=True) + >>> g = ds[0] + >>> print(g.number_of_nodes(), g.number_of_edges(), ds.num_classes) + >>> print(g.nodes[0]['feat'].shape, g.nodes[0]['label']) + """ + + def __init__( + self, + raw_dir=None, + force_reload=False, + verbose=False, + transform=None, + reorder=False, + ): + name = "flickr" + url = self._get_dgl_url("dataset/flickr.zip") + self._reorder = reorder + super(FlickrDataset, self).__init__( + name=name, + url=url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def process(self): + # Load adjacency + coo = sp.load_npz(os.path.join(self.raw_path, "adj_full.npz")) + g = eg.Graph() + g.add_edges_from(list(zip(*coo.nonzero()))) + + # Load features + feats = np.load(os.path.join(self.raw_path, "feats.npy")) + # Load labels + with open(os.path.join(self.raw_path, "class_map.json")) as f: + class_map = json.load(f) + labels = np.array([class_map[str(i)] for i in range(feats.shape[0])]) + + # Load train/val/test splits + with open(os.path.join(self.raw_path, "role.json")) as f: + role = json.load(f) + train_mask = np.zeros(feats.shape[0], dtype=bool) + train_mask[role["tr"]] = True + val_mask = np.zeros(feats.shape[0], dtype=bool) + val_mask[role["va"]] = True + test_mask = np.zeros(feats.shape[0], dtype=bool) + test_mask[role["te"]] = True + + # Attach node data + for i in range(feats.shape[0]): + g.add_node(i, feat=feats[i].astype(np.float32), label=int(labels[i])) + g.graph["train_mask"] = train_mask + g.graph["val_mask"] = val_mask + g.graph["test_mask"] = test_mask + + self._g = g + self._num_classes = int(labels.max() + 1) + if self.verbose: + print("Loaded Flickr dataset") + print( + f" Nodes: {g.number_of_nodes()}, Edges: {g.number_of_edges()}, Features: {feats.shape[1]}, Classes: {self._num_classes}" + ) + + def __getitem__(self, idx): + assert idx == 0, "FlickrDataset contains only one graph" + g = self._g + # transfer mask info + g.graph["train_mask"] = g.graph.pop("train_mask") + g.graph["val_mask"] = g.graph.pop("val_mask") + g.graph["test_mask"] = g.graph.pop("test_mask") + return self._transform(g) if self._transform else g + + def __len__(self): + return 1 + + @property + def num_classes(self): + return self._num_classes + + @staticmethod + def _get_dgl_url(path): + from .utils import _get_dgl_url + + return _get_dgl_url(path) diff --git a/easygraph/datasets/get_sample_graph.py b/easygraph/datasets/get_sample_graph.py new file mode 100644 index 0000000..dabd8f8 --- /dev/null +++ b/easygraph/datasets/get_sample_graph.py @@ -0,0 +1,210 @@ +import easygraph as eg + + +# import progressbar + + +__all__ = [ + "get_graph_karateclub", + "get_graph_blogcatalog", + "get_graph_youtube", + "get_graph_flickr", +] + + +def get_graph_karateclub(): + """Returns the undirected graph of Karate Club. + + Returns + ------- + get_graph_karateclub : easygraph.Graph + The undirected graph instance of karate club from dataset: + http://vlado.fmf.uni-lj.si/pub/networks/data/Ucinet/UciData.htm + + References + ---------- + .. [1] http://vlado.fmf.uni-lj.si/pub/networks/data/Ucinet/UciData.htm + + """ + all_members = set(range(34)) + club1 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 16, 17, 19, 21} + # club2 = all_members - club1 + + G = eg.Graph(name="Zachary's Karate Club") + for node in all_members: + G.add_node(node + 1) + + zacharydat = """\ +0 1 1 1 1 1 1 1 1 0 1 1 1 1 0 0 0 1 0 1 0 1 0 0 0 0 0 0 0 0 0 1 0 0 +1 0 1 1 0 0 0 1 0 0 0 0 0 1 0 0 0 1 0 1 0 1 0 0 0 0 0 0 0 0 1 0 0 0 +1 1 0 1 0 0 0 1 1 1 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 1 0 +1 1 1 0 0 0 0 1 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +1 0 0 0 0 0 1 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +1 0 0 0 0 0 1 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +1 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 1 +0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +1 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +1 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 +0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 +1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 +1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 0 1 0 0 1 1 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 0 0 0 1 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 1 0 0 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 +0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 1 +0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 +0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0 0 0 0 0 1 1 +0 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 +1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 1 0 0 0 1 1 +0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 1 0 0 1 0 1 0 1 1 0 0 0 0 0 1 1 1 0 1 +0 0 0 0 0 0 0 0 1 1 0 0 0 1 1 1 0 0 1 1 1 0 1 1 0 0 1 1 1 1 1 1 1 0""" + + for row, line in enumerate(zacharydat.split("\n")): + thisrow = [int(b) for b in line.split()] + for col, entry in enumerate(thisrow): + if entry == 1: + G.add_edge(row + 1, col + 1) + + # Add the name of each member's club as a node attribute. + for v in G: + G.nodes[v]["club"] = "Mr. Hi" if v in club1 else "Officer" + return G + + +def get_graph_blogcatalog(): + """Returns the undirected graph of blogcatalog. + + Returns + ------- + get_graph_blogcatalog : easygraph.Graph + The undirected graph instance of blogcatalog from dataset: + https://github.com/phanein/deepwalk/blob/master/example_graphs/blogcatalog.mat + + References + ---------- + .. [1] https://github.com/phanein/deepwalk/blob/master/example_graphs/blogcatalog.mat + + """ + from scipy.io import loadmat + + def sparse2graph(x): + from collections import defaultdict + + G = defaultdict(lambda: set()) + cx = x.tocoo() + for i, j, v in zip(cx.row, cx.col, cx.data): + G[i].add(j) + return {str(k): [str(x) for x in v] for k, v in G.items()} + + mat = loadmat("./samples/blogcatalog.mat") + A = mat["network"] + data = sparse2graph(A) + + G = eg.Graph() + for u in data: + for v in data[u]: + G.add_edge(u, v) + + return G + + +def get_graph_youtube(): + """Returns the undirected graph of Youtube dataset. + + Returns + ------- + get_graph_youtube : easygraph.Graph + The undirected graph instance of Youtube from dataset: + http://socialnetworks.mpi-sws.mpg.de/data/youtube-links.txt.gz + + References + ---------- + .. [1] http://socialnetworks.mpi-sws.mpg.de/data/youtube-links.txt.gz + + """ + import gzip + + from urllib import request + + url = "http://socialnetworks.mpi-sws.mpg.de/data/youtube-links.txt.gz" + zipped_data_path = "./samples/youtube-links.txt.gz" + unzipped_data_path = "./samples/youtube-links.txt" + + # Download .gz file + print("Downloading Youtube dataset...") + request.urlretrieve(url, zipped_data_path, _show_progress) + + # Unzip + unzipped_data = gzip.GzipFile(zipped_data_path) + open(unzipped_data_path, "wb+").write(unzipped_data.read()) + unzipped_data.close() + + # Returns graph + G = eg.Graph() + G.add_edges_from_file(file=unzipped_data_path) + return G + + +def get_graph_flickr(): + """Returns the undirected graph of Flickr dataset. + + Returns + ------- + get_graph_flickr : easygraph.Graph + The undirected graph instance of Flickr from dataset: + http://socialnetworks.mpi-sws.mpg.de/data/flickr-links.txt.gz + + References + ---------- + .. [1] http://socialnetworks.mpi-sws.mpg.de/data/flickr-links.txt.gz + + """ + import gzip + + from urllib import request + + url = "http://socialnetworks.mpi-sws.mpg.de/data/flickr-links.txt.gz" + zipped_data_path = "./samples/flickr-links.txt.gz" + unzipped_data_path = "./samples/flickr-links.txt" + + # Download .gz file + print("Downloading Flickr dataset...") + request.urlretrieve(url, zipped_data_path, _show_progress) + + # Unzip + unzipped_data = gzip.GzipFile(zipped_data_path) + open(unzipped_data_path, "wb+").write(unzipped_data.read()) + unzipped_data.close() + + # Returns graph + G = eg.Graph() + G.add_edges_from_file(file=unzipped_data_path) + return G + + +_pbar = None + + +def _show_progress(block_num, block_size, total_size): + global _pbar + if _pbar is None: + _pbar = progressbar.ProgressBar(maxval=total_size) + _pbar.start() + + downloaded = block_num * block_size + if downloaded < total_size: + _pbar.update(downloaded) + else: + _pbar.finish() + _pbar = None diff --git a/easygraph/datasets/github.py b/easygraph/datasets/github.py new file mode 100644 index 0000000..e0aebda --- /dev/null +++ b/easygraph/datasets/github.py @@ -0,0 +1,125 @@ +"""GitHub Users Social Network Dataset (musae_git) + +This dataset represents a directed social network of GitHub users collected in 2019. +Nodes represent GitHub developers, and a directed edge from user A to user B indicates that A follows B. + +Each node also includes: +- Features: User profile and activity-based features. +- Labels: Developer's project area (e.g., machine learning, web dev, etc.) + +Statistics: +- Nodes: 37,700 +- Edges: 289,003 +- Feature dim: 5,575 +- Classes: 2 + +Reference: +J. Leskovec et al. "SNAP Datasets: Stanford Large Network Dataset Collection", +https://snap.stanford.edu/data/github-social.html +""" + +import csv +import json +import os + +import easygraph as eg +import numpy as np + +from easygraph.classes.graph import Graph + +from .graph_dataset_base import EasyGraphBuiltinDataset +from .utils import download +from .utils import extract_archive + + +class GitHubUsersDataset(EasyGraphBuiltinDataset): + r"""GitHub developers social graph (musae_git). + + Parameters + ---------- + raw_dir : str, optional + Directory to store raw data. Default: None + force_reload : bool, optional + Force re-download and processing. Default: False + verbose : bool, optional + Print processing information. Default: True + transform : callable, optional + Transform to apply to the graph on load. + + Examples + -------- + >>> from easygraph.datasets import GitHubUsersDataset + >>> dataset = GitHubUsersDataset() + >>> g = dataset[0] + >>> print("Nodes:", g.number_of_nodes()) + >>> print("Edges:", g.number_of_edges()) + >>> print("Feature shape:", g.nodes[0]['feat'].shape) + >>> print("Label:", g.nodes[0]['label']) + """ + + def __init__(self, raw_dir=None, force_reload=False, verbose=True, transform=None): + name = "musae_git" + url = "https://snap.stanford.edu/data/git_web_ml.zip" + super(GitHubUsersDataset, self).__init__( + name=name, + url=url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def download(self): + archive = os.path.join(self.raw_dir, self.name + ".zip") + download(self.url, path=archive) + extract_archive(archive, self.raw_path) + + def process(self): + g = eg.DiGraph() + base_path = os.path.join(self.raw_path, "git_web_ml") + + # Load node features + with open(os.path.join(base_path, "musae_git_features.json"), "r") as f: + features = json.load(f) + + # Load labels + labels = {} + with open(os.path.join(base_path, "musae_git_target.csv"), "r") as f: + reader = csv.DictReader(f) + for row in reader: + node_id = int(row["id"]) + labels[node_id] = int(row["ml_target"]) + + # Load edges + with open(os.path.join(base_path, "musae_git_edges.csv"), "r") as f: + reader = csv.DictReader(f) + for row in reader: + u, v = int(row["id_1"]), int(row["id_2"]) + g.add_edge(u, v) + + # Add node attributes + for node_id in g.nodes: + feat = np.array(features[str(node_id)], dtype=np.float32) + label = labels.get(node_id, -1) + g.add_node(node_id, feat=feat, label=label) + + self._g = g + self._num_classes = len(set(labels.values())) + + if self.verbose: + print("Finished loading GitHub Users dataset.") + print(f" NumNodes: {g.number_of_nodes()}") + print(f" NumEdges: {g.number_of_edges()}") + print(f" Feature dim: {feat.shape[0]}") + print(f" NumClasses: {self._num_classes}") + + def __getitem__(self, idx): + assert idx == 0, "GitHubUsersDataset only contains one graph" + return self._g if self._transform is None else self._transform(self._g) + + def __len__(self): + return 1 + + @property + def num_classes(self): + return self._num_classes diff --git a/easygraph/datasets/gnn_benchmark.py b/easygraph/datasets/gnn_benchmark.py new file mode 100644 index 0000000..441800e --- /dev/null +++ b/easygraph/datasets/gnn_benchmark.py @@ -0,0 +1,216 @@ +import os + +import numpy as np +import scipy.sparse as sp + +from easygraph.classes.graph import Graph + +from .graph_dataset_base import EasyGraphBuiltinDataset +from .utils import _get_dgl_url +from .utils import _set_labels +from .utils import data_type_dict +from .utils import tensor + + +__all__ = [ + "AmazonCoBuyComputerDataset", +] + + +class GNNBenchmarkDataset(EasyGraphBuiltinDataset): + r"""Base Class for GNN Benchmark dataset + + Reference: https://github.com/shchur/gnn-benchmark#datasets + """ + + def __init__( + self, name, raw_dir=None, force_reload=False, verbose=True, transform=None + ): + _url = _get_dgl_url("dataset/" + name + ".zip") + super(GNNBenchmarkDataset, self).__init__( + name=name, + url=_url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def process(self): + npz_path = os.path.join(self.raw_path, self.name + ".npz") + g = self._load_npz(npz_path) + # g = transforms.reorder_graph( + # g, node_permute_algo='rcmk', edge_permute_algo='dst', store_ids=False) + self._graph = g + self._data = [g] + self._print_info() + + def has_cache(self): + graph_path = os.path.join(self.save_path, "dgl_graph_v1.bin") + if os.path.exists(graph_path): + return True + return False + + # def save(self): + # graph_path = os.path.join(self.save_path, 'dgl_graph_v1.bin') + # save_graphs(graph_path, self._graph) + # + # def load(self): + # graph_path = os.path.join(self.save_path, 'dgl_graph_v1.bin') + # graphs, _ = load_graphs(graph_path) + # self._graph = graphs[0] + # self._data = [graphs[0]] + # self._print_info() + + def _print_info(self): + if self.verbose: + print(" NumNodes: {}".format(self._graph.number_of_nodes())) + print(" NumEdges: {}".format(2 * self._graph.number_of_edges())) + print(" NumFeats: {}".format(self._graph.ndata["feat"].shape[-1])) + print(" NumbClasses: {}".format(self.num_classes)) + + def _load_npz(self, file_name): + with np.load(file_name, allow_pickle=True) as loader: + loader = dict(loader) + num_nodes = loader["adj_shape"][0] + adj_matrix = sp.csr_matrix( + (loader["adj_data"], loader["adj_indices"], loader["adj_indptr"]), + shape=loader["adj_shape"], + ).tocoo() + + if "attr_data" in loader: + # Attributes are stored as a sparse CSR matrix + attr_matrix = sp.csr_matrix( + ( + loader["attr_data"], + loader["attr_indices"], + loader["attr_indptr"], + ), + shape=loader["attr_shape"], + ).todense() + elif "attr_matrix" in loader: + # Attributes are stored as a (dense) np.ndarray + attr_matrix = loader["attr_matrix"] + else: + attr_matrix = None + + if "labels_data" in loader: + # Labels are stored as a CSR matrix + labels = sp.csr_matrix( + ( + loader["labels_data"], + loader["labels_indices"], + loader["labels_indptr"], + ), + shape=loader["labels_shape"], + ).todense() + elif "labels" in loader: + # Labels are stored as a numpy array + labels = loader["labels"] + else: + labels = None + if hasattr(adj_matrix, "format"): + print("can be generate eg!") + g = Graph(incoming_graph_data=adj_matrix) + # g = transforms.to_bidirected(g) + g = _set_labels(g, labels) + g.ndata["feat"] = tensor(attr_matrix, data_type_dict()["float32"]) + g.ndata["label"] = tensor(labels, data_type_dict()["int64"]) + return g + + @property + def num_classes(self): + """Number of classes.""" + raise NotImplementedError + + def __getitem__(self, idx): + r"""Get graph by index + + Parameters + ---------- + idx : int + Item index + + Returns + ------- + :class:`dgl.DGLGraph` + + The graph contains: + + - ``ndata['feat']``: node features + - ``ndata['label']``: node labels + """ + assert idx == 0, "This dataset has only one graph" + if self._transform is None: + return self._graph + else: + return self._transform(self._graph) + + def __len__(self): + r"""Number of graphs in the dataset""" + return 1 + + +class AmazonCoBuyComputerDataset(GNNBenchmarkDataset): + r"""'Computer' part of the AmazonCoBuy dataset for node classification task. + + Amazon Computers and Amazon Photo are segments of the Amazon co-purchase graph [McAuley et al., 2015], + where nodes represent goods, edges indicate that two goods are frequently bought together, node + features are bag-of-words encoded product reviews, and class labels are given by the product category. + + Reference: ``_ + + Statistics: + + - Nodes: 13,752 + - Edges: 491,722 (note that the original dataset has 245,778 edges but DGL adds + the reverse edges and remove the duplicates, hence with a different number) + - Number of classes: 10 + - Node feature size: 767 + + Parameters + ---------- + raw_dir : str + Raw file directory to download/contains the input data directory. + Default: ~/.dgl/ + force_reload : bool + Whether to reload the dataset. Default: False + verbose : bool + Whether to print out progress information. Default: True. + transform : callable, optional + A transform that takes in a :class:`~dgl.DGLGraph` object and returns + a transformed version. The :class:`~dgl.DGLGraph` object will be + transformed before every access. + + Attributes + ---------- + num_classes : int + Number of classes for each node. + + Examples + -------- + >>> data = AmazonCoBuyComputerDataset() + >>> g = data[0] + >>> num_class = data.num_classes + >>> feat = g.ndata['feat'] # get node feature + >>> label = g.ndata['label'] # get node labels + """ + + def __init__(self, raw_dir=None, force_reload=False, verbose=True, transform=None): + super(AmazonCoBuyComputerDataset, self).__init__( + name="amazon_co_buy_computer", + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + @property + def num_classes(self): + """Number of classes. + + Return + ------- + int + """ + return 10 diff --git a/easygraph/datasets/graph_dataset_base.py b/easygraph/datasets/graph_dataset_base.py new file mode 100644 index 0000000..b1d831b --- /dev/null +++ b/easygraph/datasets/graph_dataset_base.py @@ -0,0 +1,318 @@ +"""Basic EasyGraph Dataset""" + +from __future__ import absolute_import + +import abc +import hashlib +import os +import sys +import traceback + +from ..utils import retry_method_with_fix +from .utils import download +from .utils import extract_archive +from .utils import get_download_dir +from .utils import makedirs + + +class EasyGraphDataset(object): + r"""The basic EasyGraph dataset for creating graph datasets. + This class defines a basic template class for EasyGraph Dataset. + The following steps will be executed automatically: + + 1. Check whether there is a dataset cache on disk + (already processed and stored on the disk) by + invoking ``has_cache()``. If true, goto 5. + 2. Call ``download()`` to download the data if ``url`` is not None. + 3. Call ``process()`` to process the data. + 4. Call ``save()`` to save the processed dataset on disk and goto 6. + 5. Call ``load()`` to load the processed dataset from disk. + 6. Done. + + Users can overwrite these functions with their + own data processing logic. + + Parameters + ---------- + name : str + Name of the dataset + url : str + Url to download the raw dataset. Default: None + raw_dir : str + Specifying the directory that will store the + downloaded data or the directory that + already stores the input data. + Default: ~/.EasyGraphData/ + save_dir : str + Directory to save the processed dataset. + Default: same as raw_dir + hash_key : tuple + A tuple of values as the input for the hash function. + Users can distinguish instances (and their caches on the disk) + from the same dataset class by comparing the hash values. + Default: (), the corresponding hash value is ``'f9065fa7'``. + force_reload : bool + Whether to reload the dataset. Default: False + verbose : bool + Whether to print out progress information + transform : callable, optional + A transform that takes in a :class:`~dgl.DGLGraph` object and returns + a transformed version. The :class:`~dgl.DGLGraph` object will be + transformed before every access. + + """ + + def __init__( + self, + name, + url=None, + raw_dir=None, + save_dir=None, + hash_key=(), + force_reload=False, + verbose=False, + transform=None, + ): + self._name = name + self._url = url + self._force_reload = force_reload + self._verbose = verbose + self._hash_key = hash_key + self._hash = self._get_hash() + self._transform = transform + + # if no dir is provided, the default EasyGraph download dir is used. + if raw_dir is None: + self._raw_dir = get_download_dir() + else: + self._raw_dir = raw_dir + + if save_dir is None: + self._save_dir = self._raw_dir + else: + self._save_dir = save_dir + self._load() + + def download(self): + r"""Overwrite to realize your own logic of downloading data. + + It is recommended to download the to the :obj:`self.raw_dir` + folder. Can be ignored if the dataset is + already in :obj:`self.raw_dir`. + """ + pass + + def save(self): + r"""Overwrite to realize your own logic of + saving the processed dataset into files. + + It is recommended to use ``dgl.data.utils.save_graphs`` + to save dgl graph into files and use + ``dgl.data.utils.save_info`` to save extra + information into files. + """ + pass + + def load(self): + r"""Overwrite to realize your own logic of + loading the saved dataset from files. + + It is recommended to use ``dgl.data.utils.load_graphs`` + to load dgl graph from files and use + ``dgl.data.utils.load_info`` to load extra information + into python dict object. + """ + pass + + @abc.abstractmethod + def process(self): + r"""Overwrite to realize your own logic of processing the input data.""" + pass + + def has_cache(self): + r"""Overwrite to realize your own logic of + deciding whether there exists a cached dataset. + + By default False. + """ + return False + + @retry_method_with_fix(download) + def _download(self): + """Download dataset by calling ``self.download()`` + if the dataset does not exists under ``self.raw_path``. + + By default ``self.raw_path = os.path.join(self.raw_dir, self.name)`` + One can overwrite ``raw_path()`` function to change the path. + """ + + if os.path.exists(self.raw_path): # pragma: no cover + return + + makedirs(self.raw_dir) + self.download() + + def _load(self): + """Entry point from __init__ to load the dataset. + + If cache exists: + + - Load the dataset from saved dgl graph and information files. + - If loading process fails, re-download and process the dataset. + + else: + + - Download the dataset if needed. + - Process the dataset and build the dgl graph. + - Save the processed dataset into files. + """ + + load_flag = not self._force_reload and self.has_cache() + if load_flag: + try: + self.load() + self.process() + if self.verbose: + print("Done loading data from cached files.") + except KeyboardInterrupt: + raise + except: + load_flag = False + if self.verbose: + print(traceback.format_exc()) + print("Loading from cache failed, re-processing.") + + if not load_flag: + self._download() + self.process() + self.save() + if self.verbose: + print("Done saving data into cached files.") + + def _get_hash(self): + """Compute the hash of the input tuple + + Example + ------- + Assume `self._hash_key = (10, False, True)` + + >>> hash_value = self._get_hash() + >>> hash_value + 'a770b222' + """ + hash_func = hashlib.sha1() + hash_func.update(str(self._hash_key).encode("utf-8")) + return hash_func.hexdigest()[:8] + + @property + def url(self): + r"""Get url to download the raw dataset.""" + return self._url + + @property + def name(self): + r"""Name of the dataset.""" + return self._name + + @property + def raw_dir(self): + r"""Raw file directory contains the input data folder.""" + return self._raw_dir + + @property + def raw_path(self): + r"""Directory contains the input data files. + By default raw_path = os.path.join(self.raw_dir, self.name) + """ + return os.path.join(self.raw_dir, self.name) + + @property + def save_dir(self): + r"""Directory to save the processed dataset.""" + return self._save_dir + + @property + def save_path(self): + r"""Path to save the processed dataset.""" + return os.path.join(self._save_dir) + + @property + def verbose(self): + r"""Whether to print information.""" + return self._verbose + + @property + def hash(self): + r"""Hash value for the dataset and the setting.""" + return self._hash + + @abc.abstractmethod + def __getitem__(self, idx): + r"""Gets the data object at index.""" + pass + + @abc.abstractmethod + def __len__(self): + r"""The number of examples in the dataset.""" + pass + + def __repr__(self): + return f'Dataset("{self.name}"' + f" save_path={self.save_path})" + + +class EasyGraphBuiltinDataset(EasyGraphDataset): + r"""The Basic EasyGraph Builtin Dataset. + + Parameters + ---------- + name : str + Name of the dataset. + url : str + Url to download the raw dataset. + raw_dir : str + Specifying the directory that will store the + downloaded data or the directory that + already stores the input data. + Default: ~/.dgl/ + hash_key : tuple + A tuple of values as the input for the hash function. + Users can distinguish instances (and their caches on the disk) + from the same dataset class by comparing the hash values. + force_reload : bool + Whether to reload the dataset. Default: False + verbose : bool + Whether to print out progress information. Default: False + transform : callable, optional + A transform that takes in a :class:`~dgl.DGLGraph` object and returns + a transformed version. The :class:`~dgl.DGLGraph` object will be + transformed before every access. + """ + + def __init__( + self, + name, + url, + raw_dir=None, + hash_key=(), + force_reload=False, + verbose=True, + transform=None, + save_dir=None, + ): + super(EasyGraphBuiltinDataset, self).__init__( + name, + url=url, + raw_dir=raw_dir, + save_dir=save_dir, + hash_key=hash_key, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def download(self): + r"""Automatically download data and extract it.""" + if self.url is not None: + zip_file_path = os.path.join(self.raw_dir, self.name + ".zip") + download(self.url, path=zip_file_path) + extract_archive(zip_file_path, self.raw_path) diff --git a/easygraph/datasets/hypergraph/House_Committees.py b/easygraph/datasets/hypergraph/House_Committees.py new file mode 100644 index 0000000..70acb93 --- /dev/null +++ b/easygraph/datasets/hypergraph/House_Committees.py @@ -0,0 +1,216 @@ +import requests + +from easygraph.utils.exception import EasyGraphError + + +def request_text_from_url(url): + """ + Requests text data from the specified URL. + + Args: + url (str): The URL from which to request the text data. + + Returns: + str: The text content of the response if the request is successful. + + Raises: + EasyGraphError: If a connection error occurs during the request or if the HTTP response status code + indicates a failure. + """ + try: + r = requests.get(url) + except requests.ConnectionError: + raise EasyGraphError("Connection Error!") + + if r.ok: + return r.text + else: + raise EasyGraphError(f"Error: HTTP response {r.status_code}") + + +class House_Committees: + """ + A class for loading and processing the House Committees hypergraph dataset. + + This class fetches hyperedge, node label, node name, and label name data from predefined URLs, + processes the data, and generates a hypergraph representation. It also provides access to various + dataset attributes through properties and indexing. + + Attributes: + data_root (str): The root URL for the data. If `data_root` is provided during initialization, + it is set to "https://"; otherwise, it is `None`. + hyperedges_path (str): The URL of the file containing hyperedge information. + node_labels_path (str): The URL of the file containing node label information. + node_names_path (str): The URL of the file containing node name information. + label_names_path (str): The URL of the file containing label name information. + _hyperedges (list): A list of tuples representing hyperedges. + _node_labels (list): A list of node labels. + _label_names (list): A list of label names. + _node_names (list): A list of node names. + _content (dict): A dictionary containing dataset statistics and data, including the number of + classes, vertices, edges, the edge list, and node labels. + """ + + def __init__(self, data_root=None): + """ + Initializes a new instance of the `House_Committees` class. + + Args: + data_root (str, optional): The root URL for the data. If provided, it is set to "https://"; + otherwise, it is `None`. Defaults to `None`. + """ + self.data_root = "https://" if data_root is not None else data_root + self.hyperedges_path = "https://gitlab.com/easy-graph/easygraph-data-house-committees/-/raw/main/hyperedges-house-committees.txt?inline=false" + self.node_labels_path = "https://gitlab.com/easy-graph/easygraph-data-house-committees/-/raw/main/node-labels-house-committees.txt?ref_type=heads&inline=false" + self.node_names_path = "https://gitlab.com/easy-graph/easygraph-data-house-committees/-/raw/main/node-names-house-committees.txt?ref_type=heads&inline=false" + self.label_names_path = "https://gitlab.com/easy-graph/easygraph-data-house-committees/-/raw/main/label-names-house-committees.txt?ref_type=heads&inline=false" + self._hyperedges = [] + self._node_labels = [] + self._label_names = [] + self._node_names = [] + self.generate_hypergraph( + hyperedges_path=self.hyperedges_path, + node_labels_path=self.node_labels_path, + node_names_path=self.node_names_path, + label_names_path=self.label_names_path, + ) + + self._content = { + "num_classes": len(self._label_names), + "num_vertices": len(self._node_labels), + "num_edges": len(self._hyperedges), + "edge_list": self._hyperedges, + "labels": self._node_labels, + } + + def process_label_txt(self, data_str, delimiter="\n", transform_fun=str): + """ + Processes a string containing label data into a list of transformed values. + + Args: + data_str (str): The input string containing label data. + delimiter (str, optional): The delimiter used to split the input string. Defaults to "\n". + transform_fun (callable, optional): A function used to transform each label value. + Defaults to the `str` function. + + Returns: + list: A list of transformed label values. + """ + data_str = data_str.strip() + data_lst = data_str.split(delimiter) + final_lst = [] + for data in data_lst: + data = data.strip() + data = transform_fun(data) + final_lst.append(data) + return final_lst + + def __getitem__(self, key: str): + """ + Retrieves a value from the `_content` dictionary using the specified key. + + Args: + key (str): The key used to access the `_content` dictionary. + + Returns: + Any: The value corresponding to the key in the `_content` dictionary. + """ + return self._content[key] + + @property + def node_labels(self): + """ + Gets the list of node labels. + + Returns: + list: A list of node labels. + """ + return self._node_labels + + @property + def node_names(self): + """ + Gets the list of node names. + + Returns: + list: A list of node names. + """ + return self._node_names + + @property + def label_names(self): + """ + Gets the list of label names. + + Returns: + list: A list of label names. + """ + return self._label_names + + @property + def hyperedges(self): + """ + Gets the list of hyperedges. + + Returns: + list: A list of tuples representing hyperedges. + """ + return self._hyperedges + + def generate_hypergraph( + self, + hyperedges_path=None, + node_labels_path=None, + node_names_path=None, + label_names_path=None, + ): + """ + Generates a hypergraph by fetching and processing data from the specified URLs. + + Args: + hyperedges_path (str, optional): The URL of the file containing hyperedge information. + Defaults to `None`. + node_labels_path (str, optional): The URL of the file containing node label information. + Defaults to `None`. + node_names_path (str, optional): The URL of the file containing node name information. + Defaults to `None`. + label_names_path (str, optional): The URL of the file containing label name information. + Defaults to `None`. + """ + + def fun(data): + """ + Converts a string to an integer and subtracts 1. + + Args: + data (str): The input string to be converted. + + Returns: + int: The converted integer value minus 1. + """ + data = int(data) - 1 + return data + + hyperedges_info = request_text_from_url(hyperedges_path) + hyperedges_info = hyperedges_info.strip() + hyperedges_lst = hyperedges_info.split("\n") + for hyperedge in hyperedges_lst: + hyperedge = hyperedge.strip() + hyperedge = [int(i) - 1 for i in hyperedge.split(",")] + self._hyperedges.append(tuple(hyperedge)) + # print(self.hyperedges) + + node_labels_info = request_text_from_url(node_labels_path) + + process_node_labels_info = self.process_label_txt( + node_labels_info, transform_fun=fun + ) + self._node_labels = process_node_labels_info + # print("process_node_labels_info:", process_node_labels_info) + node_names_info = request_text_from_url(node_names_path) + process_node_names_info = self.process_label_txt(node_names_info) + self._node_names = process_node_names_info + # print("process_node_names_info:", process_node_names_info) + label_names_info = request_text_from_url(label_names_path) + process_label_names_info = self.process_label_txt(label_names_info) + self._label_names = process_label_names_info diff --git a/easygraph/datasets/hypergraph/Yelp.py b/easygraph/datasets/hypergraph/Yelp.py new file mode 100644 index 0000000..2257d7d --- /dev/null +++ b/easygraph/datasets/hypergraph/Yelp.py @@ -0,0 +1,82 @@ +from typing import Optional + +from easygraph.datapipe import load_from_pickle +from easygraph.datapipe import to_long_tensor +from easygraph.datapipe import to_tensor +from easygraph.datasets.hypergraph.hypergraph_dataset_base import BaseData + + +class YelpRestaurant(BaseData): + r"""The Yelp-Restaurant dataset is a restaurant-review network dataset for node classification task. + + More details see the DHG or `YOU ARE ALLSET: A MULTISET LEARNING FRAMEWORK FOR HYPERGRAPH NEURAL NETWORKS `_ paper. + + The content of the Yelp-Restaurant dataset includes the following: + + - ``num_classes``: The number of classes: :math:`11`. + - ``num_vertices``: The number of vertices: :math:`50,758`. + - ``num_edges``: The number of edges: :math:`679,302`. + - ``dim_features``: The dimension of features: :math:`1,862`. + - ``features``: The vertex feature matrix. ``torch.Tensor`` with size :math:`(50,758 \times 1,862)`. + - ``edge_list``: The edge list. ``List`` with length :math:`679,302`. + - ``labels``: The label list. ``torch.LongTensor`` with size :math:`(50,758, )`. + - ``state``: The state list. ``torch.LongTensor`` with size :math:`(50,758, )`. + - ``city``: The city list. ``torch.LongTensor`` with size :math:`(50,758, )`. + + Args: + ``data_root`` (``str``, optional): The ``data_root`` has stored the data. If set to ``None``, this function will auto-download from server and save into the default direction ``~/.dhg/datasets/``. Defaults to ``None``. + """ + + def __init__(self, data_root: Optional[str] = None) -> None: + super().__init__("yelp_restaurant", data_root) + self._content = { + "num_classes": 11, + "num_vertices": 50758, + "num_edges": 679302, + "dim_features": 1862, + "features": { + "upon": [ + { + "filename": "features.pkl", + "md5": "cedc4443884477c2e626025411c44cd7", + } + ], + "loader": load_from_pickle, + "preprocess": [ + to_tensor, + ], + }, + "edge_list": { + "upon": [ + { + "filename": "edge_list.pkl", + "md5": "4b26eecaa22305dd10edcd6372eb49da", + } + ], + "loader": load_from_pickle, + }, + "labels": { + "upon": [ + { + "filename": "labels.pkl", + "md5": "1cdc1ed9fb1f57b2accaa42db214d4ef", + } + ], + "loader": load_from_pickle, + "preprocess": [to_long_tensor], + }, + "state": { + "upon": [ + {"filename": "state.pkl", "md5": "eef3b835fad37409f29ad36539296b57"} + ], + "loader": load_from_pickle, + "preprocess": [to_long_tensor], + }, + "city": { + "upon": [ + {"filename": "city.pkl", "md5": "8302b167262b23067698e865cacd0b17"} + ], + "loader": load_from_pickle, + "preprocess": [to_long_tensor], + }, + } diff --git a/easygraph/datasets/hypergraph/__init__.py b/easygraph/datasets/hypergraph/__init__.py new file mode 100644 index 0000000..b03a3e7 --- /dev/null +++ b/easygraph/datasets/hypergraph/__init__.py @@ -0,0 +1,10 @@ +from .cat_edge_Cooking import * +from .coauthorship import * +from .cocitation import * +from .contact_primary_school import * +from .House_Committees import * +from .mathoverflow_answers import * +from .senate_committees import * +from .trivago_clicks import * +from .walmart_trips import * +from .Yelp import * diff --git a/easygraph/datasets/hypergraph/_global.py b/easygraph/datasets/hypergraph/_global.py new file mode 100644 index 0000000..927ef81 --- /dev/null +++ b/easygraph/datasets/hypergraph/_global.py @@ -0,0 +1,14 @@ +from pathlib import Path + + +def get_eg_cache_root(): + root = Path.home() / Path(".easygraph/") + root.mkdir(parents=True, exist_ok=True) + return root + + +CACHE_ROOT = get_eg_cache_root() +DATASETS_ROOT = CACHE_ROOT / "datasets" + +REMOTE_ROOT = "https://download.moon-lab.tech:28501/" +REMOTE_DATASETS_ROOT = REMOTE_ROOT + "datasets/" diff --git a/easygraph/datasets/hypergraph/cat_edge_Cooking.py b/easygraph/datasets/hypergraph/cat_edge_Cooking.py new file mode 100644 index 0000000..79cde0a --- /dev/null +++ b/easygraph/datasets/hypergraph/cat_edge_Cooking.py @@ -0,0 +1,104 @@ +import requests + +from easygraph.utils.exception import EasyGraphError + + +def request_text_from_url(url): + try: + r = requests.get(url) + except requests.ConnectionError: + raise EasyGraphError("Connection Error!") + + if r.ok: + return r.text + else: + raise EasyGraphError(f"Error: HTTP response {r.status_code}") + + +class cat_edge_Cooking: + def __init__(self, data_root=None): + self.data_root = "https://" if data_root is not None else data_root + self.hyperedges_path = "https://gitlab.com/easy-graph/easygraph-data-cat-edge-cooking/-/raw/main/hyperedges.txt?inline=false" + self.edge_labels_path = "https://gitlab.com/easy-graph/easygraph-data-cat-edge-cooking/-/raw/main/hyperedge-labels.txt?ref_type=heads&inline=false" + self.node_names_path = "https://gitlab.com/easy-graph/easygraph-data-cat-edge-cooking/-/raw/main/main/node-labels.txt?ref_type=heads&inline=false" + self.label_names_path = "https://gitlab.com/easy-graph/easygraph-data-cat-edge-cooking/-/raw/main/hyperedge-label-identities.txt?ref_type=heads&inline=false" + # self.hyperedges_path = [] + # self.edge_labels_path = [] + # self.node_names_path = [] + # self.label_names_path = [] + self.generate_hypergraph( + hyperedges_path=self.hyperedges_path, + edge_labels_path=self.edge_labels_path, + node_names_path=self.node_names_path, + label_names_path=self.label_names_path, + ) + self._content = { + "num_classes": len(self._label_names), + "num_vertices": len(self._node_labels), + "num_edges": len(self._hyperedges), + "edge_list": self._hyperedges, + "labels": self._node_labels, + } + + def __getitem__(self, key: str): + return self._content[key] + + def process_label_txt(self, data_str, delimiter="\n", transform_fun=str): + data_str = data_str.strip() + data_lst = data_str.split(delimiter) + final_lst = [] + for data in data_lst: + data = data.strip() + data = transform_fun(data) + final_lst.append(data) + return final_lst + + @property + def edge_labels(self): + return self._edge_labels + + @property + def node_names(self): + return self._node_names + + @property + def label_names(self): + return self._label_names + + @property + def hyperedges(self): + return self._hyperedges + + def generate_hypergraph( + self, + hyperedges_path=None, + node_labels_path=None, + node_names_path=None, + label_names_path=None, + ): + def fun(data): + data = int(data) - 1 + return data + + hyperedges_info = request_text_from_url(hyperedges_path) + hyperedges_info = hyperedges_info.strip() + hyperedges_lst = hyperedges_info.split("\n") + for hyperedge in hyperedges_lst: + hyperedge = hyperedge.strip() + hyperedge = [int(i) - 1 for i in hyperedge.split(" ")] + self._hyperedges.append(tuple(hyperedge)) + # print(self.hyperedges) + + edge_labels_info = request_text_from_url(self.edge_labels_path) + process_node_labels_info = self.process_label_txt( + node_labels_info, transform_fun=fun + ) + self._edge_labels = process_edge_labels_info() + + node_names_info = request_text_from_url(node_names_path) + process_node_names_info = self.process_label_txt(node_names_info) + self._node_names = process_node_names_info + + label_names_info = request_text_from_url(label_names_path) + process_label_names_info = self.process_label_txt(label_names_info) + self._label_names = process_label_names_info diff --git a/easygraph/datasets/hypergraph/coauthorship.py b/easygraph/datasets/hypergraph/coauthorship.py new file mode 100644 index 0000000..8dec6eb --- /dev/null +++ b/easygraph/datasets/hypergraph/coauthorship.py @@ -0,0 +1,192 @@ +from functools import partial +from typing import Optional + +from easygraph.datapipe import load_from_pickle +from easygraph.datapipe import norm_ft +from easygraph.datapipe import to_bool_tensor +from easygraph.datapipe import to_long_tensor +from easygraph.datapipe import to_tensor +from easygraph.datasets.hypergraph.hypergraph_dataset_base import BaseData + + +__all__ = ["CoauthorshipCora", "CoauthorshipDBLP"] + + +class CoauthorshipCora(BaseData): + r"""The Co-authorship Cora dataset is a citation network dataset for vertex classification task. + More details see the `HyperGCN `_ paper. + + The content of the Co-authorship Cora dataset includes the following: + + - ``num_classes``: The number of classes: :math:`7`. + - ``num_vertices``: The number of vertices: :math:`2,708`. + - ``num_edges``: The number of edges: :math:`1,072`. + - ``dim_features``: The dimension of features: :math:`1,433`. + - ``features``: The vertex feature matrix. ``torch.Tensor`` with size :math:`(2,708 \times 1,433)`. + - ``edge_list``: The edge list. ``List`` with length :math:`1,072`. + - ``labels``: The label list. ``torch.LongTensor`` with size :math:`(2,708, )`. + - ``train_mask``: The train mask. ``torch.BoolTensor`` with size :math:`(2,708, )`. + - ``val_mask``: The validation mask. ``torch.BoolTensor`` with size :math:`(2,708, )`. + - ``test_mask``: The test mask. ``torch.BoolTensor`` with size :math:`(2,708, )`. + + Args: + ``data_root`` (``str``, optional): The ``data_root`` has stored the data. If set to ``None``, this function will auto-download from server and save into the default direction ``~/.dhg/datasets/``. Defaults to ``None``. + """ + + def __init__(self, data_root: Optional[str] = None) -> None: + super().__init__("coauthorship_cora", data_root) + self._content = { + "num_classes": 7, + "num_vertices": 2708, + "num_edges": 1072, + "dim_features": 1433, + "features": { + "upon": [ + { + "filename": "features.pkl", + "md5": "14257c0e24b4eb741b469a351e524785", + } + ], + "loader": load_from_pickle, + "preprocess": [to_tensor, partial(norm_ft, ord=1)], + }, + "edge_list": { + "upon": [ + { + "filename": "edge_list.pkl", + "md5": "a17ff337f1b9099f5a9d4d670674e146", + } + ], + "loader": load_from_pickle, + }, + "labels": { + "upon": [ + { + "filename": "labels.pkl", + "md5": "c8d11c452e0be69f79a47dd839279117", + } + ], + "loader": load_from_pickle, + "preprocess": [to_long_tensor], + }, + "train_mask": { + "upon": [ + { + "filename": "train_mask.pkl", + "md5": "111db6c6f986be2908378df7bdca7a9b", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + "val_mask": { + "upon": [ + { + "filename": "val_mask.pkl", + "md5": "ffab1055193ffb2fe74822bb575d332a", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + "test_mask": { + "upon": [ + { + "filename": "test_mask.pkl", + "md5": "ffab1055193ffb2fe74822bb575d332a", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + } + + +class CoauthorshipDBLP(BaseData): + r"""The Co-authorship DBLP dataset is a citation network dataset for vertex classification task. + More details see the `HyperGCN `_ paper. + + The content of the Co-authorship DBLP dataset includes the following: + + - ``num_classes``: The number of classes: :math:`6`. + - ``num_vertices``: The number of vertices: :math:`41,302`. + - ``num_edges``: The number of edges: :math:`22,363`. + - ``dim_features``: The dimension of features: :math:`1,425`. + - ``features``: The vertex feature matrix. ``torch.Tensor`` with size :math:`(41,302 \times 1,425)`. + - ``edge_list``: The edge list. ``List`` with length :math:`22,363`. + - ``labels``: The label list. ``torch.LongTensor`` with size :math:`(41,302, )`. + - ``train_mask``: The train mask. ``torch.BoolTensor`` with size :math:`(41,302, )`. + - ``val_mask``: The validation mask. ``torch.BoolTensor`` with size :math:`(41,302, )`. + - ``test_mask``: The test mask. ``torch.BoolTensor`` with size :math:`(41,302, )`. + + Args: + ``data_root`` (``str``, optional): The ``data_root`` has stored the data. If set to ``None``, this function will auto-download from server and save into the default direction ``~/.dhg/datasets/``. Defaults to None. + """ + + def __init__(self, data_root: Optional[str] = None) -> None: + super().__init__("coauthorship_dblp", data_root) + self._content = { + "num_classes": 6, + "num_vertices": 41302, + "num_edges": 22363, + "dim_features": 1425, + "features": { + "upon": [ + { + "filename": "features.pkl", + "md5": "b78fd31b2586d1e19a40b3f6cd9cc2e7", + } + ], + "loader": load_from_pickle, + "preprocess": [to_tensor, partial(norm_ft, ord=1)], + }, + "edge_list": { + "upon": [ + { + "filename": "edge_list.pkl", + "md5": "c6bf5f9f3b9683bcc9b7bcc9eb8707d8", + } + ], + "loader": load_from_pickle, + }, + "labels": { + "upon": [ + { + "filename": "labels.pkl", + "md5": "2e7a792ea018028d582af8f02f2058ca", + } + ], + "loader": load_from_pickle, + "preprocess": [to_long_tensor], + }, + "train_mask": { + "upon": [ + { + "filename": "train_mask.pkl", + "md5": "a842b795c7cac4c2f98a56cf599bc1de", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + "val_mask": { + "upon": [ + { + "filename": "val_mask.pkl", + "md5": "2ec4b7df7c5e6b355067a22c391ad578", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + "test_mask": { + "upon": [ + { + "filename": "test_mask.pkl", + "md5": "2ec4b7df7c5e6b355067a22c391ad578", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + } diff --git a/easygraph/datasets/hypergraph/cocitation.py b/easygraph/datasets/hypergraph/cocitation.py new file mode 100644 index 0000000..865f642 --- /dev/null +++ b/easygraph/datasets/hypergraph/cocitation.py @@ -0,0 +1,279 @@ +from functools import partial +from typing import Optional + +from easygraph.datapipe import load_from_pickle +from easygraph.datapipe import norm_ft +from easygraph.datapipe import to_bool_tensor +from easygraph.datapipe import to_long_tensor +from easygraph.datapipe import to_tensor +from easygraph.datasets.hypergraph.hypergraph_dataset_base import BaseData + + +class CocitationCora(BaseData): + r"""The Co-citation Cora dataset is a citation network dataset for vertex classification task. + More details see the `HyperGCN `_ paper. + + The content of the Co-citation Cora dataset includes the following: + + - ``num_classes``: The number of classes: :math:`7`. + - ``num_vertices``: The number of vertices: :math:`2,708`. + - ``num_edges``: The number of edges: :math:`1,579`. + - ``dim_features``: The dimension of features: :math:`1,433`. + - ``features``: The vertex feature matrix. ``torch.Tensor`` with size :math:`(2,708 \times 1,433)`. + - ``edge_list``: The edge list. ``List`` with length :math:`1,579`. + - ``labels``: The label list. ``torch.LongTensor`` with size :math:`(2,708, )`. + - ``train_mask``: The train mask. ``torch.BoolTensor`` with size :math:`(2,708, )`. + - ``val_mask``: The validation mask. ``torch.BoolTensor`` with size :math:`(2,708, )`. + - ``test_mask``: The test mask. ``torch.BoolTensor`` with size :math:`(2,708, )`. + + Args: + ``data_root`` (``str``, optional): The ``data_root`` has stored the data. If set to ``None``, this function will auto-download from server and save into the default direction ``~/.dhg/datasets/``. Defaults to ``None``. + """ + + def __init__(self, data_root: Optional[str] = None) -> None: + super().__init__("cocitation_cora", data_root) + self._content = { + "num_classes": 7, + "num_vertices": 2708, + "num_edges": 1579, + "dim_features": 1433, + "features": { + "upon": [ + { + "filename": "features.pkl", + "md5": "14257c0e24b4eb741b469a351e524785", + } + ], + "loader": load_from_pickle, + "preprocess": [to_tensor, partial(norm_ft, ord=1)], + }, + "edge_list": { + "upon": [ + { + "filename": "edge_list.pkl", + "md5": "e43d1321880c8ecb2260d8fb7effd9ea", + } + ], + "loader": load_from_pickle, + }, + "labels": { + "upon": [ + { + "filename": "labels.pkl", + "md5": "c8d11c452e0be69f79a47dd839279117", + } + ], + "loader": load_from_pickle, + "preprocess": [to_long_tensor], + }, + "train_mask": { + "upon": [ + { + "filename": "train_mask.pkl", + "md5": "111db6c6f986be2908378df7bdca7a9b", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + "val_mask": { + "upon": [ + { + "filename": "val_mask.pkl", + "md5": "ffab1055193ffb2fe74822bb575d332a", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + "test_mask": { + "upon": [ + { + "filename": "test_mask.pkl", + "md5": "ffab1055193ffb2fe74822bb575d332a", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + } + + +class CocitationCiteseer(BaseData): + r"""The Co-citation Citeseer dataset is a citation network dataset for vertex classification task. + More details see the `HyperGCN `_ paper. + + The content of the Co-citation Citaseer dataset includes the following: + + - ``num_classes``: The number of classes: :math:`6`. + - ``num_vertices``: The number of vertices: :math:`3,312`. + - ``num_edges``: The number of edges: :math:`1,079`. + - ``dim_features``: The dimension of features: :math:`3,703`. + - ``features``: The vertex feature matrix. ``torch.Tensor`` with size :math:`(3,312 \times 3,703)`. + - ``edge_list``: The edge list. ``List`` with length :math:`1,079`. + - ``labels``: The label list. ``torch.LongTensor`` with size :math:`(3,312, )`. + - ``train_mask``: The train mask. ``torch.BoolTensor`` with size :math:`(3,312, )`. + - ``val_mask``: The validation mask. ``torch.BoolTensor`` with size :math:`(3,312, )`. + - ``test_mask``: The test mask. ``torch.BoolTensor`` with size :math:`(3,312, )`. + + Args: + ``data_root`` (``str``, optional): The ``data_root`` has stored the data. If set to ``None``, this function will auto-download from server and save into the default direction ``~/.dhg/datasets/``. Defaults to ``None``. + """ + + def __init__(self, data_root: Optional[str] = None) -> None: + super().__init__("cocitation_citeseer", data_root) + self._content = { + "num_classes": 6, + "num_vertices": 3312, + "num_edges": 1079, + "dim_features": 3703, + "features": { + "upon": [ + { + "filename": "features.pkl", + "md5": "1ee0dc89e0d5f5ac9187b55a407683e8", + } + ], + "loader": load_from_pickle, + "preprocess": [to_tensor, partial(norm_ft, ord=1)], + }, + "edge_list": { + "upon": [ + { + "filename": "edge_list.pkl", + "md5": "6687b2e96159c534a424253f536b49ae", + } + ], + "loader": load_from_pickle, + }, + "labels": { + "upon": [ + { + "filename": "labels.pkl", + "md5": "71069f78e83fa85dd6a4b9b6570447c2", + } + ], + "loader": load_from_pickle, + "preprocess": [to_long_tensor], + }, + "train_mask": { + "upon": [ + { + "filename": "train_mask.pkl", + "md5": "3b831318fc3d3e588bead5ba469fe38f", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + "val_mask": { + "upon": [ + { + "filename": "val_mask.pkl", + "md5": "c22eb5b7493908042c7e039c8bb5a82e", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + "test_mask": { + "upon": [ + { + "filename": "test_mask.pkl", + "md5": "c22eb5b7493908042c7e039c8bb5a82e", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + } + + +class CocitationPubmed(BaseData): + r"""The Co-citation PubMed dataset is a citation network dataset for vertex classification task. + More details see the `HyperGCN `_ paper. + + The content of the Co-citation PubMed dataset includes the following: + + - ``num_classes``: The number of classes: :math:`3`. + - ``num_vertices``: The number of vertices: :math:`19,717`. + - ``num_edges``: The number of edges: :math:`7,963`. + - ``dim_features``: The dimension of features: :math:`500`. + - ``features``: The vertex feature matrix. ``torch.Tensor`` with size :math:`(19,717 \times 500)`. + - ``edge_list``: The edge list. ``List`` with length :math:`7,963`. + - ``labels``: The label list. ``torch.LongTensor`` with size :math:`(19,717, )`. + - ``train_mask``: The train mask. ``torch.BoolTensor`` with size :math:`(19,717, )`. + - ``val_mask``: The validation mask. ``torch.BoolTensor`` with size :math:`(19,717, )`. + - ``test_mask``: The test mask. ``torch.BoolTensor`` with size :math:`(19,717, )`. + + Args: + ``data_root`` (``str``, optional): The ``data_root`` has stored the data. If set to ``None``, this function will auto-download from server and save into the default direction ``~/.dhg/datasets/``. Defaults to ``None``. + """ + + def __init__(self, data_root: Optional[str] = None) -> None: + super().__init__("cocitation_pubmed", data_root) + self._content = { + "num_classes": 3, + "num_vertices": 19717, + "num_edges": 7963, + "dim_features": 500, + "features": { + "upon": [ + { + "filename": "features.pkl", + "md5": "f89502c432ca451156a8235c5efc034e", + } + ], + "loader": load_from_pickle, + "preprocess": [to_tensor, partial(norm_ft, ord=1)], + }, + "edge_list": { + "upon": [ + { + "filename": "edge_list.pkl", + "md5": "c5fbedf63e5be527f200e8c4e0391b00", + } + ], + "loader": load_from_pickle, + }, + "labels": { + "upon": [ + { + "filename": "labels.pkl", + "md5": "c039f778409a15f9b2ceefacad9c2202", + } + ], + "loader": load_from_pickle, + "preprocess": [to_long_tensor], + }, + "train_mask": { + "upon": [ + { + "filename": "train_mask.pkl", + "md5": "81b422937f3adccd89a334d7093b67d7", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + "val_mask": { + "upon": [ + { + "filename": "val_mask.pkl", + "md5": "10717940ddbfa3e4f6c0b148bb394f79", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + "test_mask": { + "upon": [ + { + "filename": "test_mask.pkl", + "md5": "10717940ddbfa3e4f6c0b148bb394f79", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + } diff --git a/easygraph/datasets/hypergraph/contact_primary_school.py b/easygraph/datasets/hypergraph/contact_primary_school.py new file mode 100644 index 0000000..19fdf7c --- /dev/null +++ b/easygraph/datasets/hypergraph/contact_primary_school.py @@ -0,0 +1,183 @@ +import requests + +from easygraph.utils.exception import EasyGraphError + + +def request_text_from_url(url): + """Requests text data from the specified URL. + + Args: + url (str): The URL from which to request data. + + Returns: + str: The text content of the response if the request is successful. + + Raises: + EasyGraphError: If a connection error occurs or the HTTP response status code indicates failure. + """ + try: + r = requests.get(url) + except requests.ConnectionError: + raise EasyGraphError("Connection Error!") + + if r.ok: + return r.text + else: + raise EasyGraphError(f"Error: HTTP response {r.status_code}") + + +class contact_primary_school: + """A class for loading and processing the primary school contact network hypergraph dataset. + + This class loads hyperedge, node label, and label name data from specified URLs and generates a hypergraph. + + Attributes: + data_root (str): The root URL for the data. If not provided, it is set to None. + hyperedges_path (str): The URL for the hyperedge data. + node_labels_path (str): The URL for the node label data. + label_names_path (str): The URL for the label name data. + _hyperedges (list): A list storing hyperedges. + _node_labels (list): A list storing node labels. + _label_names (list): A list storing label names. + _node_names (list): A list storing node names (currently unused). + _content (dict): A dictionary containing dataset statistics and data. + """ + + def __init__(self, data_root=None): + """Initializes an instance of the contact_primary_school class. + + Args: + data_root (str, optional): The root URL for the data. Defaults to None. + """ + self.data_root = "https://" if data_root is not None else data_root + self.hyperedges_path = "https://gitlab.com/easy-graph/easygraph-data-contact-primary-school/-/raw/main/hyperedges-contact-primary-school.txt?inline=false" + self.node_labels_path = "https://gitlab.com/easy-graph/easygraph-data-contact-primary-school/-/raw/main/node-labels-contact-primary-school.txt?ref_type=heads&inline=false" + # self.node_names_path = "https://gitlab.com/easy-graph/easygraph-data-house-committees/-/raw/main/node-names-house-committees.txt?ref_type=heads&inline=false" + self.label_names_path = "https://gitlab.com/easy-graph/easygraph-data-contact-primary-school/-/raw/main/label-names-contact-primary-school.txt?ref_type=heads&inline=false" + self._hyperedges = [] + self._node_labels = [] + self._label_names = [] + self._node_names = [] + self.generate_hypergraph( + hyperedges_path=self.hyperedges_path, + node_labels_path=self.node_labels_path, + # node_names_path=self.node_names_path, + label_names_path=self.label_names_path, + ) + self._content = { + "num_classes": len(self._label_names), + "num_vertices": len(self._node_labels), + "num_edges": len(self._hyperedges), + "edge_list": self._hyperedges, + "labels": self._node_labels, + } + + def __getitem__(self, key: str): + """Accesses data in the _content dictionary by key. + + Args: + key (str): The key of the data to access. + + Returns: + Any: The value corresponding to the key in the _content dictionary. + """ + return self._content[key] + + def process_label_txt(self, data_str, delimiter="\n", transform_fun=str): + """Processes label data read from a text file. + + Args: + data_str (str): A string containing label data. + delimiter (str, optional): The delimiter used to split the string. Defaults to "\n". + transform_fun (callable, optional): A function used to transform each label. Defaults to str. + + Returns: + list: A list of processed labels. + """ + data_str = data_str.strip() + data_lst = data_str.split(delimiter) + final_lst = [] + for data in data_lst: + data = data.strip() + data = transform_fun(data) + final_lst.append(data) + return final_lst + + @property + def node_labels(self): + """Gets the list of node labels. + + Returns: + list: A list of node labels. + """ + return self._node_labels + + """ + @property + def node_names(self): + return self._node_names + """ + + @property + def label_names(self): + """Gets the list of label names. + + Returns: + list: A list of label names. + """ + return self._label_names + + @property + def hyperedges(self): + """Gets the list of hyperedges. + + Returns: + list: A list of hyperedges. + """ + return self._hyperedges + + def generate_hypergraph( + self, + hyperedges_path=None, + node_labels_path=None, + # node_names_path=None, + label_names_path=None, + ): + """Generates hypergraph data from specified URLs. + + Args: + hyperedges_path (str, optional): The URL for the hyperedge data. Defaults to None. + node_labels_path (str, optional): The URL for the node label data. Defaults to None. + label_names_path (str, optional): The URL for the label name data. Defaults to None. + """ + + def fun(data): + """Converts the input data to an integer and subtracts 1. + + Args: + data (str): The input string data. + + Returns: + int: The converted integer data. + """ + data = int(data) - 1 + return data + + hyperedges_info = request_text_from_url(hyperedges_path) + hyperedges_info = hyperedges_info.strip() + hyperedges_lst = hyperedges_info.split("\n") + for hyperedge in hyperedges_lst: + hyperedge = hyperedge.strip() + hyperedge = [int(i) - 1 for i in hyperedge.split(",")] + self._hyperedges.append(tuple(hyperedge)) + # print(self.hyperedges) + + node_labels_info = request_text_from_url(node_labels_path) + + process_node_labels_info = self.process_label_txt( + node_labels_info, transform_fun=fun + ) + self._node_labels = process_node_labels_info + label_names_info = request_text_from_url(label_names_path) + process_label_names_info = self.process_label_txt(label_names_info) + self._label_names = process_label_names_info diff --git a/easygraph/datasets/hypergraph/cooking_200.py b/easygraph/datasets/hypergraph/cooking_200.py new file mode 100644 index 0000000..34b2341 --- /dev/null +++ b/easygraph/datasets/hypergraph/cooking_200.py @@ -0,0 +1,85 @@ +from typing import Optional + +from easygraph.datapipe import load_from_pickle +from easygraph.datapipe import to_bool_tensor +from easygraph.datapipe import to_long_tensor +from easygraph.datasets.hypergraph.hypergraph_dataset_base import BaseData + + +class Cooking200(BaseData): + r"""The Cooking 200 dataset is collected from `Yummly.com `_ for vertex classification task. + It is a hypergraph dataset, in which vertex denotes the dish and hyperedge denotes + the ingredient. Each dish is also associated with category information, which indicates the dish's cuisine like + Chinese, Japanese, French, and Russian. + + The content of the Cooking200 dataset includes the following: + + - ``num_classes``: The number of classes: :math:`20`. + - ``num_vertices``: The number of vertices: :math:`7,403`. + - ``num_edges``: The number of edges: :math:`2,755`. + - ``edge_list``: The edge list. ``List`` with length :math:`(2,755)`. + - ``labels``: The label list. ``torch.LongTensor`` with size :math:`(7,403)`. + - ``train_mask``: The train mask. ``torch.BoolTensor`` with size :math:`(7,403)`. + - ``val_mask``: The validation mask. ``torch.BoolTensor`` with size :math:`(7,403)`. + - ``test_mask``: The test mask. ``torch.BoolTensor`` with size :math:`(7,403)`. + + Args: + ``data_root`` (``str``, optional): The ``data_root`` has stored the data. If set to ``None``, this function will auto-download from server and save into the default direction ``~/.dhg/datasets/``. Defaults to ``None``. + """ + + def __init__(self, data_root: Optional[str] = None) -> None: + super().__init__("cooking_200", data_root) + self._content = { + "num_classes": 20, + "num_vertices": 7403, + "num_edges": 2755, + "edge_list": { + "upon": [ + { + "filename": "edge_list.pkl", + "md5": "2cd32e13dd4e33576c43936542975220", + } + ], + "loader": load_from_pickle, + }, + "labels": { + "upon": [ + { + "filename": "labels.pkl", + "md5": "f1f3c0399c9c28547088f44e0bfd5c81", + } + ], + "loader": load_from_pickle, + "preprocess": [to_long_tensor], + }, + "train_mask": { + "upon": [ + { + "filename": "train_mask.pkl", + "md5": "66ea36bae024aaaed289e1998fe894bd", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + "val_mask": { + "upon": [ + { + "filename": "val_mask.pkl", + "md5": "6c0d3d8b752e3955c64788cc65dcd018", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + "test_mask": { + "upon": [ + { + "filename": "test_mask.pkl", + "md5": "0e1564904551ba493e1f8a09d103461e", + } + ], + "loader": load_from_pickle, + "preprocess": [to_bool_tensor], + }, + } diff --git a/easygraph/datasets/hypergraph/hypergraph_dataset_base.py b/easygraph/datasets/hypergraph/hypergraph_dataset_base.py new file mode 100644 index 0000000..1bfc96f --- /dev/null +++ b/easygraph/datasets/hypergraph/hypergraph_dataset_base.py @@ -0,0 +1,119 @@ +from pathlib import Path +from typing import Any +from typing import Dict +from typing import List + +from easygraph.datapipe import compose_pipes +from easygraph.datasets.hypergraph._global import DATASETS_ROOT +from easygraph.datasets.hypergraph._global import REMOTE_DATASETS_ROOT +from easygraph.datasets.utils import download_and_check + + +class BaseData: + r"""The Base Class of all datasets. + + :: + + self._content = { + 'item': { + 'upon': [ + {'filename': 'part1.pkl', 'md5': 'xxxxx',}, + {'filename': 'part2.pkl', 'md5': 'xxxxx',}, + ], + 'loader': loader_function, + 'preprocess': [datapipe1, datapipe2], + }, + ... + } + + """ + + def __init__(self, name: str, data_root=None): + # configure the data local/remote root + self.name = name + if data_root is None: + self.data_root = DATASETS_ROOT / name + else: + self.data_root = Path(data_root) / name + self.remote_root = REMOTE_DATASETS_ROOT + name + "/" + # init + self._content = {} + self._raw = {} + + def __repr__(self) -> str: + return ( + f"This is {self.name} dataset:\n" + + "\n".join(f" -> {k}" for k in self.content) + + "\nPlease try `data['name']` to get the specified data." + ) + + @property + def content(self): + r"""Return the content of the dataset.""" + return list(self._content.keys()) + + def needs_to_load(self, item_name: str) -> bool: + r"""Return whether the ``item_name`` of the dataset needs to be loaded. + + Args: + ``item_name`` (``str``): The name of the item in the dataset. + """ + assert item_name in self.content, f"{item_name} is not provided in the Data" + return ( + isinstance(self._content[item_name], dict) + and "upon" in self._content[item_name] + and "loader" in self._content[item_name] + ) + + def __getitem__(self, key: str) -> Any: + if self.needs_to_load(key): + cur_cfg = self._content[key] + if cur_cfg.get("cache", None) is None: + # get raw data + item = self.raw(key) + # preprocess and cache + pipes = cur_cfg.get("preprocess", None) + if pipes is not None: + cur_cfg["cache"] = compose_pipes(*pipes)(item) + else: + cur_cfg["cache"] = item + return cur_cfg["cache"] + else: + return self._content[key] + + def raw(self, key: str) -> Any: + r"""Return the ``key`` of the dataset with un-preprocessed format.""" + if self.needs_to_load(key): + cur_cfg = self._content[key] + if self._raw.get(key, None) is None: + upon = cur_cfg["upon"] + if len(upon) == 0: + return None + self.fetch_files(cur_cfg["upon"]) + file_path_list = [ + self.data_root / u["filename"] for u in cur_cfg["upon"] + ] + if len(file_path_list) == 1: + self._raw[key] = cur_cfg["loader"](file_path_list[0]) + else: + # here, you should implement a multi-file loader + self._raw[key] = cur_cfg["loader"](file_path_list) + return self._raw[key] + else: + return self._content[key] + + def fetch_files(self, files: List[Dict[str, str]]): + r"""Download and check the files if they are not exist. + + Args: + ``files`` (``List[Dict[str, str]]``): The files to download, each element + in the list is a dict with at lease two keys: ``filename`` and ``md5``. + If extra key ``bk_url`` is provided, it will be used to download the + file from the backup url. + """ + for file in files: + cur_filename = file["filename"] + cur_url = file.get("bk_url", None) + if cur_url is None: + cur_url = self.remote_root + cur_filename + download_and_check(cur_url, self.data_root / cur_filename, file["md5"]) diff --git a/easygraph/datasets/hypergraph/loadDeepSetDatasets.py b/easygraph/datasets/hypergraph/loadDeepSetDatasets.py new file mode 100644 index 0000000..e212ae1 --- /dev/null +++ b/easygraph/datasets/hypergraph/loadDeepSetDatasets.py @@ -0,0 +1,78 @@ +import os.path as osp + +import numpy as np +import scipy.sparse as sp +import torch + +from torch_geometric.data import Data +from torch_sparse import coalesce + + +__all__ = ["load_line_expansion_dataset"] + + +def load_line_expansion_dataset( + path=None, dataset="cocitation-cora", train_percent=0.5 +): + # load edges, features, and labels. + print("Loading {} dataset...".format(dataset)) + + file_name = f"{dataset}.content" + p2idx_features_labels = osp.join(path, dataset, file_name) + idx_features_labels = np.genfromtxt(p2idx_features_labels, dtype=np.dtype(str)) + # features = np.array(idx_features_labels[:, 1:-1]) + features = sp.csr_matrix(idx_features_labels[:, 1:-1], dtype=np.float32) + # labels = encode_onehot(idx_features_labels[:, -1]) + labels = torch.LongTensor(idx_features_labels[:, -1].astype(float)) + + print("load features") + + # build graph + idx = np.array(idx_features_labels[:, 0], dtype=np.int32) + idx_map = {j: i for i, j in enumerate(idx)} + + file_name = f"{dataset}.edges" + p2edges_unordered = osp.join(path, dataset, file_name) + edges_unordered = np.genfromtxt(p2edges_unordered, dtype=np.int32) + + edges = np.array( + list(map(idx_map.get, edges_unordered.flatten())), dtype=np.int32 + ).reshape(edges_unordered.shape) + + print("load edges") + + # From adjacency matrix to edge_list + edge_index = edges.T + # ipdb.set_trace() + assert edge_index[0].max() == edge_index[1].min() - 1 + + # check if values in edge_index is consecutive. i.e. no missing value for node_id/he_id. + assert len(np.unique(edge_index)) == edge_index.max() + 1 + + num_nodes = edge_index[0].max() + 1 + num_he = edge_index[1].max() - num_nodes + 1 + edge_index = np.hstack((edge_index, edge_index[::-1, :])) + + # build torch data class + data = Data( + x=torch.FloatTensor(np.array(features[:num_nodes].todense())), + edge_index=torch.LongTensor(edge_index), + y=labels[:num_nodes], + ) + + # used user function to override the default function. + # the following will also sort the edge_index and remove duplicates. + total_num_node_id_he_id = len(np.unique(edge_index)) + data.edge_index, data.edge_attr = coalesce( + data.edge_index, None, total_num_node_id_he_id, total_num_node_id_he_id + ) + n_x = num_nodes + # n_x = n_expanded + num_class = len(np.unique(labels[:num_nodes].numpy())) + data.n_x = n_x + # add parameters to attribute + + data.train_percent = train_percent + data.num_hyperedges = num_he + + return data diff --git a/easygraph/datasets/hypergraph/mathoverflow_answers.py b/easygraph/datasets/hypergraph/mathoverflow_answers.py new file mode 100644 index 0000000..1e2e9d7 --- /dev/null +++ b/easygraph/datasets/hypergraph/mathoverflow_answers.py @@ -0,0 +1,113 @@ +import requests + +from easygraph.utils.exception import EasyGraphError + + +def request_text_from_url(url): + try: + r = requests.get(url) + except requests.ConnectionError: + raise EasyGraphError("Connection Error!") + + if r.ok: + return r.text + else: + raise EasyGraphError(f"Error: HTTP response {r.status_code}") + + +class mathoverflow_answers: + def __init__(self, data_root=None): + self.data_root = "https://" if data_root is not None else data_root + self.hyperedges_path = "https://gitlab.com/easy-graph/easygraph-data-mathoverflow-answers/-/raw/main/hyperedges-mathoverflow-answers.txt?inline=false" + self.node_labels_path = "https://gitlab.com/easy-graph/easygraph-data-mathoverflow-answers/-/raw/main/node-labels-mathoverflow-answers.txt?ref_type=heads&inline=false" + # self.node_names_path = "https://gitlab.com/easy-graph/easygraph-data-house-committees/-/raw/main/node-names-house-committees.txt?ref_type=heads&inline=false" + self.label_names_path = "https://gitlab.com/easy-graph/easygraph-data-mathoverflow-answers/-/raw/main/label-names-mathoverflow-answers.txt?ref_type=heads&inline=false" + self._hyperedges = [] + self._node_labels = [] + self._label_names = [] + self._node_names = [] + self.generate_hypergraph( + hyperedges_path=self.hyperedges_path, + node_labels_path=self.node_labels_path, + # node_names_path=self.node_names_path, + label_names_path=self.label_names_path, + ) + self._content = { + "num_classes": len(self._label_names), + "num_vertices": len(self._node_labels), + "num_edges": len(self._hyperedges), + "edge_list": self._hyperedges, + "labels": self._node_labels, + } + + def __getitem__(self, key: str): + return self._content[key] + + def process_label_txt(self, data_str, delimiter="\n", transform_fun=str): + data_str = data_str.strip() + data_lst = data_str.split(delimiter) + final_lst = [] + for data in data_lst: + data = data.strip() + data = transform_fun(data) + final_lst.append(data) + return final_lst + + @property + def node_labels(self): + return self._node_labels + + """ + @property + def node_names(self): + return self._node_names + """ + + @property + def label_names(self): + return self._label_names + + @property + def hyperedges(self): + return self._hyperedges + + def generate_hypergraph( + self, + hyperedges_path=None, + node_labels_path=None, + # node_names_path=None, + label_names_path=None, + ): + def fun(data): + data = int(data) - 1 + return data + + hyperedges_info = request_text_from_url(hyperedges_path) + hyperedges_info = hyperedges_info.strip() + hyperedges_lst = hyperedges_info.split("\n") + for hyperedge in hyperedges_lst: + hyperedge = hyperedge.strip() + hyperedge = [int(i) - 1 for i in hyperedge.split(",")] + self._hyperedges.append(tuple(hyperedge)) + # print(self.hyperedges) + """ + node_labels_info = request_text_from_url(node_labels_path) + + process_node_labels_info = self.process_label_txt( + node_labels_info, transform_fun=fun + ) + self._node_labels = process_node_labels_info + """ + node_labels_info = request_text_from_url(node_labels_path) + node_labels_info = node_labels_info.strip() + node_labels_lst = node_labels_info.split("\n") + for node_label in node_labels_lst: + node_label = node_label.strip() + node_label = [int(i) - 1 for i in node_label.split(",")] + self._node_labels.append(tuple(node_label)) + # print("process_node_labels_info:", process_node_labels_info) + # print("process_node_names_info:", process_node_names_info) + label_names_info = request_text_from_url(label_names_path) + process_label_names_info = self.process_label_txt(label_names_info) + self._label_names = process_label_names_info + # print("process_label_names_info:", process_label_names_info) diff --git a/easygraph/datasets/hypergraph/senate_committees.py b/easygraph/datasets/hypergraph/senate_committees.py new file mode 100644 index 0000000..5e384a4 --- /dev/null +++ b/easygraph/datasets/hypergraph/senate_committees.py @@ -0,0 +1,106 @@ +import requests + +from easygraph.utils.exception import EasyGraphError + + +def request_text_from_url(url): + try: + r = requests.get(url) + except requests.ConnectionError: + raise EasyGraphError("Connection Error!") + + if r.ok: + return r.text + else: + raise EasyGraphError(f"Error: HTTP response {r.status_code}") + + +class senate_committees: + def __init__(self, data_root=None): + self.data_root = "https://" if data_root is not None else data_root + self.hyperedges_path = "https://gitlab.com/easy-graph/easygraph-data-senate-committees/-/raw/main/hyperedges-senate-committees.txt?inline=false" + self.node_labels_path = "https://gitlab.com/easy-graph/easygraph-data-senate-committees/-/raw/main/node-labels-senate-committees.txt?ref_type=heads&inline=false" + self.node_names_path = "https://gitlab.com/easy-graph/easygraph-data-senate-committees/-/raw/main/node-names-senate-committees.txt?ref_type=heads&inline=false" + self.label_names_path = "https://gitlab.com/easy-graph/easygraph-data-senate-committees/-/raw/main/label-names-senate-committees.txt?ref_type=heads&inline=false" + self._hyperedges = [] + self._node_labels = [] + self._label_names = [] + self._node_names = [] + self.generate_hypergraph( + hyperedges_path=self.hyperedges_path, + node_labels_path=self.node_labels_path, + node_names_path=self.node_names_path, + label_names_path=self.label_names_path, + ) + self._content = { + "num_classes": len(self._label_names), + "num_vertices": len(self._node_labels), + "num_edges": len(self._hyperedges), + "edge_list": self._hyperedges, + "labels": self._node_labels, + } + + def __getitem__(self, key: str): + return self._content[key] + + def process_label_txt(self, data_str, delimiter="\n", transform_fun=str): + data_str = data_str.strip() + data_lst = data_str.split(delimiter) + final_lst = [] + for data in data_lst: + data = data.strip() + data = transform_fun(data) + final_lst.append(data) + return final_lst + + @property + def node_labels(self): + return self._node_labels + + @property + def node_names(self): + return self._node_names + + @property + def label_names(self): + return self._label_names + + @property + def hyperedges(self): + return self._hyperedges + + def generate_hypergraph( + self, + hyperedges_path=None, + node_labels_path=None, + node_names_path=None, + label_names_path=None, + ): + def fun(data): + data = int(data) - 1 + return data + + hyperedges_info = request_text_from_url(hyperedges_path) + hyperedges_info = hyperedges_info.strip() + hyperedges_lst = hyperedges_info.split("\n") + for hyperedge in hyperedges_lst: + hyperedge = hyperedge.strip() + hyperedge = [int(i) - 1 for i in hyperedge.split(",")] + self._hyperedges.append(tuple(hyperedge)) + # print(self.hyperedges) + + node_labels_info = request_text_from_url(node_labels_path) + + process_node_labels_info = self.process_label_txt( + node_labels_info, transform_fun=fun + ) + self._node_labels = process_node_labels_info + # print("process_node_labels_info:", process_node_labels_info) + node_names_info = request_text_from_url(node_names_path) + process_node_names_info = self.process_label_txt(node_names_info) + self._node_names = process_node_names_info + # print("process_node_names_info:", process_node_names_info) + label_names_info = request_text_from_url(label_names_path) + process_label_names_info = self.process_label_txt(label_names_info) + self._label_names = process_label_names_info + # print("process_label_names_info:", process_label_names_info) diff --git a/easygraph/datasets/hypergraph/trivago_clicks.py b/easygraph/datasets/hypergraph/trivago_clicks.py new file mode 100644 index 0000000..a861004 --- /dev/null +++ b/easygraph/datasets/hypergraph/trivago_clicks.py @@ -0,0 +1,104 @@ +import requests + +from easygraph.utils.exception import EasyGraphError + + +def request_text_from_url(url): + try: + r = requests.get(url) + except requests.ConnectionError: + raise EasyGraphError("Connection Error!") + + if r.ok: + return r.text + else: + raise EasyGraphError(f"Error: HTTP response {r.status_code}") + + +class trivago_clicks: + def __init__(self, data_root=None): + self.data_root = "https://" if data_root is not None else data_root + self.hyperedges_path = "https://gitlab.com/easy-graph/easygraph-data-trivago-clicks/-/raw/main/hyperedges-trivago-clicks.txt?inline=false" + self.node_labels_path = "https://gitlab.com/easy-graph/easygraph-data-trivago-clicks/-/raw/main/node-labels-trivago-clicks.txt?ref_type=heads&inline=false" + # self.node_names_path = "https://gitlab.com/easy-graph/easygraph-data-trivago-clicks/-/raw/main/node-names-house-committees.txt?ref_type=heads&inline=false" + self.label_names_path = "https://gitlab.com/easy-graph/easygraph-data-trivago-clicks/-/raw/main/label-names-trivago-clicks.txt?ref_type=heads&inline=false" + self._hyperedges = [] + self._node_labels = [] + self._label_names = [] + self._node_names = [] + self.generate_hypergraph( + hyperedges_path=self.hyperedges_path, + node_labels_path=self.node_labels_path, + # node_names_path=self.node_names_path, + label_names_path=self.label_names_path, + ) + self._content = { + "num_classes": len(self._label_names), + "num_vertices": len(self._node_labels), + "num_edges": len(self._hyperedges), + "edge_list": self._hyperedges, + "labels": self._node_labels, + } + + def __getitem__(self, key: str): + return self._content[key] + + def process_label_txt(self, data_str, delimiter="\n", transform_fun=str): + data_str = data_str.strip() + data_lst = data_str.split(delimiter) + final_lst = [] + for data in data_lst: + data = data.strip() + data = transform_fun(data) + final_lst.append(data) + return final_lst + + @property + def node_labels(self): + return self._node_labels + + """ + @property + def node_names(self): + return self._node_names + """ + + @property + def label_names(self): + return self._label_names + + @property + def hyperedges(self): + return self._hyperedges + + def generate_hypergraph( + self, + hyperedges_path=None, + node_labels_path=None, + # node_names_path=None, + label_names_path=None, + ): + def fun(data): + data = int(data) - 1 + return data + + hyperedges_info = request_text_from_url(hyperedges_path) + hyperedges_info = hyperedges_info.strip() + hyperedges_lst = hyperedges_info.split("\n") + for hyperedge in hyperedges_lst: + hyperedge = hyperedge.strip() + hyperedge = [int(i) - 1 for i in hyperedge.split(",")] + self._hyperedges.append(tuple(hyperedge)) + # print(self.hyperedges) + + node_labels_info = request_text_from_url(node_labels_path) + + process_node_labels_info = self.process_label_txt( + node_labels_info, transform_fun=fun + ) + self._node_labels = process_node_labels_info + # print("process_node_labels_info:", process_node_labels_info) + # print("process_node_names_info:", process_node_names_info) + label_names_info = request_text_from_url(label_names_path) + process_label_names_info = self.process_label_txt(label_names_info) + self._label_names = process_label_names_info diff --git a/easygraph/datasets/hypergraph/walmart_trips.py b/easygraph/datasets/hypergraph/walmart_trips.py new file mode 100644 index 0000000..278222e --- /dev/null +++ b/easygraph/datasets/hypergraph/walmart_trips.py @@ -0,0 +1,208 @@ +import requests + +from easygraph.utils.exception import EasyGraphError + + +def request_text_from_url(url): + """ + Requests text content from the given URL. + + Args: + url (str): The URL from which to request text data. + + Returns: + str: The text content of the response if the request is successful. + + Raises: + EasyGraphError: If a connection error occurs during the request or if the HTTP response status code is not OK. + """ + try: + r = requests.get(url) + except requests.ConnectionError: + raise EasyGraphError("Connection Error!") + + if r.ok: + return r.text + else: + raise EasyGraphError(f"Error: HTTP response {r.status_code}") + + +class walmart_trips: + """ + A class for loading and processing the Walmart trips hypergraph dataset. + + This class fetches hyperedge, node label, and label name data from predefined URLs, + processes the data, and generates a hypergraph representation. It also provides access + to various dataset attributes through properties and indexing. + + Attributes: + data_root (str): The root URL for the data. If provided during initialization, it is set to "https://"; + otherwise, it is None. + hyperedges_path (str): The URL of the file containing hyperedge information. + node_labels_path (str): The URL of the file containing node label information. + label_names_path (str): The URL of the file containing label name information. + _hyperedges (list): A list of tuples representing hyperedges. + _node_labels (list): A list of node labels. + _label_names (list): A list of label names. + _node_names (list): An empty list reserved for node names (currently unused). + _content (dict): A dictionary containing dataset statistics and data, such as the number of classes, + vertices, edges, the edge list, and node labels. + """ + + def __init__(self, data_root=None, local_path=None): + """ + Initializes an instance of the walmart_trips class. + + Args: + data_root (str, optional): The root URL for the data. If provided, it is set to "https://"; + otherwise, it is None. Defaults to None. + local_path (str, optional): Currently unused. Defaults to None. + """ + self.data_root = "https://" if data_root is not None else data_root + self.hyperedges_path = "https://gitlab.com/easy-graph/easygraph-data-walmart-trips/-/raw/main/hyperedges-walmart-trips.txt?inline=false" + self.node_labels_path = "https://gitlab.com/easy-graph/easygraph-data-walmart-trips/-/raw/main/node-labels-walmart-trips.txt?ref_type=heads&inline=false" + # self.node_names_path = "https://gitlab.com/easy-graph/easygraph-data-walmart-trips/-/raw/main/node-names-house-committees.txt?ref_type=heads&inline=false" + self.label_names_path = "https://gitlab.com/easy-graph/easygraph-data-walmart-trips/-/raw/main/label-names-walmart-trips.txt?ref_type=heads&inline=false" + self._hyperedges = [] + self._node_labels = [] + self._label_names = [] + self._node_names = [] + + self.generate_hypergraph( + hyperedges_path=self.hyperedges_path, + node_labels_path=self.node_labels_path, + # node_names_path=self.node_names_path, + label_names_path=self.label_names_path, + ) + + self._content = { + "num_classes": len(self._label_names), + "num_vertices": len(self._node_labels), + "num_edges": len(self._hyperedges), + "edge_list": self._hyperedges, + "labels": self._node_labels, + } + + def __getitem__(self, key: str): + """ + Retrieves a value from the _content dictionary using the specified key. + + Args: + key (str): The key used to access the _content dictionary. + + Returns: + Any: The value corresponding to the key in the _content dictionary. + """ + return self._content[key] + + def process_label_txt(self, data_str, delimiter="\n", transform_fun=str): + """ + Processes a string containing label data into a list of transformed values. + + Args: + data_str (str): The input string containing label data. + delimiter (str, optional): The delimiter used to split the input string. Defaults to "\n". + transform_fun (callable, optional): A function used to transform each label value. + Defaults to the str function. + + Returns: + list: A list of transformed label values. + """ + data_str = data_str.strip() + data_lst = data_str.split(delimiter) + final_lst = [] + for data in data_lst: + data = data.strip() + data = transform_fun(data) + final_lst.append(data) + return final_lst + + @property + def node_labels(self): + """ + Gets the list of node labels. + + Returns: + list: A list of node labels. + """ + return self._node_labels + + """ + @property + def node_names(self): + return self._node_names + """ + + @property + def label_names(self): + """ + Gets the list of label names. + + Returns: + list: A list of label names. + """ + return self._label_names + + @property + def hyperedges(self): + """ + Gets the list of hyperedges. + + Returns: + list: A list of tuples representing hyperedges. + """ + return self._hyperedges + + def generate_hypergraph( + self, + hyperedges_path=None, + node_labels_path=None, + # node_names_path=None, + label_names_path=None, + ): + """ + Generates a hypergraph by fetching and processing data from the specified URLs. + + Args: + hyperedges_path (str, optional): The URL of the file containing hyperedge information. + Defaults to None. + node_labels_path (str, optional): The URL of the file containing node label information. + Defaults to None. + label_names_path (str, optional): The URL of the file containing label name information. + Defaults to None. + """ + + def fun(data): + """ + Converts a string to an integer and subtracts 1. + + Args: + data (str): The input string to be converted. + + Returns: + int: The converted integer value minus 1. + """ + data = int(data) - 1 + return data + + hyperedges_info = request_text_from_url(hyperedges_path) + hyperedges_info = hyperedges_info.strip() + hyperedges_lst = hyperedges_info.split("\n") + for hyperedge in hyperedges_lst: + hyperedge = hyperedge.strip() + hyperedge = [int(i) - 1 for i in hyperedge.split(",")] + self._hyperedges.append(tuple(hyperedge)) + # print(self.hyperedges) + + node_labels_info = request_text_from_url(node_labels_path) + + process_node_labels_info = self.process_label_txt( + node_labels_info, transform_fun=fun + ) + self._node_labels = process_node_labels_info + # print("process_node_labels_info:", process_node_labels_info) + # print("process_node_names_info:", process_node_names_info) + label_names_info = request_text_from_url(label_names_path) + process_label_names_info = self.process_label_txt(label_names_info) + self._label_names = process_label_names_info + # print("process_label_names_info:", process_label_names_info) diff --git a/easygraph/datasets/karate.py b/easygraph/datasets/karate.py new file mode 100644 index 0000000..89702b5 --- /dev/null +++ b/easygraph/datasets/karate.py @@ -0,0 +1,93 @@ +import easygraph as eg + +from .graph_dataset_base import EasyGraphDataset +from .utils import _set_labels +from .utils import tensor + + +""" KarateClubDataset for inductive learning. """ + + +class KarateClubDataset(EasyGraphDataset): + """Karate Club dataset for Node Classification + + Zachary's karate club is a social network of a university + karate club, described in the paper "An Information Flow + Model for Conflict and Fission in Small Groups" by Wayne W. Zachary. + The network became a popular example of community structure in + networks after its use by Michelle Girvan and Mark Newman in 2002. + Official website: ``_ + + Karate Club dataset statistics: + + - Nodes: 34 + - Edges: 156 + - Number of Classes: 2 + + Parameters + ---------- + transform : callable, optional + A transform that takes in a :class:`~eg.Graph` object and returns + a transformed version. The :class:`~eg.Graph` object will be + transformed before every access. + + Attributes + ---------- + num_classes : int + Number of node classes + + Examples + -------- + >>> dataset = KarateClubDataset() + >>> num_classes = dataset.num_classes + >>> g = dataset[0] + >>> labels = g.ndata['label'] + """ + + def __init__(self, transform=None): + super(KarateClubDataset, self).__init__(name="karate_club", transform=transform) + + def process(self): + import numpy as np + + kc_graph = eg.get_graph_karateclub() + label = np.asarray( + [kc_graph.nodes[i]["club"] != "Mr. Hi" for i in kc_graph.nodes] + ).astype(np.int64) + label = tensor(label) + + kc_graph = _set_labels(kc_graph, label) + kc_graph.ndata["label"] = label + self._graph = kc_graph + self._data = [kc_graph] + + @property + def num_classes(self): + """Number of classes.""" + return 2 + + def __getitem__(self, idx): + r"""Get graph object + + Parameters + ---------- + idx : int + Item index, KarateClubDataset has only one graph object + + Returns + ------- + :class:`eg.Graph` + + graph structure and labels. + + - ``ndata['label']``: ground truth labels + """ + assert idx == 0, "This dataset has only one graph" + if self._transform is None: + return self._graph + else: + return self._transform(self._graph) + + def __len__(self): + r"""The number of graphs in the dataset.""" + return 1 diff --git a/easygraph/datasets/ppi.py b/easygraph/datasets/ppi.py new file mode 100644 index 0000000..950a434 --- /dev/null +++ b/easygraph/datasets/ppi.py @@ -0,0 +1,217 @@ +"""PPIDataset for inductive learning.""" + +import json +import os + +import numpy as np + +from easygraph.classes.directed_graph import DiGraph + +from ..readwrite import json_graph +from .graph_dataset_base import EasyGraphBuiltinDataset +from .utils import _get_dgl_url +from .utils import data_type_dict +from .utils import tensor + + +class PPIDataset(EasyGraphBuiltinDataset): + r"""Protein-Protein Interaction dataset for inductive node classification + + A toy Protein-Protein Interaction network dataset. The dataset contains + 24 graphs. The average number of nodes per graph is 2372. Each node has + 50 features and 121 labels. 20 graphs for training, 2 for validation + and 2 for testing. + + Reference: ``_ + + Statistics: + + - Train examples: 20 + - Valid examples: 2 + - Test examples: 2 + + Parameters + ---------- + mode : str + Must be one of ('train', 'valid', 'test'). + Default: 'train' + raw_dir : str + Raw file directory to download/contains the input data directory. + Default: ~/.eg/ + force_reload : bool + Whether to reload the dataset. + Default: False + verbose : bool + Whether to print out progress information. + Default: True. + transform : callable, optional + A transform that takes in a :class:`~eg.DGLGraph` object and returns + a transformed version. The :class:`~eg.DGLGraph` object will be + transformed before every access. + + Attributes + ---------- + num_labels : int + Number of labels for each node + labels : Tensor + Node labels + features : Tensor + Node features + + Examples + -------- + >>> dataset = PPIDataset(mode='valid') + >>> num_labels = dataset.num_labels + >>> for g in dataset: + .... feat = g.ndata['feat'] + .... label = g.ndata['label'] + .... # your code here + >>> + """ + + def __init__( + self, + mode="train", + raw_dir=None, + force_reload=False, + verbose=False, + transform=None, + ): + assert mode in ["train", "valid", "test"] + self.mode = mode + _url = _get_dgl_url("dataset/ppi.zip") + super(PPIDataset, self).__init__( + name="ppi", + url=_url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def process(self): + graph_file = os.path.join( + self.save_path, "ppi", "{}_graph.json".format(self.mode) + ) + label_file = os.path.join( + self.save_path, "ppi", "{}_labels.npy".format(self.mode) + ) + feat_file = os.path.join( + self.save_path, "ppi", "{}_feats.npy".format(self.mode) + ) + graph_id_file = os.path.join( + self.save_path, "ppi", "{}_graph_id.npy".format(self.mode) + ) + + g_data = json.load(open(graph_file)) + self._labels = np.load(label_file) + self._feats = np.load(feat_file) + self.graph = DiGraph(json_graph.node_link_graph(g_data)) + graph_id = np.load(graph_id_file) + + # lo, hi means the range of graph ids for different portion of the dataset, + # 20 graphs for training, 2 for validation and 2 for testing. + lo, hi = 1, 21 + if self.mode == "valid": + lo, hi = 21, 23 + elif self.mode == "test": + lo, hi = 23, 25 + + graph_masks = [] + self.graphs = [] + for g_id in range(lo, hi): + g_mask = np.where(graph_id == g_id)[0] + graph_masks.append(g_mask) + g = self.graph.nodes_subgraph(g_mask) + g.ndata["feat"] = tensor( + self._feats[g_mask], dtype=data_type_dict()["float32"] + ) + g.ndata["label"] = tensor( + self._labels[g_mask], dtype=data_type_dict()["float32"] + ) + self.graphs.append(g) + + def has_cache(self): + graph_list_path = os.path.join( + self.save_path, "{}_eg_graph_list.bin".format(self.mode) + ) + g_path = os.path.join(self.save_path, "{}_eg_graph.bin".format(self.mode)) + info_path = os.path.join(self.save_path, "{}_info.pkl".format(self.mode)) + return ( + os.path.exists(graph_list_path) + and os.path.exists(g_path) + and os.path.exists(info_path) + ) + + def save(self): + graph_list_path = os.path.join( + self.save_path, "{}_eg_graph_list.bin".format(self.mode) + ) + g_path = os.path.join(self.save_path, "{}_eg_graph.bin".format(self.mode)) + info_path = os.path.join(self.save_path, "{}_info.pkl".format(self.mode)) + # save_graphs(graph_list_path, self.graphs) + # save_graphs(g_path, self.graph) + # save_info(info_path, {'labels': self._labels, 'feats': self._feats}) + + # def load(self): + # graph_list_path = os.path.join(self.save_path, '{}_eg_graph_list.bin'.format(self.mode)) + # g_path = os.path.join(self.save_path, '{}_eg_graph.bin'.format(self.mode)) + # info_path = os.path.join(self.save_path, '{}_info.pkl'.format(self.mode)) + # self.graphs = load_graphs(graph_list_path)[0] + # g, _ = load_graphs(g_path) + # self.graph = g[0] + # info = load_info(info_path) + # self._labels = info['labels'] + # self._feats = info['feats'] + + @property + def num_labels(self): + return 121 + + def __len__(self): + """Return number of samples in this dataset.""" + return len(self.graphs) + + def __getitem__(self, item): + """Get the item^th sample. + + Parameters + --------- + item : int + The sample index. + + Returns + ------- + :class:`eg.Graph` + graph structure, node features and node labels. + + - ``ndata['feat']``: node features + - ``ndata['label']``: node labels + """ + if self._transform is None: + return self.graphs[item] + else: + return self._transform(self.graphs[item]) + + +class LegacyPPIDataset(PPIDataset): + """Legacy version of PPI Dataset""" + + def __getitem__(self, item): + """Get the item^th sample. + + Parameters + --------- + idx : int + The sample index. + + Returns + ------- + (eg.DGLGraph, Tensor, Tensor) + The graph, features and its label. + """ + if self._transform is None: + g = self.graphs[item] + else: + g = self._transform(self.graphs[item]) + return g, g.ndata["feat"], g.ndata["label"] diff --git a/easygraph/datasets/reddit.py b/easygraph/datasets/reddit.py new file mode 100644 index 0000000..a5e3949 --- /dev/null +++ b/easygraph/datasets/reddit.py @@ -0,0 +1,104 @@ +import os + +import easygraph as eg +import numpy as np +import scipy.sparse as sp + +from easygraph.classes.graph import Graph + +from .graph_dataset_base import EasyGraphBuiltinDataset +from .utils import data_type_dict +from .utils import download +from .utils import extract_archive +from .utils import tensor + + +class RedditDataset(EasyGraphBuiltinDataset): + r"""Reddit posts graph (Sept 2014) for community (subreddit) classification. + + Statistics: + - Nodes: ~232,965 + - Edges: ~114 million (approx.) + - Features per node: 602 + - Classes: number of subreddit communities + + Data are split by post-day: first 20 days train, then validation (30%), test (rest). + + Parameters + ---------- + self_loop : bool + Add self-loop edges if True. + raw_dir, force_reload, verbose, transform : same as EasyGraphBuiltinDataset + """ + + def __init__( + self, + self_loop=False, + raw_dir=None, + force_reload=False, + verbose=True, + transform=None, + ): + name = "reddit" + url = "https://data.dgl.ai/dataset/reddit.zip" + self.self_loop = self_loop + super().__init__( + name=name, + url=url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def process(self): + # Expect two files extracted: reddit_data.npz & reddit_graph.npz + data = np.load(os.path.join(self.raw_path, "reddit_data.npz")) + feat = data["feature"] # shape [N, 602] + labels = data["label"] # shape [N] + split = data["node_types"] # 1=train,2=val,3=test + + # Load adjacency + adj = sp.load_npz(os.path.join(self.raw_path, "reddit_graph.npz")) + src, dst = adj.nonzero() + if self.self_loop: + self_loops = np.arange(adj.shape[0]) + src = np.concatenate([src, self_loops]) + dst = np.concatenate([dst, self_loops]) + edges = list(zip(src, dst)) + + # Build graph + g = eg.Graph() + g.add_edges_from(edges) + + # Assign node features, labels, and masks + for i in range(feat.shape[0]): + g.add_node( + i, + feat=feat[i], + label=int(labels[i]), + train_mask=(split[i] == 1), + val_mask=(split[i] == 2), + test_mask=(split[i] == 3), + ) + + self._g = g + self._num_classes = int(np.max(labels) + 1) + + if self.verbose: + print("Loaded Reddit dataset:") + print(f" NumNodes: {g.number_of_nodes()}") + print(f" NumEdges: {g.number_of_edges()}") + print(f" NumFeats: {feat.shape[1]}") + print(f" NumClasses: {self._num_classes}") + + def __getitem__(self, idx): + assert idx == 0, "RedditDataset only contains one graph" + return self._g if self.transform is None else self.transform(self._g) + + def __len__(self): + return 1 + + @property + def num_classes(self): + return self._num_classes diff --git a/easygraph/datasets/roadnet.py b/easygraph/datasets/roadnet.py new file mode 100644 index 0000000..1d7bfa8 --- /dev/null +++ b/easygraph/datasets/roadnet.py @@ -0,0 +1,107 @@ +"""RoadNet-CA Dataset + +This dataset represents the road network of California. +Nodes correspond to intersections, and edges represent roads connecting them. + +The data is undirected and unweighted. No features or labels are provided. + +Statistics: +- Nodes: 1,965,206 +- Edges: 2,766,607 +- Features: None +- Labels: None + +Reference: +J. Leskovec and A. Krevl, “SNAP Datasets: Stanford Large Network Dataset Collection,” +https://snap.stanford.edu/data/roadNet-CA.html +""" + +import gzip +import os +import shutil + +import easygraph as eg + +from easygraph.classes.graph import Graph + +from .graph_dataset_base import EasyGraphBuiltinDataset +from .utils import download + + +class RoadNetCADataset(EasyGraphBuiltinDataset): + r"""Road network of California (RoadNet-CA) + + Nodes are road intersections and edges are roads connecting them. + + Parameters + ---------- + raw_dir : str, optional + Directory to store the raw downloaded files. Default: None + force_reload : bool, optional + Whether to re-download and process the dataset. Default: False + verbose : bool, optional + Whether to print detailed processing logs. Default: True + transform : callable, optional + Optional transform to apply on the graph. + + Examples + -------- + >>> from easygraph.datasets import RoadNetCADataset + >>> dataset = RoadNetCADataset() + >>> g = dataset[0] + >>> print("Nodes:", g.number_of_nodes()) + >>> print("Edges:", g.number_of_edges()) + """ + + def __init__(self, raw_dir=None, force_reload=False, verbose=True, transform=None): + name = "roadNet-CA" + url = "https://snap.stanford.edu/data/roadNet-CA.txt.gz" + super(RoadNetCADataset, self).__init__( + name=name, + url=url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def download(self): + r"""Download and decompress the .txt.gz file.""" + compressed_path = os.path.join(self.raw_dir, self.name + ".txt.gz") + extracted_path = os.path.join(self.raw_path, self.name + ".txt") + + download(self.url, path=compressed_path) + + if not os.path.exists(self.raw_path): + os.makedirs(self.raw_path) + + with gzip.open(compressed_path, "rb") as f_in: + with open(extracted_path, "wb") as f_out: + shutil.copyfileobj(f_in, f_out) + + def process(self): + graph = eg.Graph() # Undirected road network + edge_list_path = os.path.join(self.raw_path, self.name + ".txt") + + with open(edge_list_path, "r") as f: + for line in f: + if line.startswith("#") or line.strip() == "": + continue + u, v = map(int, line.strip().split()) + graph.add_edge(u, v) + + self._g = graph + self._num_nodes = graph.number_of_nodes() + self._num_edges = graph.number_of_edges() + + if self.verbose: + print("Finished loading RoadNet-CA dataset.") + print(f" NumNodes: {self._num_nodes}") + print(f" NumEdges: {self._num_edges}") + + def __getitem__(self, idx): + assert idx == 0, "RoadNetCADataset only contains one graph" + return self._g if self._transform is None else self._transform(self._g) + + def __len__(self): + return 1 diff --git a/easygraph/datasets/samples/blogcatalog.mat b/easygraph/datasets/samples/blogcatalog.mat new file mode 100644 index 0000000..5b63e59 Binary files /dev/null and b/easygraph/datasets/samples/blogcatalog.mat differ diff --git a/easygraph/datasets/twitter_ego.py b/easygraph/datasets/twitter_ego.py new file mode 100644 index 0000000..7b63121 --- /dev/null +++ b/easygraph/datasets/twitter_ego.py @@ -0,0 +1,65 @@ +import gzip +import os + +import easygraph as eg + +from easygraph.datasets.graph_dataset_base import EasyGraphBuiltinDataset +from easygraph.datasets.utils import download +from easygraph.datasets.utils import extract_archive + + +class TwitterEgoDataset(EasyGraphBuiltinDataset): + r""" + Twitter Ego Network Dataset + + The Twitter dataset was collected from public sources and contains a large ego-network of Twitter users. + The combined network includes 81K edges among 81K users. + + Source: J. McAuley and J. Leskovec, Stanford SNAP, 2012 + URL: https://snap.stanford.edu/data/egonets-Twitter.html + File used: https://snap.stanford.edu/data/twitter_combined.txt.gz + """ + + def __init__(self): + super(TwitterEgoDataset, self).__init__( + name="twitter_ego", + url="https://snap.stanford.edu/data/twitter_combined.txt.gz", + force_reload=False, + ) + + def download(self): + gz_path = os.path.join(self.raw_path, "twitter_combined.txt.gz") + download(self.url, path=gz_path) + extract_archive(gz_path, self.raw_path) + + def process(self): + import gzip + + import easygraph as eg + + gz_path = os.path.join(self.raw_path, "twitter_combined.txt.gz") + txt_path = os.path.join(self.raw_path, "twitter_combined.txt") + + if not os.path.exists(txt_path): + with gzip.open(gz_path, "rt") as f_in, open(txt_path, "w") as f_out: + f_out.writelines(f_in) + + G = eg.Graph() + edge_count = 0 + with open(txt_path, "r") as f: + for line in f: + u, v = map(int, line.strip().split()) + G.add_edge(u, v) + edge_count += 1 + + self._graphs = [G] + self._graph = G + self._processed = True + + def __getitem__(self, idx): + if self._graph is not None: + return self._graph + elif self._graphs: + return self._graphs[idx] + else: + return None diff --git a/easygraph/datasets/utils.py b/easygraph/datasets/utils.py new file mode 100644 index 0000000..b48fb39 --- /dev/null +++ b/easygraph/datasets/utils.py @@ -0,0 +1,358 @@ +import errno +import hashlib +import numbers +import os + +from pathlib import Path + +import numpy as np +import requests +import torch as th + + +__all__ = [ + "download", + "extract_archive", + "get_download_dir", + "makedirs", + "generate_mask_tensor", +] + +import warnings + +from easygraph.utils.download import _retry + + +def _get_eg_url(file_url): + """Get EasyGraph online url for download.""" + eg_repo_url = "https://gitlab.com/easy-graph/" + repo_url = eg_repo_url + if repo_url[-1] != "/": + repo_url = repo_url + "/" + return repo_url + file_url + + +def _get_dgl_url(file_url): + """Get DGL online url for download.""" + dgl_repo_url = "https://data.dgl.ai/" + repo_url = os.environ.get("DGL_REPO", dgl_repo_url) + if repo_url[-1] != "/": + repo_url = repo_url + "/" + return repo_url + file_url + + +def _set_labels(G, labels): + index = 0 + for node in G.nodes: + G.add_node(node, label=labels[index]) + index += 1 + return G + + +def _set_features(G, features): + index = 0 + for node in G.nodes: + G.add_node(node, feat=features[index]) + index += 1 + return G + + +def nonzero_1d(input): + x = th.nonzero(input, as_tuple=False).squeeze() + return x if x.dim() == 1 else x.view(-1) + + +def tensor(data, dtype=None): + if isinstance(data, numbers.Number): + data = [data] + if isinstance(data, list) and len(data) > 0 and isinstance(data[0], th.Tensor): + # prevent GPU->CPU->GPU copies + if data[0].ndim == 0: + # zero dimension scalar tensors + return th.stack(data) + if isinstance(data, th.Tensor): + return th.as_tensor(data, dtype=dtype, device=data.device) + else: + return th.as_tensor(data, dtype=dtype) + + +def data_type_dict(): + return { + "float16": th.float16, + "float32": th.float32, + "float64": th.float64, + "uint8": th.uint8, + "int8": th.int8, + "int16": th.int16, + "int32": th.int32, + "int64": th.int64, + "bool": th.bool, + } + + +def download( + url, + path=None, + overwrite=True, + sha1_hash=None, + retries=5, + verify_ssl=True, + log=True, +): + """Download a given URL. + + Codes borrowed from mxnet/gluon/utils.py + + Parameters + ---------- + url : str + URL to download. + path : str, optional + Destination path to store downloaded file. By default stores to the + current directory with the same name as in url. + overwrite : bool, optional + Whether to overwrite the destination file if it already exists. + By default always overwrites the downloaded file. + sha1_hash : str, optional + Expected sha1 hash in hexadecimal digits. Will ignore existing file when hash is specified + but doesn't match. + retries : integer, default 5 + The number of times to attempt downloading in case of failure or non 200 return codes. + verify_ssl : bool, default True + Verify SSL certificates. + log : bool, default True + Whether to print the progress for download + + Returns + ------- + str + The file path of the downloaded file. + """ + if path is None: + fname = url.split("/")[-1] + # Empty filenames are invalid + assert fname, ( + "Can't construct file-name from this URL. " + "Please set the `path` option manually." + ) + else: + path = os.path.expanduser(path) + if os.path.isdir(path): + fname = os.path.join(path, url.split("/")[-1]) + else: + fname = path + assert retries >= 0, "Number of retries should be at least 0" + + if not verify_ssl: + warnings.warn( + "Unverified HTTPS request is being made (verify_ssl=False). " + "Adding certificate verification is strongly advised." + ) + + if ( + overwrite + or not os.path.exists(fname) + or (sha1_hash and not check_sha1(fname, sha1_hash)) + ): + dirname = os.path.dirname(os.path.abspath(os.path.expanduser(fname))) + if not os.path.exists(dirname): + os.makedirs(dirname) + while retries + 1 > 0: + # Disable pyling too broad Exception + # pylint: disable=W0703 + try: + if log: + print("Downloading %s from %s..." % (fname, url)) + r = requests.get(url, stream=True, verify=verify_ssl) + if r.status_code != 200: + raise RuntimeError("Failed downloading url %s" % url) + with open(fname, "wb") as f: + for chunk in r.iter_content(chunk_size=1024): + if chunk: # filter out keep-alive new chunks + f.write(chunk) + if sha1_hash and not check_sha1(fname, sha1_hash): + raise UserWarning( + "File {} is downloaded but the content hash does not match." + " The repo may be outdated or download may be incomplete. " + 'If the "repo_url" is overridden, consider switching to ' + "the default repo.".format(fname) + ) + break + except Exception as e: + retries -= 1 + if retries <= 0: + raise e + else: + if log: + print( + "download failed, retrying, {} attempt{} left".format( + retries, "s" if retries > 1 else "" + ) + ) + + return fname + + +def extract_archive(file, target_dir, overwrite=False): + """Extract archive file. + + Parameters + ---------- + file : str + Absolute path of the archive file. + target_dir : str + Target directory of the archive to be uncompressed. + overwrite : bool, default True + Whether to overwrite the contents inside the directory. + By default always overwrites. + """ + if os.path.exists(target_dir) and not overwrite: + return + print("Extracting file to {}".format(target_dir)) + if file.endswith(".tar.gz") or file.endswith(".tar") or file.endswith(".tgz"): + import tarfile + + with tarfile.open(file, "r") as archive: + archive.extractall(path=target_dir) + elif file.endswith(".gz"): + import gzip + import shutil + + with gzip.open(file, "rb") as f_in: + target_file = os.path.join(target_dir, os.path.basename(file)[:-3]) + with open(target_file, "wb") as f_out: + shutil.copyfileobj(f_in, f_out) + elif file.endswith(".zip"): + import zipfile + + with zipfile.ZipFile(file, "r") as archive: + archive.extractall(path=target_dir) + else: + raise Exception("Unrecognized file type: " + file) + + +def get_download_dir(): + """Get the absolute path to the download directory. + + Returns + ------- + dirname : str + Path to the download directory + """ + default_dir = os.path.join(os.path.expanduser("~"), ".EasyGraphData") + dirname = os.environ.get("EG_DOWNLOAD_DIR", default_dir) + if not os.path.exists(dirname): + os.makedirs(dirname) + return dirname + + +def makedirs(path): + try: + os.makedirs(os.path.expanduser(os.path.normpath(path))) + except OSError as e: + if e.errno != errno.EEXIST and os.path.isdir(path): + raise e + + +def check_sha1(filename, sha1_hash): + """Check whether the sha1 hash of the file content matches the expected hash. + + Codes borrowed from mxnet/gluon/utils.py + + Parameters + ---------- + filename : str + Path to the file. + sha1_hash : str + Expected sha1 hash in hexadecimal digits. + + Returns + ------- + bool + Whether the file content matches the expected hash. + """ + sha1 = hashlib.sha1() + with open(filename, "rb") as f: + while True: + data = f.read(1048576) + if not data: + break + sha1.update(data) + + return sha1.hexdigest() == sha1_hash + + +def generate_mask_tensor(mask): + """Generate mask tensor according to different backend + For torch, it will create a bool tensor + Parameters + ---------- + mask: numpy ndarray + input mask tensor + """ + assert isinstance( + mask, np.ndarray + ), "input for generate_mask_tensor should be an numpy ndarray" + return tensor(mask, dtype=data_type_dict()["bool"]) + + +def deprecate_property(old, new): + warnings.warn( + "Property {} will be deprecated, please use {} instead.".format(old, new) + ) + + +def check_file(file_path: Path, md5: str): + r"""Check if a file is valid. + + Args: + ``file_path`` (``Path``): The local path of the file. + ``md5`` (``str``): The md5 of the file. + + Raises: + FileNotFoundError: Not found the file. + """ + if not file_path.exists(): + raise FileNotFoundError(f"{file_path} does not exist.") + else: + with open(file_path, "rb") as f: + data = f.read() + cur_md5 = hashlib.md5(data).hexdigest() + return cur_md5 == md5 + + +def download_file(url: str, file_path: Path): + r"""Download a file from a url. + + Args: + ``url`` (``str``): the url of the file + ``file_path`` (``str``): the path to the file + """ + file_path.parent.mkdir(parents=True, exist_ok=True) + r = requests.get(url, stream=True, verify=True) + if r.status_code != 200: + raise requests.HTTPError(f"{url} is not accessible.") + with open(file_path, "wb") as f: + for chunk in r.iter_content(chunk_size=1024): + if chunk: + f.write(chunk) + + +@_retry(3) +def download_and_check(url: str, file_path: Path, md5: str): + r"""Download a file from a url and check its integrity. + + Args: + ``url`` (``str``): The url of the file. + ``file_path`` (``Path``): The path to the file. + ``md5`` (``str``): The md5 of the file. + """ + if not file_path.exists(): + download_file(url, file_path) + if not check_file(file_path, md5): + file_path.unlink() + raise ValueError( + f"{file_path} is corrupted. We will delete it, and try to download it" + " again." + ) + return True diff --git a/easygraph/datasets/web_google.py b/easygraph/datasets/web_google.py new file mode 100644 index 0000000..9759729 --- /dev/null +++ b/easygraph/datasets/web_google.py @@ -0,0 +1,118 @@ +"""Web-Google Dataset + +This dataset is a web graph based on Google's web pages and their hyperlink +structure, as crawled by the Stanford WebBase project in 2002. + +Each node represents a web page, and a directed edge from u to v indicates +a hyperlink from page u to page v. + +Statistics: +- Nodes: 875713 +- Edges: 5105039 +- Features: None +- Labels: None + +Reference: +J. Leskovec, A. Rajaraman, J. Ullman, “Mining of Massive Datasets.” +Dataset from SNAP: https://snap.stanford.edu/data/web-Google.html +""" + +import gzip +import os +import shutil + +import easygraph as eg + +from easygraph.classes.graph import Graph + +from .graph_dataset_base import EasyGraphBuiltinDataset +from .utils import download +from .utils import extract_archive + + +class WebGoogleDataset(EasyGraphBuiltinDataset): + r"""Web-Google hyperlink network dataset. + + Parameters + ---------- + raw_dir : str, optional + Directory to store the raw downloaded files. Default: None + force_reload : bool, optional + Whether to re-download and process the dataset. Default: False + verbose : bool, optional + Whether to print detailed processing logs. Default: True + transform : callable, optional + Optional transform to apply on the graph. + + Examples + -------- + >>> from easygraph.datasets import WebGoogleDataset + >>> dataset = WebGoogleDataset() + >>> g = dataset[0] + >>> print("Nodes:", g.number_of_nodes()) + >>> print("Edges:", g.number_of_edges()) + """ + + def __init__(self, raw_dir=None, force_reload=False, verbose=True, transform=None): + name = "web-Google" + url = "https://snap.stanford.edu/data/web-Google.txt.gz" + super(WebGoogleDataset, self).__init__( + name=name, + url=url, + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def download(self): + r"""Download and extract .gz edge list.""" + if self.url is not None: + file_path = os.path.join(self.raw_dir, self.name + ".txt.gz") + download(self.url, path=file_path) + extract_archive(file_path, self.raw_path) + + def process(self): + graph = eg.DiGraph() # Web-Google is directed + edge_list_path = os.path.join(self.raw_path, self.name + ".txt") + + with open(edge_list_path, "r") as f: + for line in f: + if line.startswith("#") or line.strip() == "": + continue + u, v = map(int, line.strip().split()) + graph.add_edge(u, v) + + self._g = graph + self._num_nodes = graph.number_of_nodes() + self._num_edges = graph.number_of_edges() + + if self.verbose: + print("Finished loading Web-Google dataset.") + print(f" NumNodes: {self._num_nodes}") + print(f" NumEdges: {self._num_edges}") + + def __getitem__(self, idx): + assert idx == 0, "WebGoogleDataset only contains one graph" + return self._g if self._transform is None else self._transform(self._g) + + def __len__(self): + return 1 + + def download(self): + r"""Download and decompress the .txt.gz file.""" + if self.url is not None: + compressed_path = os.path.join(self.raw_dir, self.name + ".txt.gz") + extracted_path = os.path.join(self.raw_path, self.name + ".txt") + + # Download .gz file + download(self.url, path=compressed_path) + + # Ensure output directory exists + if not os.path.exists(self.raw_path): + os.makedirs(self.raw_path) + + # Decompress manually + with gzip.open(compressed_path, "rb") as f_in: + with open(extracted_path, "wb") as f_out: + shutil.copyfileobj(f_in, f_out) diff --git a/easygraph/datasets/wiki_topcats.py b/easygraph/datasets/wiki_topcats.py new file mode 100644 index 0000000..9c337d5 --- /dev/null +++ b/easygraph/datasets/wiki_topcats.py @@ -0,0 +1,105 @@ +"""Wikipedia Top Categories Dataset (wiki-topcats) + +This dataset is a directed graph of Wikipedia articles restricted to +top-level categories (at least 100 articles), capturing the largest +strongly connected component. + +Statistics: +- Nodes: 1,791,489 +- Edges: 28,511,807 +- Categories: 17,364 +- Overlapping labels per node + +Source: +H. Yin, A. Benson, J. Leskovec, D. Gleich. +"Local Higher-order Graph Clustering", KDD 2017 +Data: https://snap.stanford.edu/data/wiki-topcats.html +""" + +import gzip +import os + +import easygraph as eg + +from easygraph.datasets.graph_dataset_base import EasyGraphBuiltinDataset +from easygraph.datasets.utils import download +from easygraph.datasets.utils import extract_archive + + +class WikiTopCatsDataset(EasyGraphBuiltinDataset): + """Wikipedia Top Categories Snapshot from 2011 (SNAP)""" + + def __init__(self, raw_dir=None, force_reload=False, verbose=True, transform=None): + super(WikiTopCatsDataset, self).__init__( + name="wiki_topcats", + url="https://snap.stanford.edu/data/wiki-topcats.txt.gz", + raw_dir=raw_dir, + force_reload=force_reload, + verbose=verbose, + transform=transform, + ) + + def download(self): + # Download the main graph file + gz_path = os.path.join(self.raw_dir, "wiki-topcats.txt.gz") + download(self.url, path=gz_path) + + # Also download category info and page names + cat_url = "https://snap.stanford.edu/data/wiki-topcats-categories.txt.gz" + names_url = "https://snap.stanford.edu/data/wiki-topcats-page-names.txt.gz" + download( + cat_url, path=os.path.join(self.raw_dir, "wiki-topcats-categories.txt.gz") + ) + download( + names_url, path=os.path.join(self.raw_dir, "wiki-topcats-page-names.txt.gz") + ) + + def process(self): + raw = self.raw_dir + + # Decompress and read edges + edge_gz = os.path.join(raw, "wiki-topcats.txt.gz") + edge_txt = os.path.join(raw, "wiki-topcats.txt") + if not os.path.exists(edge_txt): + with gzip.open(edge_gz, "rt") as fin, open(edge_txt, "w") as fout: + fout.writelines(fin) + G = eg.DiGraph() + edge_count = 0 + with open(edge_txt, "r") as f: + for line in f: + u, v = map(int, line.strip().split()) + G.add_edge(u, v) + edge_count += 1 + if self.verbose: + print(f"Loaded graph: {G.number_of_nodes()} nodes, {edge_count} edges") + + # Compress node names + names_gz = os.path.join(raw, "wiki-topcats-page-names.txt.gz") + names = {} + with gzip.open(names_gz, "rt") as f: + for idx, line in enumerate(f): + names[idx] = line.strip() + + # Load categories + cats_gz = os.path.join(raw, "wiki-topcats-categories.txt.gz") + labels = {} # mapping: node -> list of category strings + with gzip.open(cats_gz, "rt") as f: + for idx, line in enumerate(f): + categories = line.strip().split(";") + categories = [cat.strip() for cat in categories if cat.strip()] + labels[idx] = categories + + # Attach node features: empty, and node labels + for n in G.nodes: + G.add_node(n, name=names.get(n, ""), label=labels.get(n, [])) + + self._graph = G + self._graphs = [G] + self._processed = True + + def __getitem__(self, idx): + assert idx == 0 + return self._graph + + def __len__(self): + return 1 diff --git a/easygraph/exception.py b/easygraph/exception.py new file mode 100644 index 0000000..9a0b537 --- /dev/null +++ b/easygraph/exception.py @@ -0,0 +1,84 @@ +""" +********** +Exceptions +********** + +Base exceptions and errors for EasyGraph. +""" + +__all__ = [ + "HasACycle", + "NodeNotFound", + "EasyGraphAlgorithmError", + "EasyGraphException", + "EasyGraphError", + "EasyGraphNoCycle", + "EasyGraphNoPath", + "EasyGraphNotImplemented", + "EasyGraphPointlessConcept", + "EasyGraphUnbounded", + "EasyGraphUnfeasible", +] + + +class EasyGraphException(Exception): + """Base class for exceptions in EasyGraph.""" + + +class EasyGraphError(EasyGraphException): + """Exception for a serious error in EasyGraph""" + + +class EasyGraphPointlessConcept(EasyGraphException): + """Raised when a null graph is provided as input to an algorithm + that cannot use it. + + The null graph is sometimes considered a pointless concept [1]_, + thus the name of the exception. + + References + ---------- + .. [1] Harary, F. and Read, R. "Is the Null Graph a Pointless + Concept?" In Graphs and Combinatorics Conference, George + Washington University. New York: Springer-Verlag, 1973. + + """ + + +class EasyGraphAlgorithmError(EasyGraphException): + """Exception for unexpected termination of algorithms.""" + + +class EasyGraphUnfeasible(EasyGraphAlgorithmError): + """Exception raised by algorithms trying to solve a problem + instance that has no feasible solution.""" + + +class EasyGraphNoPath(EasyGraphUnfeasible): + """Exception for algorithms that should return a path when running + on graphs where such a path does not exist.""" + + +class EasyGraphNoCycle(EasyGraphUnfeasible): + """Exception for algorithms that should return a cycle when running + on graphs where such a cycle does not exist.""" + + +class HasACycle(EasyGraphException): + """Raised if a graph has a cycle when an algorithm expects that it + will have no cycles. + + """ + + +class EasyGraphUnbounded(EasyGraphAlgorithmError): + """Exception raised by algorithms trying to solve a maximization + or a minimization problem instance that is unbounded.""" + + +class EasyGraphNotImplemented(EasyGraphException): + """Exception raised by algorithms not implemented for a type of graph.""" + + +class NodeNotFound(EasyGraphException): + """Exception raised if requested node is not present in the graph""" diff --git a/easygraph/experiments/__init__.py b/easygraph/experiments/__init__.py new file mode 100644 index 0000000..8cc6a55 --- /dev/null +++ b/easygraph/experiments/__init__.py @@ -0,0 +1,10 @@ +try: + from .base import BaseTask + from .hypergraphs import HypergraphVertexClassificationTask + + +except: + print( + "Warning raise in module: experiments. Please install Pytorch before you use" + " functions related to nueral network" + ) diff --git a/easygraph/experiments/base.py b/easygraph/experiments/base.py new file mode 100644 index 0000000..7a9ccae --- /dev/null +++ b/easygraph/experiments/base.py @@ -0,0 +1,204 @@ +import abc +import logging +import shutil +import time + +from copy import deepcopy +from pathlib import Path +from typing import Callable +from typing import Optional +from typing import Union + +import optuna +import torch +import torch.nn as nn + +from easygraph.classes.base import load_structure +from easygraph.ml_metrics import BaseEvaluator +from easygraph.utils import default_log_formatter +from optuna.samplers import TPESampler + + +class BaseTask: + r"""The base class of Auto-experiment in EasyGraph. + + Args: + ``work_root`` (``Optional[Union[str, Path]]``): User's work root to store all studies. + ``data`` (``dict``): The dictionary to store input data that used in the experiment. + ``model_builder`` (``Callable``): The function to build a model with a fixed parameter ``trial``. + ``train_builder`` (``Callable``): The function to build a training configuration with two fixed parameters ``trial`` and ``model``. + ``evaluator`` (``eg.ml_metrics.BaseEvaluator``): The EasyGraph evaluator object to evaluate performance of the model in the experiment. + ``device`` (``torch.device``): The target device to run the experiment. + ``structure_builder`` (``Optional[Callable]``): The function to build a structure with a fixed parameter ``trial``. The structure can be ``eg.Graph``, ``eg.DiGraph``, ``eg.BiGraph``, and ``eg.Hypergraph``. + ``study_name`` (``Optional[str]``): The name of this study. If set to ``None``, the study name will be generated automatically according to current time. Defaults to ``None``. + ``overwrite`` (``bool``): The flag that whether to overwrite the existing study. Different studies are identified by the ``study_name``. Defaults to ``True``. + """ + + def __init__( + self, + work_root: Optional[Union[str, Path]], + data: dict, + model_builder: Callable, + train_builder: Callable, + evaluator: BaseEvaluator, + device: torch.device, + structure_builder: Optional[Callable] = None, + study_name: Optional[str] = None, + overwrite: bool = True, + ): + self.data = data + self.model_builder = model_builder + self.train_builder = train_builder + self.structure_builder = structure_builder + self.evaluator = evaluator + self.device = device + self.study = None + if study_name is None: + self.study_name = time.strftime("%Y-%m-%d--%H-%M-%S", time.localtime()) + else: + self.study_name = study_name + work_root = Path(work_root) + self.study_root = work_root / self.study_name + if overwrite and self.study_root.exists(): + shutil.rmtree(self.study_root) + self.log_file = self.study_root / "log.txt" + self.cache_root = self.study_root / "cache" + if not work_root.exists(): + if work_root.parent.exists(): + work_root.mkdir(exist_ok=True) + else: + raise ValueError(f"The work_root {work_root} does not exist.") + self.study_root.mkdir(exist_ok=True) + self.cache_root.mkdir(exist_ok=True) + # configure logging + self.logger = optuna.logging.get_logger("optuna") + self.logger.setLevel(logging.INFO) + out_file_handler = logging.FileHandler(self.log_file, mode="a", encoding="utf8") + out_file_handler.setFormatter(default_log_formatter()) + self.logger.addHandler(out_file_handler) + self.logger.info(f"Logs will be saved to {self.log_file.absolute()}") + self.logger.info( + f"Files in training will be saved in {self.study_root.absolute()}" + ) + + def experiment(self, trial: optuna.Trial): + r"""Run the experiment for a given trial. + + Args: + ``trial`` (``optuna.Trial``): The ``optuna.Trial`` object. + """ + if self.structure_builder is not None: + self.data["structure"] = self.structure_builder(trial).to(self.device) + model = self.model_builder(trial).to(self.device) + train_configs: dict = self.train_builder(trial, model) + assert "optimizer" in train_configs.keys() + optimizer = train_configs["optimizer"] + assert "criterion" in train_configs.keys() + criterion = train_configs["criterion"] + scheduler = train_configs.get("scheduler", None) + + best_model = None + if self.direction == "maximize": + best_score = -float("inf") + else: + best_score = float("inf") + for epoch in range(self.max_epoch): + self.train(self.data, model, optimizer, criterion) + val_res = self.validate(self.data, model) + trial.report(val_res, epoch) + if trial.should_prune(): + raise optuna.exceptions.TrialPruned() + if scheduler is not None: + scheduler.step() + if self.direction == "maximize": + if val_res > best_score: + best_score = val_res + best_model = deepcopy(model) + with open(self.cache_root / f"{trial.number}_model.pth", "wb") as f: + torch.save(best_model.cpu().state_dict(), f) + self.data["structure"].save(self.cache_root / f"{trial.number}_structure.dhg") + return best_score + + def _remove_cached_data(self): + r"""Remove cached models and structures.""" + if self.study is not None: + for filename in self.cache_root.glob("*"): + if filename.stem.split("_")[0] != str(self.study.best_trial.number): + filename.unlink() + + def run(self, max_epoch: int, num_trials: int = 1, direction: str = "maximize"): + r"""Run experiments with automatically hyper-parameter tuning. + + Args: + ``max_epoch`` (``int``): The maximum number of epochs to train for each experiment. + ``num_trials`` (``int``): The number of trials to run. Defaults to ``1``. + ``direction`` (``str``): The direction to optimize. Defaults to ``"maximize"``. + """ + self.logger.info(f"Random seed is {dhg.random.seed()}") + sampler = TPESampler(seed=dhg.random.seed()) + self.max_epoch, self.direction = max_epoch, direction + self.study = optuna.create_study(direction=direction, sampler=sampler) + self.study.optimize(self.experiment, n_trials=num_trials, timeout=600) + + self._remove_cached_data() + self.best_model = self.model_builder(self.study.best_trial) + self.best_model.load_state_dict( + torch.load(f"{self.cache_root}/{self.study.best_trial.number}_model.pth") + ) + self.best_structure = load_structure( + f"{self.cache_root}/{self.study.best_trial.number}_structure.dhg" + ) + self.best_model = self.best_model.to(self.device) + self.best_structure = self.best_structure.to(self.device) + + self.logger.info("Best trial:") + self.best_trial = self.study.best_trial + self.logger.info(f"\tValue: {self.best_trial.value:.3f}") + self.logger.info(f"\tParams:") + for key, value in self.best_trial.params.items(): + self.logger.info(f"\t\t{key} |-> {value}") + test_res = self.test() + self.logger.info(f"Final test results:") + for key, value in test_res.items(): + self.logger.info(f"\t{key} |-> {value:.3f}") + + @abc.abstractmethod + def train( + self, + data: dict, + model: nn.Module, + optimizer: torch.optim.Optimizer, + criterion: nn.Module, + ): + r"""Train model for one epoch. + + Args: + ``data`` (``dict``): The input data. + ``model`` (``nn.Module``): The model. + ``optimizer`` (``torch.optim.Optimizer``): The model optimizer. + ``criterion`` (``nn.Module``): The loss function. + """ + + @torch.no_grad() + @abc.abstractmethod + def validate( + self, + data: dict, + model: nn.Module, + ): + r"""Validate the model. + + Args: + ``data`` (``dict``): The input data. + ``model`` (``nn.Module``): The model. + """ + + @torch.no_grad() + @abc.abstractmethod + def test(self, data: Optional[dict] = None, model: Optional[nn.Module] = None): + r"""Test the model. + + Args: + ``data`` (``dict``, optional): The input data if set to ``None``, the specified ``data`` in the initialization of the experiments will be used. Defaults to ``None``. + ``model`` (``nn.Module``, optional): The model if set to ``None``, the trained best model will be used. Defaults to ``None``. + """ diff --git a/easygraph/experiments/hypergraphs/__init__.py b/easygraph/experiments/hypergraphs/__init__.py new file mode 100644 index 0000000..ad62064 --- /dev/null +++ b/easygraph/experiments/hypergraphs/__init__.py @@ -0,0 +1 @@ +from .hypergraph import HypergraphVertexClassificationTask diff --git a/easygraph/experiments/hypergraphs/hypergraph.py b/easygraph/experiments/hypergraphs/hypergraph.py new file mode 100644 index 0000000..3e3a89a --- /dev/null +++ b/easygraph/experiments/hypergraphs/hypergraph.py @@ -0,0 +1,121 @@ +from pathlib import Path +from typing import Callable +from typing import Optional +from typing import Union + +import optuna +import torch +import torch.nn as nn + +from easygraph.ml_metrics import BaseEvaluator + +from ..vertex_classification import VertexClassificationTask + + +class HypergraphVertexClassificationTask(VertexClassificationTask): + r"""The auto-experiment class for the vertex classification task on hypergraph. + + Args: + ``work_root`` (``Optional[Union[str, Path]]``): User's work root to store all studies. + ``data`` (``dict``): The dictionary to store input data that used in the experiment. + ``model_builder`` (``Callable``): The function to build a model with a fixed parameter ``trial``. + ``train_builder`` (``Callable``): The function to build a training configuration with two fixed parameters ``trial`` and ``model``. + ``evaluator`` (``easygraph.ml_metrics.BaseEvaluator``): The DHG evaluator object to evaluate performance of the model in the experiment. + ``device`` (``torch.device``): The target device to run the experiment. + ``structure_builder`` (``Optional[Callable]``): The function to build a structure with a fixed parameter ``trial``. The structure should be ``easygraph.Hypergraph``. + ``study_name`` (``Optional[str]``): The name of this study. If set to ``None``, the study name will be generated automatically according to current time. Defaults to ``None``. + ``overwrite`` (``bool``): The flag that whether to overwrite the existing study. Different studies are identified by the ``study_name``. Defaults to ``True``. + """ + + def __init__( + self, + work_root: Optional[Union[str, Path]], + data: dict, + model_builder: Callable, + train_builder: Callable, + evaluator: BaseEvaluator, + device: torch.device, + structure_builder: Optional[Callable] = None, + study_name: Optional[str] = None, + overwrite: bool = True, + ): + super().__init__( + work_root, + data, + model_builder, + train_builder, + evaluator, + device, + structure_builder=structure_builder, + study_name=study_name, + overwrite=overwrite, + ) + + def to(self, device: torch.device): + r"""Move the input data to the target device. + + Args: + ``device`` (``torch.device``): The specified target device to store the input data. + """ + return super().to(device) + + @property + def vars_for_DL(self): + r"""Return a name list for available variables for deep learning in the vertex classification on hypergraph. The name list includes ``features``, ``structure``, ``labels``, ``train_mask``, ``val_mask``, and ``test_mask``. + """ + return super().vars_for_DL + + def experiment(self, trial: optuna.Trial): + r"""Run the experiment for a given trial. + + Args: + ``trial`` (``optuna.Trial``): The ``optuna.Trial`` object. + """ + return super().experiment(trial) + + def run(self, max_epoch: int, num_trials: int = 1, direction: str = "maximize"): + r"""Run experiments with automatically hyper-parameter tuning. + + Args: + ``max_epoch`` (``int``): The maximum number of epochs to train for each experiment. + ``num_trials`` (``int``): The number of trials to run. Defaults to ``1``. + ``direction`` (``str``): The direction to optimize. Defaults to ``"maximize"``. + """ + return super().run(max_epoch, num_trials, direction) + + def train( + self, + data: dict, + model: nn.Module, + optimizer: torch.optim.Optimizer, + criterion: nn.Module, + ): + r"""Train model for one epoch. + + Args: + ``data`` (``dict``): The input data. + ``model`` (``nn.Module``): The model. + ``optimizer`` (``torch.optim.Optimizer``): The model optimizer. + ``criterion`` (``nn.Module``): The loss function. + """ + return super().train(data, model, optimizer, criterion) + + @torch.no_grad() + def validate(self, data: dict, model: nn.Module): + r"""Validate the model. + + Args: + ``data`` (``dict``): The input data. + ``model`` (``nn.Module``): The model. + """ + return super().validate(data, model) + + @torch.no_grad() + def test(self, data: Optional[dict] = None, model: Optional[nn.Module] = None): + r"""Test the model. + + Args: + ``data`` (``dict``, optional): The input data if set to ``None``, the specified ``data`` in the intialization of the experiments will be used. Defaults to ``None``. + ``model`` (``nn.Module``, optional): The model if set to ``None``, the trained best model will be used. Defaults to ``None``. + """ + return super().test(data, model) diff --git a/easygraph/experiments/vertex_classification.py b/easygraph/experiments/vertex_classification.py new file mode 100644 index 0000000..3759d16 --- /dev/null +++ b/easygraph/experiments/vertex_classification.py @@ -0,0 +1,166 @@ +from pathlib import Path +from typing import Callable +from typing import Optional +from typing import Union + +import optuna +import torch +import torch.nn as nn + +from easygraph.ml_metrics import BaseEvaluator + +from .base import BaseTask + + +class VertexClassificationTask(BaseTask): + r"""The auto-experiment class for the vertex classification task. + + Args: + ``work_root`` (``Optional[Union[str, Path]]``): User's work root to store all studies. + ``data`` (``dict``): The dictionary to store input data that used in the experiment. + ``model_builder`` (``Callable``): The function to build a model with a fixed parameter ``trial``. + ``train_builder`` (``Callable``): The function to build a training configuration with two fixed parameters ``trial`` and ``model``. + ``evaluator`` (``eg.ml_metrics.BaseEvaluator``): The DHG evaluator object to evaluate performance of the model in the experiment. + ``device`` (``torch.device``): The target device to run the experiment. + ``structure_builder`` (``Optional[Callable]``): The function to build a structure with a fixed parameter ``trial``. The structure can be ``eg.Hypergraph``. + ``study_name`` (``Optional[str]``): The name of this study. If set to ``None``, the study name will be generated automatically according to current time. Defaults to ``None``. + ``overwrite`` (``bool``): The flag that whether to overwrite the existing study. Different studies are identified by the ``study_name``. Defaults to ``True``. + """ + + def __init__( + self, + work_root: Optional[Union[str, Path]], + data: dict, + model_builder: Callable, + train_builder: Callable, + evaluator: BaseEvaluator, + device: torch.device, + structure_builder: Optional[Callable] = None, + study_name: Optional[str] = None, + overwrite: bool = True, + ): + super().__init__( + work_root, + data, + model_builder, + train_builder, + evaluator, + device, + structure_builder=structure_builder, + study_name=study_name, + overwrite=overwrite, + ) + self.to(self.device) + + def to(self, device: torch.device): + r"""Move the input data to the target device. + + Args: + ``device`` (``torch.device``): The specified target device to store the input data. + """ + self.device = device + for name in self.vars_for_DL: + if name in self.data.keys(): + self.data[name] = self.data[name].to(device) + return self + + @property + def vars_for_DL(self): + r"""Return a name list for available variables for deep learning in the vertex classification task. The name list includes ``features``, ``structure``, ``labels``, ``train_mask``, ``val_mask``, and ``test_mask``. + """ + return ( + "features", + "structure", + "labels", + "train_mask", + "val_mask", + "test_mask", + ) + + def experiment(self, trial: optuna.Trial): + r"""Run the experiment for a given trial. + + Args: + ``trial`` (``optuna.Trial``): The ``optuna.Trial`` object. + """ + return super().experiment(trial) + + def run(self, max_epoch: int, num_trials: int = 1, direction: str = "maximize"): + r"""Run experiments with automatically hyper-parameter tuning. + + Args: + ``max_epoch`` (``int``): The maximum number of epochs to train for each experiment. + ``num_trials`` (``int``): The number of trials to run. Defaults to ``1``. + ``direction`` (``str``): The direction to optimize. Defaults to ``"maximize"``. + """ + return super().run(max_epoch, num_trials, direction) + + def train( + self, + data: dict, + model: nn.Module, + optimizer: torch.optim.Optimizer, + criterion: nn.Module, + ): + r"""Train model for one epoch. + + Args: + ``data`` (``dict``): The input data. + ``model`` (``nn.Module``): The model. + ``optimizer`` (``torch.optim.Optimizer``): The model optimizer. + ``criterion`` (``nn.Module``): The loss function. + """ + features, structure = data["features"], data["structure"] + train_mask, labels = data["train_mask"], data["labels"] + model.train() + optimizer.zero_grad() + outputs = model(features, structure) + loss = criterion( + outputs[train_mask], + labels[train_mask], + ) + loss.backward() + optimizer.step() + + @torch.no_grad() + def validate(self, data: dict, model: nn.Module): + r"""Validate the model. + + Args: + ``data`` (``dict``): The input data. + ``model`` (``nn.Module``): The model. + """ + features, structure = data["features"], data["structure"] + val_mask, labels = data["val_mask"], data["labels"] + model.eval() + outputs = model(features, structure) + res = self.evaluator.validate(labels[val_mask], outputs[val_mask]) + return res + + @torch.no_grad() + def test(self, data: Optional[dict] = None, model: Optional[nn.Module] = None): + r"""Test the model. + + Args: + ``data`` (``dict``, optional): The input data if set to ``None``, the specified ``data`` in the initialization of the experiments will be used. Defaults to ``None``. + ``model`` (``nn.Module``, optional): The model if set to ``None``, the trained best model will be used. Defaults to ``None``. + """ + if data is None: + features, structure = self.data["features"], self.best_structure + test_mask, labels = self.data["test_mask"], self.data["labels"] + else: + features, structure = ( + data["features"].to(self.device), + data["structure"].to(self.device), + ) + test_mask, labels = ( + data["test_mask"].to(self.device), + data["labels"].to(self.device), + ) + if model is None: + model = self.best_model + model = model.to(self.device) + model.eval() + outputs = model(features, structure) + res = self.evaluator.test(labels[test_mask], outputs[test_mask]) + return res diff --git a/easygraph/functions/__init__.py b/easygraph/functions/__init__.py new file mode 100644 index 0000000..9e4b095 --- /dev/null +++ b/easygraph/functions/__init__.py @@ -0,0 +1,21 @@ +from easygraph.functions.basic import * +from easygraph.functions.centrality import * +from easygraph.functions.community import * +from easygraph.functions.components import * +from easygraph.functions.core import * +from easygraph.functions.drawing import * +from easygraph.functions.graph_embedding import * +from easygraph.functions.graph_generator import * +from easygraph.functions.isolate import * +from easygraph.functions.path import * +from easygraph.functions.structural_holes import * + + +try: + from easygraph.functions.hypergraph import * +except: + print( + "Warning raise in module:model.Please install " + "Pytorch before you use functions" + " related to Hypergraph" + ) diff --git a/easygraph/functions/basic/__init__.py b/easygraph/functions/basic/__init__.py new file mode 100644 index 0000000..576caea --- /dev/null +++ b/easygraph/functions/basic/__init__.py @@ -0,0 +1,4 @@ +from .avg_degree import * +from .cluster import * +from .localassort import * +from .predecessor_path_based import * diff --git a/easygraph/functions/basic/avg_degree.py b/easygraph/functions/basic/avg_degree.py new file mode 100644 index 0000000..ed75477 --- /dev/null +++ b/easygraph/functions/basic/avg_degree.py @@ -0,0 +1,31 @@ +__all__ = [ + "average_degree", +] + + +def average_degree(G) -> float: + """Returns the average degree of the graph. + + Parameters + ---------- + G : graph + A EasyGraph graph + + Returns + ------- + average degree : float + The average degree of the graph. + + Notes + ----- + Self loops are counted twice in the total degree of a node. + + Examples + -------- + >>> G = eg.Graph() # or DiGraph, MultiGraph, MultiDiGraph, etc + >>> G.add_edge(1, 2) + >>> G.add_edge(2, 3) + >>> eg.average_degree(G) + 1.3333333333333333 + """ + return G.number_of_edges() / G.number_of_nodes() * 2 diff --git a/easygraph/functions/basic/cluster.py b/easygraph/functions/basic/cluster.py new file mode 100644 index 0000000..01a053b --- /dev/null +++ b/easygraph/functions/basic/cluster.py @@ -0,0 +1,559 @@ +from collections import Counter +from itertools import chain + +import numpy as np + +from easygraph.utils.decorators import hybrid +from easygraph.utils.decorators import not_implemented_for +from easygraph.utils.misc import split +from easygraph.utils.misc import split_len + + +__all__ = ["average_clustering", "clustering"] + + +def _local_weighted_triangles_and_degree_iter_parallel( + nodes_nbrs, G, weight, max_weight +): + ret = [] + + def wt(u, v): + return G[u][v].get(weight, 1) / max_weight + + for i, nbrs in nodes_nbrs: + inbrs = set(nbrs) - {i} + weighted_triangles = 0 + seen = set() + for j in inbrs: + seen.add(j) + # This avoids counting twice -- we double at the end. + jnbrs = set(G[j]) - seen + # Only compute the edge weight once, before the inner inner + # loop. + wij = wt(i, j) + weighted_triangles += sum( + np.cbrt([(wij * wt(j, k) * wt(k, i)) for k in inbrs & jnbrs]) + ) + ret.append((i, len(inbrs), 2 * weighted_triangles)) + return ret + + +@not_implemented_for("multigraph") +def _weighted_triangles_and_degree_iter(G, nodes=None, weight="weight", n_workers=None): + """Return an iterator of (node, degree, weighted_triangles). + + Used for weighted clustering. + Note: this returns the geometric average weight of edges in the triangle. + Also, each triangle is counted twice (each direction). + So you may want to divide by 2. + + """ + + if weight is None or G.number_of_edges() == 0: + max_weight = 1 + else: + max_weight = max(d.get(weight, 1) for u, v, d in G.edges) + if nodes is None: + nodes_nbrs = G.adj.items() + else: + nodes_nbrs = ((n, G[n]) for n in G.nbunch_iter(nodes)) + + def wt(u, v): + return G[u][v].get(weight, 1) / max_weight + + if n_workers is not None: + import random + + from functools import partial + from multiprocessing import Pool + + _local_weighted_triangles_and_degree_iter_function = partial( + _local_weighted_triangles_and_degree_iter_parallel, + G=G, + weight=weight, + max_weight=max_weight, + ) + nodes_nbrs = list(nodes_nbrs) + random.shuffle(nodes_nbrs) + if len(nodes_nbrs) > n_workers * 30000: + nodes_nbrs = split_len(nodes, step=30000) + else: + nodes_nbrs = split(nodes_nbrs, n_workers) + with Pool(n_workers) as p: + ret = p.imap(_local_weighted_triangles_and_degree_iter_function, nodes_nbrs) + for r in ret: + for x in r: + yield x + else: + for i, nbrs in nodes_nbrs: + inbrs = set(nbrs) - {i} + weighted_triangles = 0 + seen = set() + for j in inbrs: + seen.add(j) + # This avoids counting twice -- we double at the end. + jnbrs = set(G[j]) - seen + # Only compute the edge weight once, before the inner inner + # loop. + wij = wt(i, j) + weighted_triangles += sum( + np.cbrt([(wij * wt(j, k) * wt(k, i)) for k in inbrs & jnbrs]) + ) + yield (i, len(inbrs), 2 * weighted_triangles) + + +def _local_directed_weighted_triangles_and_degree_parallel( + nodes_nbrs, G, weight, max_weight +): + ret = [] + + def wt(u, v): + return G[u][v].get(weight, 1) / max_weight + + for i, preds, succs in nodes_nbrs: + ipreds = set(preds) - {i} + isuccs = set(succs) - {i} + + directed_triangles = 0 + for j in ipreds: + jpreds = set(G._pred[j]) - {j} + jsuccs = set(G._adj[j]) - {j} + directed_triangles += sum( + np.cbrt([(wt(j, i) * wt(k, i) * wt(k, j)) for k in ipreds & jpreds]) + ) + directed_triangles += sum( + np.cbrt([(wt(j, i) * wt(k, i) * wt(j, k)) for k in ipreds & jsuccs]) + ) + directed_triangles += sum( + np.cbrt([(wt(j, i) * wt(i, k) * wt(k, j)) for k in isuccs & jpreds]) + ) + directed_triangles += sum( + np.cbrt([(wt(j, i) * wt(i, k) * wt(j, k)) for k in isuccs & jsuccs]) + ) + + for j in isuccs: + jpreds = set(G._pred[j]) - {j} + jsuccs = set(G._adj[j]) - {j} + directed_triangles += sum( + np.cbrt([(wt(i, j) * wt(k, i) * wt(k, j)) for k in ipreds & jpreds]) + ) + directed_triangles += sum( + np.cbrt([(wt(i, j) * wt(k, i) * wt(j, k)) for k in ipreds & jsuccs]) + ) + directed_triangles += sum( + np.cbrt([(wt(i, j) * wt(i, k) * wt(k, j)) for k in isuccs & jpreds]) + ) + directed_triangles += sum( + np.cbrt([(wt(i, j) * wt(i, k) * wt(j, k)) for k in isuccs & jsuccs]) + ) + + dtotal = len(ipreds) + len(isuccs) + dbidirectional = len(ipreds & isuccs) + ret.append([i, dtotal, dbidirectional, directed_triangles]) + return ret + + +@not_implemented_for("multigraph") +def _directed_weighted_triangles_and_degree_iter( + G, nodes=None, weight="weight", n_workers=None +): + """Return an iterator of + (node, total_degree, reciprocal_degree, directed_weighted_triangles). + + Used for directed weighted clustering. + Note that unlike `_weighted_triangles_and_degree_iter()`, this function counts + directed triangles so does not count triangles twice. + + """ + + if weight is None or G.number_of_edges() == 0: + max_weight = 1 + else: + max_weight = max(d.get(weight, 1) for u, v, d in G.edges) + + nodes_nbrs = ((n, G._pred[n], G._adj[n]) for n in G.nbunch_iter(nodes)) + + def wt(u, v): + return G[u][v].get(weight, 1) / max_weight + + if n_workers is not None: + import random + + from functools import partial + from multiprocessing import Pool + + _local_directed_weighted_triangles_and_degree_function = partial( + _local_directed_weighted_triangles_and_degree_parallel, + G=G, + weight=weight, + max_weight=max_weight, + ) + nodes_nbrs = list(nodes_nbrs) + random.shuffle(nodes_nbrs) + if len(nodes_nbrs) > n_workers * 30000: + nodes_nbrs = split_len(nodes, step=30000) + else: + nodes_nbrs = split(nodes_nbrs, n_workers) + with Pool(n_workers) as p: + ret = p.imap( + _local_directed_weighted_triangles_and_degree_function, nodes_nbrs + ) + for r in ret: + for x in r: + yield x + + else: + for i, preds, succs in nodes_nbrs: + ipreds = set(preds) - {i} + isuccs = set(succs) - {i} + + directed_triangles = 0 + for j in ipreds: + jpreds = set(G._pred[j]) - {j} + jsuccs = set(G._adj[j]) - {j} + directed_triangles += sum( + np.cbrt([(wt(j, i) * wt(k, i) * wt(k, j)) for k in ipreds & jpreds]) + ) + directed_triangles += sum( + np.cbrt([(wt(j, i) * wt(k, i) * wt(j, k)) for k in ipreds & jsuccs]) + ) + directed_triangles += sum( + np.cbrt([(wt(j, i) * wt(i, k) * wt(k, j)) for k in isuccs & jpreds]) + ) + directed_triangles += sum( + np.cbrt([(wt(j, i) * wt(i, k) * wt(j, k)) for k in isuccs & jsuccs]) + ) + + for j in isuccs: + jpreds = set(G._pred[j]) - {j} + jsuccs = set(G._adj[j]) - {j} + directed_triangles += sum( + np.cbrt([(wt(i, j) * wt(k, i) * wt(k, j)) for k in ipreds & jpreds]) + ) + directed_triangles += sum( + np.cbrt([(wt(i, j) * wt(k, i) * wt(j, k)) for k in ipreds & jsuccs]) + ) + directed_triangles += sum( + np.cbrt([(wt(i, j) * wt(i, k) * wt(k, j)) for k in isuccs & jpreds]) + ) + directed_triangles += sum( + np.cbrt([(wt(i, j) * wt(i, k) * wt(j, k)) for k in isuccs & jsuccs]) + ) + + dtotal = len(ipreds) + len(isuccs) + dbidirectional = len(ipreds & isuccs) + yield (i, dtotal, dbidirectional, directed_triangles) + + +def average_clustering(G, nodes=None, weight=None, count_zeros=True, n_workers=None): + r"""Compute the average clustering coefficient for the graph G. + + The clustering coefficient for the graph is the average, + + .. math:: + + C = \frac{1}{n}\sum_{v \in G} c_v, + + where :math:`n` is the number of nodes in `G`. + + Parameters + ---------- + G : graph + + nodes : container of nodes, optional (default=all nodes in G) + Compute average clustering for nodes in this container. + + weight : string or None, optional (default=None) + The edge attribute that holds the numerical value used as a weight. + If None, then each edge has weight 1. + + count_zeros : bool + If False include only the nodes with nonzero clustering in the average. + + Returns + ------- + avg : float + Average clustering + + Examples + -------- + >>> G = eg.complete_graph(5) + >>> print(eg.average_clustering(G)) + 1.0 + + Notes + ----- + This is a space saving routine; it might be faster + to use the clustering function to get a list and then take the average. + + Self loops are ignored. + + References + ---------- + .. [1] Generalizations of the clustering coefficient to weighted + complex networks by J. Saramäki, M. Kivelä, J.-P. Onnela, + K. Kaski, and J. Kertész, Physical Review E, 75 027105 (2007). + http://jponnela.com/web_documents/a9.pdf + .. [2] Marcus Kaiser, Mean clustering coefficients: the role of isolated + nodes and leafs on clustering measures for small-world networks. + https://arxiv.org/abs/0802.2512 + """ + c = clustering(G, nodes, weight=weight, n_workers=n_workers).values() + if not count_zeros: + c = [v for v in c if abs(v) > 0] + return sum(c) / len(c) + + +def _local_directed_triangles_and_degree_iter_parallel(nodes_nbrs, G): + ret = [] + for i, preds, succs in nodes_nbrs: + ipreds = set(preds) - {i} + isuccs = set(succs) - {i} + + directed_triangles = 0 + for j in chain(ipreds, isuccs): + jpreds = set(G._pred[j]) - {j} + jsuccs = set(G._adj[j]) - {j} + directed_triangles += sum( + 1 + for k in chain( + (ipreds & jpreds), + (ipreds & jsuccs), + (isuccs & jpreds), + (isuccs & jsuccs), + ) + ) + dtotal = len(ipreds) + len(isuccs) + dbidirectional = len(ipreds & isuccs) + ret.append((i, dtotal, dbidirectional, directed_triangles)) + return ret + + +@not_implemented_for("multigraph") +def _directed_triangles_and_degree_iter(G, nodes=None, n_workers=None): + """Return an iterator of + (node, total_degree, reciprocal_degree, directed_triangles). + + Used for directed clustering. + Note that unlike `_triangles_and_degree_iter()`, this function counts + directed triangles so does not count triangles twice. + + """ + nodes_nbrs = ((n, G._pred[n], G._adj[n]) for n in G.nbunch_iter(nodes)) + + if n_workers is not None: + import random + + from functools import partial + from multiprocessing import Pool + + _local_directed_triangles_and_degree_iter_parallel_function = partial( + _local_directed_triangles_and_degree_iter_parallel, G=G + ) + nodes_nbrs = list(nodes_nbrs) + random.shuffle(nodes_nbrs) + if len(nodes_nbrs) > n_workers * 30000: + nodes_nbrs = split_len(nodes_nbrs, step=30000) + else: + nodes_nbrs = split(nodes_nbrs, n_workers) + + with Pool(n_workers) as p: + ret = p.imap( + _local_directed_triangles_and_degree_iter_parallel_function, nodes_nbrs + ) + for r in ret: + for x in r: + yield x + else: + for i, preds, succs in nodes_nbrs: + ipreds = set(preds) - {i} + isuccs = set(succs) - {i} + + directed_triangles = 0 + for j in chain(ipreds, isuccs): + jpreds = set(G._pred[j]) - {j} + jsuccs = set(G._adj[j]) - {j} + directed_triangles += sum( + 1 + for k in chain( + (ipreds & jpreds), + (ipreds & jsuccs), + (isuccs & jpreds), + (isuccs & jsuccs), + ) + ) + dtotal = len(ipreds) + len(isuccs) + dbidirectional = len(ipreds & isuccs) + yield (i, dtotal, dbidirectional, directed_triangles) + + +def _local_triangles_and_degree_iter_function_parallel(nodes_nbrs, G): + ret = [] + for v, v_nbrs in nodes_nbrs: + vs = set(v_nbrs) - {v} + gen_degree = Counter(len(vs & (set(G[w]) - {w})) for w in vs) + ntriangles = sum(k * val for k, val in gen_degree.items()) + ret.append((v, len(vs), ntriangles, gen_degree)) + return ret + + +@not_implemented_for("multigraph") +def _triangles_and_degree_iter(G, nodes=None, n_workers=None): + """Return an iterator of (node, degree, triangles, generalized degree). + + This double counts triangles so you may want to divide by 2. + See degree(), triangles() and generalized_degree() for definitions + and details. + + """ + if nodes is None: + nodes_nbrs = G.adj.items() + else: + nodes_nbrs = ((n, G[n]) for n in G.nbunch_iter(nodes)) + + if n_workers is not None: + import random + + from functools import partial + from multiprocessing import Pool + + _local_triangles_and_degree_iter_function = partial( + _local_triangles_and_degree_iter_function_parallel, G=G + ) + nodes_nbrs = list(nodes_nbrs) + random.shuffle(nodes_nbrs) + if len(nodes_nbrs) > n_workers * 30000: + nodes_nbrs = split_len(nodes_nbrs, step=30000) + else: + nodes_nbrs = split(nodes_nbrs, n_workers) + + with Pool(n_workers) as p: + ret = p.imap(_local_triangles_and_degree_iter_function, nodes_nbrs) + for r in ret: + for x in r: + yield x + else: + for v, v_nbrs in nodes_nbrs: + vs = set(v_nbrs) - {v} + gen_degree = Counter(len(vs & (set(G[w]) - {w})) for w in vs) + ntriangles = sum(k * val for k, val in gen_degree.items()) + yield (v, len(vs), ntriangles, gen_degree) + + +@hybrid("cpp_clustering") +def clustering(G, nodes=None, weight=None, n_workers=None): + r"""Compute the clustering coefficient for nodes. + + For unweighted graphs, the clustering of a node :math:`u` + is the fraction of possible triangles through that node that exist, + + .. math:: + + c_u = \frac{2 T(u)}{deg(u)(deg(u)-1)}, + + where :math:`T(u)` is the number of triangles through node :math:`u` and + :math:`deg(u)` is the degree of :math:`u`. + + For weighted graphs, there are several ways to define clustering [1]_. + the one used here is defined + as the geometric average of the subgraph edge weights [2]_, + + .. math:: + + c_u = \frac{1}{deg(u)(deg(u)-1))} + \sum_{vw} (\hat{w}_{uv} \hat{w}_{uw} \hat{w}_{vw})^{1/3}. + + The edge weights :math:`\hat{w}_{uv}` are normalized by the maximum weight + in the network :math:`\hat{w}_{uv} = w_{uv}/\max(w)`. + + The value of :math:`c_u` is assigned to 0 if :math:`deg(u) < 2`. + + Additionally, this weighted definition has been generalized to support negative edge weights [3]_. + + For directed graphs, the clustering is similarly defined as the fraction + of all possible directed triangles or geometric average of the subgraph + edge weights for unweighted and weighted directed graph respectively [4]_. + + .. math:: + + c_u = \frac{2}{deg^{tot}(u)(deg^{tot}(u)-1) - 2deg^{\leftrightarrow}(u)} + T(u), + + where :math:`T(u)` is the number of directed triangles through node + :math:`u`, :math:`deg^{tot}(u)` is the sum of in degree and out degree of + :math:`u` and :math:`deg^{\leftrightarrow}(u)` is the reciprocal degree of + :math:`u`. + + + Parameters + ---------- + G : graph + + nodes : container of nodes, optional (default=all nodes in G) + Compute clustering for nodes in this container. + + weight : string or None, optional (default=None) + The edge attribute that holds the numerical value used as a weight. + If None, then each edge has weight 1. + + Returns + ------- + out : float, or dictionary + Clustering coefficient at specified nodes + + Examples + -------- + >>> G = eg.complete_graph(5) + >>> print(eg.clustering(G, 0)) + 1.0 + >>> print(eg.clustering(G)) + {0: 1.0, 1: 1.0, 2: 1.0, 3: 1.0, 4: 1.0} + + Notes + ----- + Self loops are ignored. + + References + ---------- + .. [1] Generalizations of the clustering coefficient to weighted + complex networks by J. Saramäki, M. Kivelä, J.-P. Onnela, + K. Kaski, and J. Kertész, Physical Review E, 75 027105 (2007). + http://jponnela.com/web_documents/a9.pdf + .. [2] Intensity and coherence of motifs in weighted complex + networks by J. P. Onnela, J. Saramäki, J. Kertész, and K. Kaski, + Physical Review E, 71(6), 065103 (2005). + .. [3] Generalization of Clustering Coefficients to Signed Correlation Networks + by G. Costantini and M. Perugini, PloS one, 9(2), e88669 (2014). + .. [4] Clustering in complex directed networks by G. Fagiolo, + Physical Review E, 76(2), 026107 (2007). + """ + + if G.is_directed(): + if weight is not None: + td_iter = _directed_weighted_triangles_and_degree_iter( + G, nodes, weight, n_workers=n_workers + ) + clusterc = { + v: 0 if t == 0 else t / ((dt * (dt - 1) - 2 * db) * 2) + for v, dt, db, t in td_iter + } + else: + td_iter = _directed_triangles_and_degree_iter(G, nodes, n_workers=n_workers) + clusterc = { + v: 0 if t == 0 else t / ((dt * (dt - 1) - 2 * db) * 2) + for v, dt, db, t in td_iter + } + else: + # The formula 2*T/(d*(d-1)) from docs is t/(d*(d-1)) here b/c t==2*T + if weight is not None: + td_iter = _weighted_triangles_and_degree_iter( + G, nodes, weight, n_workers=n_workers + ) + clusterc = {v: 0 if t == 0 else t / (d * (d - 1)) for v, d, t in td_iter} + else: + td_iter = _triangles_and_degree_iter(G, nodes, n_workers=n_workers) + clusterc = {v: 0 if t == 0 else t / (d * (d - 1)) for v, d, t, _ in td_iter} + if nodes in G: + # Return the value of the sole entry in the dictionary. + return clusterc[nodes] + return clusterc diff --git a/easygraph/functions/basic/localassort.py b/easygraph/functions/basic/localassort.py new file mode 100644 index 0000000..865acff --- /dev/null +++ b/easygraph/functions/basic/localassort.py @@ -0,0 +1,226 @@ +import easygraph as eg +import numpy as np +import scipy.sparse as sparse + + +__all__ = [ + "localAssort", +] + + +def localAssort( + edgelist, node_attr, pr=np.arange(0.0, 1.0, 0.1), undir=True, missingValue=-1 +): + """Calculate the multiscale assortativity. + You must ensure that the node index and node attribute index start from 0 + Parameters + ---------- + edgelist : array_like + the network represented as an edge list, + i.e., a E x 2 array of node pairs + node_attr : array_like + n length array of node attribute values + pr : array, optional + array of one minus restart probabilities for the random walk in + calculating the personalised pagerank. The largest of these values + determines the accuracy of the TotalRank vector max(pr) -> 1 is more + accurate (default: [0, .1, .2, .3, .4, .5, .6, .7, .8, .9]) + undir : bool, optional + indicate if network is undirected (default: True) + missingValue : int, optional + token to indicate missing attribute values (default: -1) + Returns + ------- + assortM : array_like + n x len(pr) array of local assortativities, each column corresponds to + a value of the input restart probabilities, pr. Note if only number of + restart probabilties is greater than one (i.e., len(pr) > 1). + assortT : array_like + n length array of multiscale assortativities + Z : array_like + N length array of per-node confidence scores + References + ---------- + For full details see [1]_ + .. [1] Peel, L., Delvenne, J. C., & Lambiotte, R. (2018). "Multiscale + mixing patterns in networks.' PNAS, 115(16), 4057-4062. + """ + # number of nodes + n = len(node_attr) + + # number of nodes with complete attribute + ncomp = (node_attr != missingValue).sum() + # number of edges + m = len(edgelist) + # construct adjacency matrix and calculate degree sequence + A, degree = createA(edgelist, n, undir) + + # construct diagonal inverse degree matrix + D = sparse.diags(1.0 / degree, 0, format="csc") + + # construct transition matrix (row normalised adjacency matrix) + W = D @ A + + # number of distinct node categories + c = len(np.unique(node_attr)) + if ncomp < n: + c -= 1 + + # calculate node weights for how "complete" the + # metadata is around the node + Z = np.zeros(n) + + Z[node_attr == missingValue] = 1.0 + + Z = (W @ Z) / degree + + # indicator array if node has attribute data (or missing) + hasAttribute = node_attr != missingValue + + # calculate global expected values + values = np.ones(ncomp) + + yi = (hasAttribute).nonzero()[0] + + yj = node_attr[hasAttribute] + Y = sparse.coo_matrix((values, (yi, yj)), shape=(n, c)).tocsc() + eij_glob = np.array(Y.T @ (A @ Y).todense()) + + eij_glob /= np.sum(eij_glob) + + ab_glob = np.sum(eij_glob.sum(1) * eij_glob.sum(0)) + # initialise outputs + assortM = np.empty((n, len(pr))) + assortT = np.empty(n) + WY = (W @ Y).tocsc() + + for i in range(n): + pis, ti, it = calculateRWRrange(W, i, pr, n) + if len(pr) > 1: + for ii, pri in enumerate(pr): + pi = pis[:, ii] + + YPI = sparse.coo_matrix( + ( + pi[hasAttribute], + (node_attr[hasAttribute], np.arange(n)[hasAttribute]), + ), + shape=(c, n), + ).tocsr() + trace_e = (YPI.dot(WY).toarray()).trace() + assortM[i, ii] = trace_e + YPI = sparse.coo_matrix( + (ti[hasAttribute], (node_attr[hasAttribute], np.arange(n)[hasAttribute])), + shape=(c, n), + ).tocsr() + e_gh = (YPI @ WY).toarray() + e_gh_sum = e_gh.sum() + Z[i] = e_gh_sum + e_gh /= e_gh_sum + trace_e = e_gh.trace() + assortT[i] = trace_e + + assortT -= ab_glob + np.divide(assortT, 1.0 - ab_glob, out=assortT, where=ab_glob != 0) + + if len(pr) > 1: + assortM -= ab_glob + np.divide(assortM, 1.0 - ab_glob, out=assortM, where=ab_glob != 0) + return assortM, assortT, Z + return None, assortT, Z + + +def createA(E, n, undir=True): + """Create adjacency matrix and degree sequence.""" + if undir: + G = eg.Graph() + else: + G = eg.DiGraph() + G.add_nodes_from(range(n)) + + for e in E: + G.add_edge(e[0], e[1]) + + A = eg.to_scipy_sparse_matrix(G) + + degree = np.array(A.sum(1)).flatten() + + return A, degree + + +def calculateRWRrange(W, i, alphas, n, maxIter=1000): + """ + Calculate the personalised TotalRank and personalised PageRank vectors. + Parameters + ---------- + W : array_like + transition matrix (row normalised adjacency matrix) + i : int + index of the personalisation node + alphas : array_like + array of (1 - restart probabilties) + n : int + number of nodes in the network + maxIter : int, optional + maximum number of interations (default: 1000) + Returns + ------- + pPageRank_all : array_like + personalised PageRank for all input alpha values (only calculated if + more than one alpha given as input, i.e., len(alphas) > 1) + pTotalRank : array_like + personalised TotalRank (personalised PageRank with alpha integrated + out) + + it : int + number of iterations + References + ---------- + See [2]_ and [3]_ for further details. + .. [2] Boldi, P. (2005). "TotalRank: Ranking without damping." In Special + interest tracks and posters of the 14th international conference on + World Wide Web (pp. 898-899). + .. [3] Boldi, P., Santini, M., & Vigna, S. (2007). "A deeper investigation + of PageRank as a function of the damping factor." In Dagstuhl Seminar + Proceedings. Schloss Dagstuhl-Leibniz-Zentrum für Informatik. + """ + alpha0 = alphas.max() + WT = alpha0 * W.T + diff = 1 + it = 1 + + # initialise PageRank vectors + pPageRank = np.zeros(n) + + pPageRank_all = np.zeros((n, len(alphas))) + pPageRank[i] = 1 + + pPageRank_all[i, :] = 1 + + pPageRank_old = pPageRank.copy() + pTotalRank = pPageRank.copy() + + oneminusalpha0 = 1 - alpha0 + + while diff > 1e-9: + # calculate personalised PageRank via power iteration + pPageRank = WT @ pPageRank + pPageRank[i] += oneminusalpha0 + # calculate difference in pPageRank from previous iteration + delta_pPageRank = pPageRank - pPageRank_old + # Eq. [S23] Ref. [1] + pTotalRank += (delta_pPageRank) / ((it + 1) * (alpha0**it)) + # only calculate personalised pageranks if more than one alpha + if len(alphas) > 1: + pPageRank_all += np.outer((delta_pPageRank), (alphas / alpha0) ** it) + + # calculate convergence criteria + diff = np.sum((delta_pPageRank) ** 2) / n + it += 1 + + if it > maxIter: + print(i, "max iterations exceeded") + diff = 0 + pPageRank_old = pPageRank.copy() + + return pPageRank_all, pTotalRank, it diff --git a/easygraph/functions/basic/predecessor_path_based.py b/easygraph/functions/basic/predecessor_path_based.py new file mode 100644 index 0000000..5386d54 --- /dev/null +++ b/easygraph/functions/basic/predecessor_path_based.py @@ -0,0 +1,101 @@ +import easygraph as eg + + +__all__ = [ + "predecessor", +] + + +def predecessor(G, source, target=None, cutoff=None, return_seen=None): + """Returns dict of predecessors for the path from source to all nodes in G. + + Parameters + ---------- + G : EasyGraph graph + + source : node label + Starting node for path + + target : node label, optional + Ending node for path. If provided only predecessors between + source and target are returned + + cutoff : integer, optional + Depth to stop the search. Only paths of length <= cutoff are returned. + + return_seen : bool, optional (default=None) + Whether to return a dictionary, keyed by node, of the level (number of + hops) to reach the node (as seen during breadth-first-search). + + Returns + ------- + pred : dictionary + Dictionary, keyed by node, of predecessors in the shortest path. + + + (pred, seen): tuple of dictionaries + If `return_seen` argument is set to `True`, then a tuple of dictionaries + is returned. The first element is the dictionary, keyed by node, of + predecessors in the shortest path. The second element is the dictionary, + keyed by node, of the level (number of hops) to reach the node (as seen + during breadth-first-search). + + Examples + -------- + >>> G = eg.path_graph(4) + >>> list(G) + [0, 1, 2, 3] + >>> eg.predecessor(G, 0) + {0: [], 1: [0], 2: [1], 3: [2]} + >>> eg.predecessor(G, 0, return_seen=True) + ({0: [], 1: [0], 2: [1], 3: [2]}, {0: 0, 1: 1, 2: 2, 3: 3}) + + + """ + + if source not in G: + raise eg.NodeNotFound(f"Source {source} not in G") + level = 0 # the current level + nextlevel = [source] # list of nodes to check at next level + seen = {source: level} # level (number of hops) when seen in BFS + pred = {source: []} # predecessor dictionary + while nextlevel: + level = level + 1 + thislevel = nextlevel + nextlevel = [] + for v in thislevel: + for w in list(G.neighbors(v)): + if w not in seen: + pred[w] = [v] + seen[w] = level + nextlevel.append(w) + elif seen[w] == level: # add v to predecessor list if it + pred[w].append(v) # is at the correct level + if cutoff and cutoff <= level: + break + + if target is not None: + if return_seen: + if target not in pred: + return ([], -1) # No predecessor + return (pred[target], seen[target]) + else: + if target not in pred: + return [] # No predecessor + return pred[target] + else: + if return_seen: + return (pred, seen) + else: + return pred + + +# def main(): +# G = eg.path_graph(4) +# print(G.edges) + +# print(predecessor(G, 0)) + + +# if __name__ == "__main__": +# main() diff --git a/easygraph/functions/basic/tests/__init__.py b/easygraph/functions/basic/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easygraph/functions/basic/tests/test_avg_degree.py b/easygraph/functions/basic/tests/test_avg_degree.py new file mode 100644 index 0000000..adf19b5 --- /dev/null +++ b/easygraph/functions/basic/tests/test_avg_degree.py @@ -0,0 +1,41 @@ +import easygraph as eg +import pytest + +from easygraph.functions.basic import average_degree + + +def test_average_degree_basic(): + G = eg.Graph() + G.add_edges_from([(1, 2), (2, 3)]) + assert average_degree(G) == pytest.approx(4 / 3) + + +def test_average_degree_empty_graph(): + G = eg.Graph() + with pytest.raises(ZeroDivisionError): + average_degree(G) + + +def test_average_degree_self_loop(): + G = eg.Graph() + G.add_edge(1, 1) # self-loop + # Self-loop counts as 2 towards degree of node 1 + assert average_degree(G) == pytest.approx(2.0) + + +def test_average_degree_with_isolated_node(): + G = eg.Graph() + G.add_edges_from([(1, 2), (2, 3)]) + G.add_node(4) # isolated node + assert average_degree(G) == pytest.approx(1.0) + + +def test_average_degree_directed_graph(): + G = eg.DiGraph() + G.add_edges_from([(1, 2), (2, 3), (3, 1)]) + assert average_degree(G) == pytest.approx(2.0) + + +def test_average_degree_invalid_input(): + with pytest.raises(AttributeError): + average_degree(None) diff --git a/easygraph/functions/basic/tests/test_cluster.py b/easygraph/functions/basic/tests/test_cluster.py new file mode 100644 index 0000000..548bb7a --- /dev/null +++ b/easygraph/functions/basic/tests/test_cluster.py @@ -0,0 +1,418 @@ +import easygraph as eg +import pytest + + +class TestClustering: + @classmethod + def setup_class(cls): + pytest.importorskip("numpy") + + def test_clustering(self): + G = eg.DiGraph() + G.add_edge("1", "2", weight=16) + G.add_edge("2", "3", weight=16) + G.add_edge("4", "3", weight=16) + G.add_edge("3", "4", weight=23) + G.add_edge("3", "5", weight=16) + G.add_edge("4", "2", weight=20) + print("clustering" in dir(eg)) + assert eg.clustering(G) == { + "1": 0, + "2": 0.3333333333333333, + "3": 0.2, + "4": 0.5, + "5": 0, + } + + def test_path(self): + G = eg.path_graph(10) + assert list(eg.clustering(G).values()) == [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ] + assert eg.clustering(G) == { + 0: 0, + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + 6: 0, + 7: 0, + 8: 0, + 9: 0, + } + + def test_k5(self): + G = eg.complete_graph(5) + assert list(eg.clustering(G).values()) == [1, 1, 1, 1, 1] + assert eg.average_clustering(G) == 1 + G.remove_edge(1, 2) + assert list(eg.clustering(G).values()) == [ + 5 / 6, + 1, + 1, + 5 / 6, + 5 / 6, + ] + assert eg.clustering(G, [1, 4]) == {1: 1, 4: 0.83333333333333337} + + def test_k5_signed(self): + G = eg.complete_graph(5) + assert list(eg.clustering(G).values()) == [1, 1, 1, 1, 1] + assert eg.average_clustering(G) == 1 + G.remove_edge(1, 2) + G.add_edge(0, 1, weight=-1) + assert list(eg.clustering(G, weight="weight").values()) == [ + 1 / 6, + -1 / 3, + 1, + 3 / 6, + 3 / 6, + ] + + +class TestDirectedClustering: + def test_clustering(self): + G = eg.DiGraph() + assert list(eg.clustering(G).values()) == [] + assert eg.clustering(G) == {} + + def test_path(self): + G = eg.path_graph(10, create_using=eg.DiGraph()) + assert list(eg.clustering(G).values()) == [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ] + assert eg.clustering(G) == { + 0: 0, + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + 6: 0, + 7: 0, + 8: 0, + 9: 0, + } + assert eg.clustering(G, 0) == 0 + + def test_k5(self): + G = eg.complete_graph(5, create_using=eg.DiGraph()) + assert list(eg.clustering(G).values()) == [1, 1, 1, 1, 1] + assert eg.average_clustering(G) == 1 + G.remove_edge(1, 2) + assert list(eg.clustering(G).values()) == [ + 11 / 12, + 1, + 1, + 11 / 12, + 11 / 12, + ] + assert eg.clustering(G, [1, 4]) == {1: 1, 4: 11 / 12} + G.remove_edge(2, 1) + assert list(eg.clustering(G).values()) == [ + 5 / 6, + 1, + 1, + 5 / 6, + 5 / 6, + ] + assert eg.clustering(G, [1, 4]) == {1: 1, 4: 0.83333333333333337} + assert eg.clustering(G, 4) == 5 / 6 + + def test_triangle_and_edge(self): + G = eg.empty_graph(range(3), eg.DiGraph()) + G.add_edges_from(eg.pairwise(range(3), cyclic=True)) + G.add_edge(0, 4) + assert eg.clustering(G)[0] == 1 / 6 + + +class TestDirectedAverageClustering: + @classmethod + def setup_class(cls): + pytest.importorskip("numpy") + + def test_empty(self): + G = eg.DiGraph() + with pytest.raises(ZeroDivisionError): + eg.average_clustering(G) + + def test_average_clustering(self): + G = eg.empty_graph(range(3), eg.DiGraph()) + G.add_edges_from(eg.pairwise(range(3), cyclic=True)) + G.add_edge(2, 3) + assert eg.average_clustering(G) == (1 + 1 + 1 / 3) / 8 + assert eg.average_clustering(G, count_zeros=True) == (1 + 1 + 1 / 3) / 8 + assert eg.average_clustering(G, count_zeros=False) == (1 + 1 + 1 / 3) / 6 + assert eg.average_clustering(G, [1, 2, 3]) == (1 + 1 / 3) / 6 + assert eg.average_clustering(G, [1, 2, 3], count_zeros=True) == (1 + 1 / 3) / 6 + assert eg.average_clustering(G, [1, 2, 3], count_zeros=False) == (1 + 1 / 3) / 4 + + +class TestAverageClustering: + @classmethod + def setup_class(cls): + pytest.importorskip("numpy") + + def test_empty(self): + G = eg.Graph() + with pytest.raises(ZeroDivisionError): + eg.average_clustering(G) + + def test_average_clustering(self): + G = eg.complete_graph(3) + G.add_edge(2, 3) + + assert eg.average_clustering(G) == (1 + 1 + 1 / 3) / 4 + assert eg.average_clustering(G, count_zeros=True) == (1 + 1 + 1 / 3) / 4 + assert eg.average_clustering(G, count_zeros=False) == (1 + 1 + 1 / 3) / 3 + assert eg.average_clustering(G, [1, 2, 3]) == (1 + 1 / 3) / 3 + assert eg.average_clustering(G, [1, 2, 3], count_zeros=True) == (1 + 1 / 3) / 3 + assert eg.average_clustering(G, [1, 2, 3], count_zeros=False) == (1 + 1 / 3) / 2 + + def test_average_clustering_signed(self): + G = eg.complete_graph(3) + G.add_edge(2, 3) + G.add_edge(0, 1, weight=-1) + assert eg.average_clustering(G, weight="weight") == (-1 - 1 - 1 / 3) / 4 + assert ( + eg.average_clustering(G, weight="weight", count_zeros=True) + == (-1 - 1 - 1 / 3) / 4 + ) + assert ( + eg.average_clustering(G, weight="weight", count_zeros=False) + == (-1 - 1 - 1 / 3) / 3 + ) + + +class TestDirectedWeightedClustering: + @classmethod + def setup_class(cls): + global np + np = pytest.importorskip("numpy") + + def test_clustering(self): + G = eg.DiGraph() + assert list(eg.clustering(G, weight="weight").values()) == [] + assert eg.clustering(G) == {} + + def test_path(self): + G = eg.path_graph(10, create_using=eg.DiGraph()) + print("type:", eg.clustering(G, weight="weight")) + assert list(eg.clustering(G, weight="weight").values()) == [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ] + assert eg.clustering(G, weight="weight") == { + 0: 0, + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + 6: 0, + 7: 0, + 8: 0, + 9: 0, + } + + def test_k5(self): + G = eg.complete_graph(5, create_using=eg.DiGraph()) + assert list(eg.clustering(G, weight="weight").values()) == [1, 1, 1, 1, 1] + assert eg.average_clustering(G, weight="weight") == 1 + G.remove_edge(1, 2) + assert list(eg.clustering(G, weight="weight").values()) == [ + 11 / 12, + 1, + 1, + 11 / 12, + 11 / 12, + ] + assert eg.clustering(G, [1, 4], weight="weight") == {1: 1, 4: 11 / 12} + G.remove_edge(2, 1) + assert list(eg.clustering(G, weight="weight").values()) == [ + 5 / 6, + 1, + 1, + 5 / 6, + 5 / 6, + ] + assert eg.clustering(G, [1, 4], weight="weight") == { + 1: 1, + 4: 0.83333333333333337, + } + + def test_triangle_and_edge(self): + G = eg.empty_graph(range(3), create_using=eg.DiGraph()) + G.add_edges_from(eg.pairwise(range(3), cyclic=True)) + G.add_edge(0, 4, weight=2) + assert eg.clustering(G)[0] == 1 / 6 + # Relaxed comparisons to allow graphblas-algorithms to pass tests + np.testing.assert_allclose(eg.clustering(G, weight="weight")[0], 1 / 12) + np.testing.assert_allclose(eg.clustering(G, 0, weight="weight"), 1 / 12) + + +class TestWeightedClustering: + @classmethod + def setup_class(cls): + global np + np = pytest.importorskip("numpy") + + def test_clustering(self): + G = eg.Graph() + assert list(eg.clustering(G, weight="weight").values()) == [] + assert eg.clustering(G) == {} + + def test_path(self): + G = eg.path_graph(10) + assert list(eg.clustering(G, weight="weight").values()) == [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ] + assert eg.clustering(G, weight="weight") == { + 0: 0, + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + 6: 0, + 7: 0, + 8: 0, + 9: 0, + } + + def test_cubical(self): + G = eg.from_dict_of_lists( + { + 0: [1, 3, 4], + 1: [0, 2, 7], + 2: [1, 3, 6], + 3: [0, 2, 5], + 4: [0, 5, 7], + 5: [3, 4, 6], + 6: [2, 5, 7], + 7: [1, 4, 6], + }, + create_using=None, + ) + assert list(eg.clustering(G, weight="weight").values()) == [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ] + assert eg.clustering(G, 1) == 0 + assert list(eg.clustering(G, [1, 2], weight="weight").values()) == [0, 0] + assert eg.clustering(G, 1, weight="weight") == 0 + assert eg.clustering(G, [1, 2], weight="weight") == {1: 0, 2: 0} + + def test_k5(self): + G = eg.complete_graph(5) + assert list(eg.clustering(G, weight="weight").values()) == [1, 1, 1, 1, 1] + assert eg.average_clustering(G, weight="weight") == 1 + G.remove_edge(1, 2) + assert list(eg.clustering(G, weight="weight").values()) == [ + 5 / 6, + 1, + 1, + 5 / 6, + 5 / 6, + ] + assert eg.clustering(G, [1, 4], weight="weight") == { + 1: 1, + 4: 0.83333333333333337, + } + + def test_triangle_and_edge(self): + G = eg.empty_graph(range(3), None) + G.add_edges_from(eg.pairwise(range(3), cyclic=True)) + G.add_edge(0, 4, weight=2) + assert eg.clustering(G)[0] == 1 / 3 + np.testing.assert_allclose(eg.clustering(G, weight="weight")[0], 1 / 6) + np.testing.assert_allclose(eg.clustering(G, 0, weight="weight"), 1 / 6) + + def test_triangle_and_signed_edge(self): + G = eg.empty_graph(range(3), None) + G.add_edges_from(eg.pairwise(range(3), cyclic=True)) + G.add_edge(0, 1, weight=-1) + G.add_edge(3, 0, weight=0) + assert eg.clustering(G)[0] == 1 / 3 + assert eg.clustering(G, weight="weight")[0] == -1 / 3 + + +class TestAdditionalClusteringCases: + def test_self_loops_ignored(self): + G = eg.Graph() + G.add_edges_from([(0, 1), (1, 2), (2, 0)]) + G.add_edge(0, 0) # self-loop + assert eg.clustering(G, 0) == 1.0 + + def test_isolated_node(self): + G = eg.Graph() + G.add_node(1) + assert eg.clustering(G) == {1: 0} + + def test_degree_one_node(self): + G = eg.Graph() + G.add_edge(1, 2) + assert eg.clustering(G) == {1: 0, 2: 0} + + def test_custom_weight_name(self): + G = eg.Graph() + G.add_edge(0, 1, strength=2) + G.add_edge(1, 2, strength=2) + G.add_edge(2, 0, strength=2) + result = eg.clustering(G, weight="strength") + assert result[0] > 0 + + def test_negative_weights_mixed(self): + G = eg.complete_graph(3) + G[0][1]["weight"] = -1 + G[1][2]["weight"] = 1 + G[2][0]["weight"] = 1 + assert eg.clustering(G, 0, weight="weight") < 0 + + def test_directed_reciprocal_edges(self): + G = eg.DiGraph() + G.add_edges_from([(0, 1), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)]) + result = eg.clustering(G) + assert all(0 <= v <= 1 for v in result.values()) diff --git a/easygraph/functions/basic/tests/test_localassort.py b/easygraph/functions/basic/tests/test_localassort.py new file mode 100644 index 0000000..7217068 --- /dev/null +++ b/easygraph/functions/basic/tests/test_localassort.py @@ -0,0 +1,104 @@ +import sys + +import easygraph as eg +import numpy as np +import pytest + +from easygraph.functions.basic.localassort import localAssort + + +class TestLocalAssort: + @classmethod + def setup_class(self): + self.G = eg.get_graph_karateclub() + edgelist = [] + node_num = len(self.G.nodes) + for e in self.G.edges: + edgelist.append([e[0] - 1, e[1] - 1]) + self.edgelist = np.int32(edgelist) + self.valuelist = np.arange(node_num, dtype=np.int32) % 6 + + @pytest.mark.skipif( + sys.version_info.major <= 3 and sys.version_info.minor <= 7, + reason="python version should higher than 3.7", + ) + def test_karateclub(self): + assortM, assortT, Z = eg.localAssort( + self.edgelist, self.valuelist, pr=np.arange(0, 1, 0.1) + ) + + _, assortT, Z = eg.functions.basic.localassort.localAssort( + self.edgelist, self.valuelist, pr=np.array([0.9]) + ) + + +def test_localassort_small_complete_graph(): + G = eg.complete_graph(4) + edgelist = np.array(list(G.edges)) + node_attr = np.array([0, 0, 1, 1]) + assortM, assortT, Z = localAssort(edgelist, node_attr) + assert assortM.shape == (4, 10) + assert assortT.shape == (4,) + assert Z.shape == (4,) + assert np.all(Z >= 0) and np.all(Z <= 1) + + +def test_localassort_with_missing_attributes(): + G = eg.path_graph(5) + edgelist = np.array(list(G.edges)) + node_attr = np.array([0, -1, 1, -1, 1]) + assortM, assortT, Z = localAssort(edgelist, node_attr, pr=np.array([0.5])) + assert assortT.shape == (5,) + assert Z.shape == (5,) + assert np.any(np.isnan(assortT)) + + +def test_localassort_directed_graph(): + G = eg.DiGraph() + G.add_edges_from([(0, 1), (1, 2), (2, 3)]) + edgelist = np.array(list(G.edges)) + node_attr = np.array([0, 1, 0, 1]) + assortM, assortT, Z = localAssort(edgelist, node_attr, undir=False) + assert assortM.shape == (4, 10) + assert assortT.shape == (4,) + assert Z.shape == (4,) + + +def test_localassort_single_node_graph(): + edgelist = np.empty((0, 2), dtype=int) + node_attr = np.array([0]) + assortM, assortT, Z = localAssort(edgelist, node_attr) + assert assortM.shape == (1, 10) + assert np.all(np.isnan(assortM)) or np.allclose(assortM, 0, atol=1e-5) + assert np.all(np.isnan(assortT)) or np.allclose(assortT, 0, atol=1e-5) + assert np.all(np.isnan(Z)) or np.allclose(Z, 0, atol=1e-5) + + +def test_localassort_disconnected_graph(): + G = eg.Graph() + G.add_nodes_from(range(5)) + edgelist = np.empty((0, 2), dtype=int) + node_attr = np.array([0, 1, 0, 1, 1]) + assortM, assortT, Z = localAssort(edgelist, node_attr) + assert assortM.shape == (5, 10) + assert np.all(np.isnan(assortM)) or np.allclose(assortM, 0, atol=1e-5) + assert np.all(np.isnan(assortT)) or np.allclose(assortT, 0, atol=1e-5) + assert np.all(np.isnan(Z)) or np.allclose(Z, 0, atol=1e-5) + + +def test_localassort_high_restart_probabilities(): + G = eg.path_graph(5) + edgelist = np.array(list(G.edges)) + node_attr = np.array([1, 0, 1, 0, 1]) + pr = np.array([0.95, 0.99]) + assortM, assortT, Z = localAssort(edgelist, node_attr, pr=pr) + assert assortM.shape == (5, 2) + assert assortT.shape == (5,) + assert Z.shape == (5,) + + +def test_localassort_invalid_attribute_length(): + edgelist = np.array([[0, 1], [1, 2]]) + node_attr = np.array([0, 1]) # too short + with pytest.raises(ValueError): + localAssort(edgelist, node_attr) diff --git a/easygraph/functions/basic/tests/test_predecessor.py b/easygraph/functions/basic/tests/test_predecessor.py new file mode 100644 index 0000000..6c772c7 --- /dev/null +++ b/easygraph/functions/basic/tests/test_predecessor.py @@ -0,0 +1,79 @@ +import easygraph as eg +import pytest + + +class TestPredecessor: + # @classmethod + # def setup_class(self): + # pytest.importskip("numpy") + + def test_predecessor(self): + G = eg.path_graph(4) + for source in G: + assert eg.predecessor(G, source) in [ + {0: [], 1: [0], 2: [1], 3: [2]}, + {1: [], 0: [1], 2: [1], 3: [2]}, + {2: [], 1: [2], 3: [2], 0: [1]}, + {3: [], 2: [3], 1: [2], 0: [1]}, + ] + + def test_basic_predecessor(self): + G = eg.path_graph(4) + result = eg.predecessor(G, 0) + assert result == {0: [], 1: [0], 2: [1], 3: [2]} + + def test_with_return_seen(self): + G = eg.path_graph(4) + pred, seen = eg.predecessor(G, 0, return_seen=True) + assert pred == {0: [], 1: [0], 2: [1], 3: [2]} + assert seen == {0: 0, 1: 1, 2: 2, 3: 3} + + def test_with_target(self): + G = eg.path_graph(4) + assert eg.predecessor(G, 0, target=2) == [1] + + def test_with_target_and_return_seen(self): + G = eg.path_graph(4) + pred, seen = eg.predecessor(G, 0, target=2, return_seen=True) + assert pred == [1] + assert seen == 2 + + def test_with_cutoff(self): + G = eg.path_graph(4) + pred = eg.predecessor(G, 0, cutoff=1) + assert pred == {0: [], 1: [0]} + + def test_disconnected_graph(self): + G = eg.Graph() + G.add_edges_from([(0, 1), (2, 3)]) + pred = eg.predecessor(G, 0) + assert 2 not in pred and 3 not in pred + + def test_invalid_source(self): + G = eg.path_graph(4) + with pytest.raises(eg.NodeNotFound): + eg.predecessor(G, 99) + + def test_no_path_to_target(self): + G = eg.Graph() + G.add_edges_from([(0, 1), (2, 3)]) + assert eg.predecessor(G, 0, target=3) == [] + + def test_no_path_to_target_with_return_seen(self): + G = eg.Graph() + G.add_edges_from([(0, 1), (2, 3)]) + pred, seen = eg.predecessor(G, 0, target=3, return_seen=True) + assert pred == [] + assert seen == -1 + + def test_cycle_graph(self): + G = eg.Graph() + G.add_edges_from([(0, 1), (1, 2), (2, 3), (3, 0)]) # cycled graph + pred = eg.predecessor(G, 0) + assert set(pred.keys()) == set(G.nodes) + + def test_directed_graph(self): + G = eg.DiGraph() + G.add_edges_from([(0, 1), (1, 2), (2, 3)]) + pred = eg.predecessor(G, 0) + assert pred == {0: [], 1: [0], 2: [1], 3: [2]} diff --git a/easygraph/functions/centrality/__init__.py b/easygraph/functions/centrality/__init__.py new file mode 100644 index 0000000..bd2fec5 --- /dev/null +++ b/easygraph/functions/centrality/__init__.py @@ -0,0 +1,9 @@ +from .betweenness import * +from .closeness import * +from .degree import * +from .ego_betweenness import * +from .flowbetweenness import * +from .laplacian import * +from .pagerank import * +from .katz_centrality import * +from .eigenvector import * \ No newline at end of file diff --git a/easygraph/functions/centrality/betweenness.py b/easygraph/functions/centrality/betweenness.py new file mode 100644 index 0000000..2ac6aba --- /dev/null +++ b/easygraph/functions/centrality/betweenness.py @@ -0,0 +1,245 @@ +from easygraph.utils import * +from easygraph.utils.decorators import * + + +__all__ = [ + "betweenness_centrality", +] + + +def betweenness_centrality_parallel(nodes, G, path_length, accumulate): + betweenness = {node: 0.0 for node in G} + for node in nodes: + S, P, sigma = path_length(G, source=node) + betweenness = accumulate(betweenness, S, P, sigma, node) + return betweenness + + +@not_implemented_for("multigraph") +@hybrid("cpp_betweenness_centrality") +def betweenness_centrality( + G, weight=None, sources=None, normalized=True, endpoints=False, n_workers=None +): + r"""Compute the shortest-basic betweenness centrality for nodes. + + .. math:: + + c_B(v) = \sum_{s,t \in V} \frac{\sigma(s, t|v)}{\sigma(s, t)} + + where V is the set of nodes, + + .. math:: + \sigma(s, t) + + is the number of shortest (s, t)-paths, and + + .. math:: + + \sigma(s, t|v) + + is the number of those paths passing through some node v other than s, t. + + .. math:: + + If\ s\ =\ t,\ \sigma(s, t) = 1, and\ if\ v \in {s, t}, \sigma(s, t|v) = 0 [2]_. + + Parameters + ---------- + G : graph + A easygraph graph. + + weight : None or string, optional (default=None) + If None, all edge weights are considered equal. + Otherwise holds the name of the edge attribute used as weight. + + sources : None or nodes list, optional (default=None) + If None, all nodes are considered. + Otherwise,the set of source vertices to consider when calculating shortest paths. + + normalized : bool, optional + If True the betweenness values are normalized by `2/((n-1)(n-2))` + for graphs, and `1/((n-1)(n-2))` for directed graphs where `n` + is the number of nodes in G. + + endpoints : bool, optional + If True include the endpoints in the shortest basic counts. + + Returns + ------- + + nodes : dictionary + Dictionary of nodes with betweenness centrality as the value. + + >>> betweenness_centrality(G,weight="weight") + """ + + import functools + + if weight is not None: + path_length = functools.partial(_single_source_dijkstra_path, weight=weight) + else: + path_length = functools.partial(_single_source_bfs_path) + + if endpoints: + accumulate = functools.partial(_accumulate_endpoints) + else: + accumulate = functools.partial(_accumulate_basic) + + if sources is not None: + nodes = sources + else: + nodes = G.nodes + betweenness = dict.fromkeys(G, 0.0) + + if n_workers is not None: + # use the parallel version for large graph + import random + + from functools import partial + from multiprocessing import Pool + + nodes = list(nodes) + random.shuffle(nodes) + + if len(nodes) > n_workers * 30000: + nodes = split_len(nodes, step=30000) + else: + nodes = split(nodes, n_workers) + local_function = partial( + betweenness_centrality_parallel, + G=G, + path_length=path_length, + accumulate=accumulate, + ) + with Pool(n_workers) as p: + ret = p.imap(local_function, nodes) + for res in ret: + for key in res: + betweenness[key] += res[key] + else: + # use np-parallel version for small graph + for node in nodes: + S, P, sigma = path_length(G, source=node) + betweenness = accumulate(betweenness, S, P, sigma, node) + + betweenness = _rescale( + betweenness, + len(G), + normalized=normalized, + directed=G.is_directed(), + endpoints=endpoints, + ) + ret = [0.0 for i in range(len(G))] + for i in range(len(ret)): + ret[i] = betweenness[G.index2node[i]] + return ret + + +def _rescale(betweenness, n, normalized, directed=False, endpoints=False): + if normalized: + if endpoints: + if n < 2: + scale = None # no normalization + else: + # Scale factor should include endpoint nodes + scale = 1 / (n * (n - 1)) + elif n <= 2: + scale = None # no normalization b=0 for all nodes + else: + scale = 1 / ((n - 1) * (n - 2)) + else: # rescale by 2 for undirected graphs + if not directed: + scale = 0.5 + else: + scale = None + if scale is not None: + for v in betweenness: + betweenness[v] *= scale + return betweenness + + +def _single_source_bfs_path(G, source): + S = [] + P = {v: [] for v in G} + sigma = dict.fromkeys(G, 0.0) + D = {} + sigma[source] = 1.0 + D[source] = 0 + Q = [source] + adj = G.adj + while Q: + v = Q.pop(0) + S.append(v) + Dv = D[v] + sigmav = sigma[v] + for w in adj[v]: + if w not in D: + Q.append(w) + D[w] = Dv + 1 + if D[w] == Dv + 1: + sigma[w] += sigmav + P[w].append(v) + return S, P, sigma + + +def _single_source_dijkstra_path(G, source, weight="weight"): + from heapq import heappop + from heapq import heappush + + push = heappush + pop = heappop + S = [] + P = {v: [] for v in G} + sigma = dict.fromkeys(G, 0.0) + D = {} + sigma[source] = 1.0 + seen = {source: 0} + Q = [] + from itertools import count + + c = count() + adj = G.adj + push(Q, (0, next(c), source, source)) + while Q: + (dist, _, pred, v) = pop(Q) + if v in D: + continue + sigma[v] += sigma[pred] + S.append(v) + D[v] = dist + for w in adj[v]: + vw_dist = dist + adj[v][w].get(weight, 1) + if w not in D and (w not in seen or vw_dist < seen[w]): + seen[w] = vw_dist + push(Q, (vw_dist, next(c), v, w)) + sigma[w] = 0.0 + P[w] = [v] + elif vw_dist == seen[w]: # handle equal paths + sigma[w] += sigma[v] + P[w].append(v) + return S, P, sigma + + +def _accumulate_endpoints(betweenness, S, P, sigma, s): + betweenness[s] += len(S) - 1 + delta = dict.fromkeys(S, 0) + while S: + w = S.pop() + coeff = (1 + delta[w]) / sigma[w] + for v in P[w]: + delta[v] += sigma[v] * coeff + if w != s: + betweenness[w] += delta[w] + 1 + return betweenness + + +def _accumulate_basic(betweenness, S, P, sigma, s): + delta = dict.fromkeys(S, 0) + while S: + w = S.pop() + coeff = (1 + delta[w]) / sigma[w] + for v in P[w]: + delta[v] += sigma[v] * coeff + if w != s: + betweenness[w] += delta[w] + return betweenness diff --git a/easygraph/functions/centrality/closeness.py b/easygraph/functions/centrality/closeness.py new file mode 100644 index 0000000..4493647 --- /dev/null +++ b/easygraph/functions/centrality/closeness.py @@ -0,0 +1,105 @@ +from easygraph.functions.basic import * +from easygraph.functions.path import single_source_bfs +from easygraph.functions.path import single_source_dijkstra +from easygraph.utils import * + + +__all__ = [ + "closeness_centrality", +] + + +def closeness_centrality_parallel(nodes, G, path_length): + ret = [] + length = len(G) + for node in nodes: + x = path_length(G, node) + dist = sum(x.values()) + cnt = len(x) + if dist == 0: + ret.append([node, 0]) + else: + ret.append([node, (cnt - 1) * (cnt - 1) / (dist * (length - 1))]) + return ret + + +@not_implemented_for("multigraph") +@hybrid("cpp_closeness_centrality") +def closeness_centrality(G, weight=None, sources=None, n_workers=None): + r""" + Compute closeness centrality for nodes. + + .. math:: + + C_{WF}(u) = \frac{n-1}{N-1} \frac{n - 1}{\sum_{v=1}^{n-1} d(v, u)}, + + Notice that the closeness distance function computes the + outcoming distance to `u` for directed graphs. To use + incoming distance, act on `G.reverse()`. + + Parameters + ---------- + G : graph + A easygraph graph + + weight : None or string, optional (default=None) + If None, all edge weights are considered equal. + Otherwise holds the name of the edge attribute used as weight. + + sources : None or nodes list, optional (default=None) + If None, all nodes are returned + Otherwise,the set of source vertices to creturn. + + Returns + ------- + nodes : dictionary + Dictionary of nodes with closeness centrality as the value. + """ + closeness = dict() + if sources is not None: + nodes = sources + else: + nodes = G.nodes + length = len(G) + import functools + + if weight is not None: + path_length = functools.partial(single_source_dijkstra, weight=weight) + else: + path_length = functools.partial(single_source_bfs) + + if n_workers is not None: + # use parallel version for large graph + import random + + from functools import partial + from multiprocessing import Pool + + nodes = list(nodes) + random.shuffle(nodes) + + if len(nodes) > n_workers * 30000: + nodes = split_len(nodes, step=30000) + else: + nodes = split(nodes, n_workers) + local_function = partial( + closeness_centrality_parallel, G=G, path_length=path_length + ) + with Pool(n_workers) as p: + ret = p.imap(local_function, nodes) + res = [x for i in ret for x in i] + closeness = dict(res) + else: + # use np-parallel version for small graph + for node in nodes: + x = path_length(G, node) + dist = sum(x.values()) + cnt = len(x) + if dist == 0: + closeness[node] = 0 + else: + closeness[node] = (cnt - 1) * (cnt - 1) / (dist * (length - 1)) + ret = [0.0 for i in range(len(G))] + for i in range(len(ret)): + ret[i] = closeness[G.index2node[i]] + return ret diff --git a/easygraph/functions/centrality/degree.py b/easygraph/functions/centrality/degree.py new file mode 100644 index 0000000..9d3d0b2 --- /dev/null +++ b/easygraph/functions/centrality/degree.py @@ -0,0 +1,125 @@ +from easygraph.utils.decorators import * + + +__all__ = ["degree_centrality", "in_degree_centrality", "out_degree_centrality"] + + +@not_implemented_for("multigraph") +@hybrid("cpp_degree_centrality") +def degree_centrality(G): + """Compute the degree centrality for nodes in a bipartite network. + + The degree centrality for a node v is the fraction of nodes it + is connected to. + + parameters + ---------- + G : graph + A easygraph graph + + Returns + ------- + nodes : dictionary + Dictionary of nodes with degree centrality as the value. + + Notes + ----- + The degree centrality are normalized by dividing by n-1 where + n is number of nodes in G. + """ + if len(G) <= 1: + return {n: 1 for n in G} + + s = 1.0 / (len(G) - 1.0) + centrality = {n: d * s for n, d in (G.degree()).items()} + return centrality + + +@not_implemented_for("multigraph") +@only_implemented_for_Directed_graph +@hybrid("cpp_in_degree_centrality") +def in_degree_centrality(G): + """Compute the in-degree centrality for nodes. + + The in-degree centrality for a node v is the fraction of nodes its + incoming edges are connected to. + + Parameters + ---------- + G : graph + A EasyGraph graph + + Returns + ------- + nodes : dictionary + Dictionary of nodes with in-degree centrality as values. + + Raises + ------ + EasyGraphNotImplemented: + If G is undirected. + + See Also + -------- + degree_centrality, out_degree_centrality + + Notes + ----- + The degree centrality values are normalized by dividing by the maximum + possible degree in a simple graph n-1 where n is the number of nodes in G. + + For multigraphs or graphs with self loops the maximum degree might + be higher than n-1 and values of degree centrality greater than 1 + are possible. + """ + if len(G) <= 1: + return {n: 1 for n in G} + + s = 1.0 / (len(G) - 1.0) + centrality = {n: d * s for n, d in G.in_degree().items()} + return centrality + + +@not_implemented_for("multigraph") +@only_implemented_for_Directed_graph +@hybrid("cpp_out_degree_centrality") +def out_degree_centrality(G): + """Compute the out-degree centrality for nodes. + + The out-degree centrality for a node v is the fraction of nodes its + outgoing edges are connected to. + + Parameters + ---------- + G : graph + A EasyGraph graph + + Returns + ------- + nodes : dictionary + Dictionary of nodes with out-degree centrality as values. + + Raises + ------ + EasyGraphNotImplemented: + If G is undirected. + + See Also + -------- + degree_centrality, in_degree_centrality + + Notes + ----- + The degree centrality values are normalized by dividing by the maximum + possible degree in a simple graph n-1 where n is the number of nodes in G. + + For multigraphs or graphs with self loops the maximum degree might + be higher than n-1 and values of degree centrality greater than 1 + are possible. + """ + if len(G) <= 1: + return {n: 1 for n in G} + + s = 1.0 / (len(G) - 1.0) + centrality = {n: d * s for n, d in G.out_degree().items()} + return centrality diff --git a/easygraph/functions/centrality/ego_betweenness.py b/easygraph/functions/centrality/ego_betweenness.py new file mode 100644 index 0000000..7b10d87 --- /dev/null +++ b/easygraph/functions/centrality/ego_betweenness.py @@ -0,0 +1,57 @@ +__all__ = ["ego_betweenness"] +import numpy as np + +from easygraph.utils import * + + +@not_implemented_for("multigraph") +def ego_betweenness(G, node): + """ + ego networks are networks consisting of a single actor (ego) together with the actors they are connected to (alters) and all the links among those alters.[1] + Burt (1992), in his book Structural Holes, provides ample evidence that having high betweenness centrality, which is highly correlated with having many structural holes, can bring benefits to ego.[1] + Returns the betweenness centrality of a ego network whose ego is set + + Parameters + ---------- + G : graph + node : int + + Returns + ------- + sum : float + the betweenness centrality of a ego network whose ego is set + + Examples + -------- + Returns the betwenness centrality of node 1. + + >>> ego_betweenness(G,node=1) + + Reference + --------- + .. [1] Martin Everett, Stephen P. Borgatti. "Ego network betweenness." Social Networks, Volume 27, Issue 1, Pages 31-38, 2005. + + """ + g = G.ego_subgraph(node) + print(g.edges) + print(g.nodes) + n = len(g) + + A = np.zeros((n, n)) + + for i in range(n): + for j in range(n): + if g.has_edge(g.index2node[i], g.index2node[j]): + A[i, j] = 1 + + B = A * A + C = np.identity(n) - A + sum = 0 + flag = G.is_directed() + for i in range(n): + for j in range(n): + if i != j and C[i, j] == 1 and B[i, j] != 0: + sum += 1.0 / B[i, j] + if flag == False: + sum /= 2 + return sum diff --git a/easygraph/functions/centrality/eigenvector.py b/easygraph/functions/centrality/eigenvector.py new file mode 100644 index 0000000..f38085d --- /dev/null +++ b/easygraph/functions/centrality/eigenvector.py @@ -0,0 +1,154 @@ +import math +import easygraph as eg +from easygraph.utils import * +from easygraph.utils.decorators import * +from scipy import sparse +from scipy.sparse import linalg +import numpy as np +from collections import defaultdict + +__all__ = ["eigenvector_centrality"] + +@not_implemented_for("multigraph") +@hybrid("cpp_eigenvector_centrality") +def eigenvector_centrality(G, max_iter=100, tol=1.0e-6, nstart=None, weight=None): + """Calculate eigenvector centrality for nodes in the graph + + Eigenvector centrality is based on the idea that a node's importance + depends on the importance of its neighboring nodes. + Specifically, a node's centrality is proportional to the sum of + centrality values of its neighbors. + + Parameters + ---------- + G : graph object + An undirected or directed graph + + max_iter : int, optional (default=100) + Maximum number of iterations for the power method + + tol : float, optional (default=1.0e-6) + Convergence threshold; algorithm terminates when the difference + between centrality values in consecutive iterations is less than this value + + nstart : dictionary, optional (default=None) + Dictionary mapping nodes to initial centrality values + If None, the ARPACK solver is used to directly compute the eigenvector + + weight : string or None, optional (default=None) + Name of the edge attribute to be used as edge weight + If None, all edges are considered to have weight 1 + + Returns + ------- + centrality : dictionary + Dictionary mapping nodes to their eigenvector centrality values + + Raises + ------ + EasyGraphPointlessConcept + When input is an empty graph + + EasyGraphError + When the algorithm fails to converge within the specified maximum iterations + + Notes + ----- + This algorithm uses the power iteration method to find the principal eigenvector. + When nstart is not provided, the ARPACK solver is used for efficiency. + The returned centrality values are normalized. + """ + + if len(G) == 0: + raise eg.EasyGraphPointlessConcept( + "cannot compute centrality for the null graph" + ) + + if len(G) == 1: + raise eg.EasyGraphPointlessConcept( + "cannot compute eigenvector centrality for a single node graph" + ) + + + # Build node list and mapping + nodelist = list(G.nodes) + n = len(nodelist) + node_map = {node: i for i, node in enumerate(nodelist)} + + # Build weighted adjacency matrix + row, col, data = [], [], [] + for u in nodelist: + u_idx = node_map[u] + for v, attrs in G[u].items(): + if v in node_map: + v_idx = node_map[v] + w = attrs.get(weight, 1.0) if weight else 1.0 + # Build transpose matrix for centrality calculation + row.append(v_idx) + col.append(u_idx) + data.append(float(w)) + + # Create CSR format sparse matrix + A = sparse.csr_matrix((data, (row, col)), shape=(n, n)) + + # Detect and handle isolated nodes + row_sums = np.array(A.sum(axis=1)).flatten() + col_sums = np.array(A.sum(axis=0)).flatten() + isolated_nodes = np.where((row_sums == 0) & (col_sums == 0))[0] + + has_isolated = len(isolated_nodes) > 0 + isolated_indices = [] + + # Add small self-loops to isolated nodes for stability + if has_isolated: + # Store isolated node indices + isolated_indices = isolated_nodes.tolist() + + # Add small self-loop weights to isolated nodes + for idx in isolated_indices: + A[idx, idx] = 1.0e-4 # Small enough to not affect results, but maintains numerical stability + if nstart is not None: + # Use custom initial vector for power iteration + v = np.array([nstart.get(n, 1.0) for n in nodelist], dtype=float) + v = v / np.sum(np.abs(v)) + + # Power iteration method to compute principal eigenvector + v_last = np.zeros_like(v) + for _ in range(max_iter): + np.copyto(v_last, v) + v = A @ v_last # Sparse matrix multiplication + + norm = np.linalg.norm(v) + if norm < 1e-10: + v = v_last.copy() + break + v = v / norm # Normalization + + # Check convergence + if np.linalg.norm(v - v_last) < tol: + break + else: + raise eg.EasyGraphError(f"Eigenvector calculation did not converge in {max_iter} iterations") + + centrality = v + else: + # Use ARPACK solver to directly compute the principal eigenvector + eigenvalues, eigenvectors = linalg.eigs(A, k=1, which='LR', + maxiter=max_iter, tol=tol) + centrality = np.real(eigenvectors[:,0]) + + # Ensure positive results and normalize + if centrality.sum() < 0: + centrality = -centrality + + centrality = centrality / np.linalg.norm(centrality) + # Set centrality of isolated nodes to zero + if has_isolated: + for idx in isolated_indices: + centrality[idx] = 0.0 + # Renormalize if needed + if np.sum(centrality) > 0: + centrality = centrality / np.linalg.norm(centrality) + + # Return dictionary of node centrality values + return {nodelist[i]: float(centrality[i]) for i in range(n)} \ No newline at end of file diff --git a/easygraph/functions/centrality/flowbetweenness.py b/easygraph/functions/centrality/flowbetweenness.py new file mode 100644 index 0000000..0fe5407 --- /dev/null +++ b/easygraph/functions/centrality/flowbetweenness.py @@ -0,0 +1,146 @@ +import collections +import copy + +from easygraph.utils.decorators import * + + +__all__ = [ + "flowbetweenness_centrality", +] + + +@not_implemented_for("multigraph") +def flowbetweenness_centrality(G): + """Compute the independent-basic betweenness centrality for nodes in a flow network. + + .. math:: + + c_B(v) =\\sum_{s,t \\in V} \frac{\\sigma(s, t|v)}{\\sigma(s, t)} + + where V is the set of nodes, + + .. math:: + + \\sigma(s, t)\\ is\\ the\\ number\\ of\\ independent\\ (s, t)-paths, + + .. math:: + + \\sigma(s, t|v)\\ is\\ the\\ maximum\\ number\\ possible\\ of\\ those\\ paths\\ passing\\ through\\ some\\ node\\ v\\ other\\ than\\ s, t.\ + + .. math:: + + If\\ s\\ =\\ t,\\ \\sigma(s, t)\\ =\\ 1,\\ and\\ if\\ v \\in \\{s, t\\},\\ \\sigma(s, t|v)\\ =\\ 0\\ [2]_. + + Parameters + ---------- + G : graph + A easygraph directed graph. + + Returns + ------- + nodes : dictionary + Dictionary of nodes with independent-basic betweenness centrality as the value. + + Notes + ----- + A flow network is a directed graph where each edge has a capacity and each edge receives a flow. + """ + if G.is_directed() == False: + print("Please input a directed graph") + return + flow_dict = NumberOfFlow(G) + nodes = G.nodes + result_dict = dict() + for node, _ in nodes.items(): + result_dict[node] = 0 + for node_v, _ in nodes.items(): + for node_s, _ in nodes.items(): + for node_t, _ in nodes.items(): + num = 1 + num_v = 0 + if node_s == node_t: + num_v = 0 + num = 1 + if node_v in [node_s, node_t]: + num_v = 0 + num = 1 + if node_v != node_s and node_v != node_t and node_s != node_t: + num = flow_dict[node_s][node_t] + num_v = min(flow_dict[node_s][node_v], flow_dict[node_v][node_t]) + if num == 0: + pass + else: + result_dict[node_v] = result_dict[node_v] + num_v / num + return result_dict + + +# flow betweenness +def NumberOfFlow(G): + nodes = G.nodes + result_dict = dict() + for node1, _ in nodes.items(): + result_dict[node1] = dict() + for node2, _ in nodes.items(): + if node1 == node2: + pass + else: + result_dict[node1][node2] = edmonds_karp(G, node1, node2) + return result_dict + + +def edmonds_karp(G, source, sink): + nodes = G.nodes + parent = dict() + for node, _ in nodes.items(): + parent[node] = -1 + + adj = copy.deepcopy(G.adj) + max_flow = 0 + while bfs(G, source, sink, parent, adj): + path_flow = float("inf") + s = sink + while s != source: + path_flow = min(path_flow, adj[parent[s]][s].get("weight", 1)) + s = parent[s] + max_flow += path_flow + v = sink + while v != source: + u = parent[v] + x = adj[u][v].get("weight", 1) + adj[u][v].update({"weight": x}) + adj[u][v]["weight"] -= path_flow + + flag = 0 + if v not in adj: + adj[v] = dict() + if u not in adj[v]: + adj[v][u] = dict() + flag = 1 + if flag == 1: + x = 0 + else: + x = adj[v][u].get("weight", 1) + adj[v][u].update({"weight": x}) + adj[v][u]["weight"] += path_flow + v = parent[v] + return max_flow + + +def bfs(G, source, sink, parent, adj): + nodes = G.nodes + visited = dict() + for node, _ in nodes.items(): + visited[node] = 0 + queue = collections.deque() + queue.append(source) + visited[source] = True + while queue: + u = queue.popleft() + if u not in adj: + continue + for v, attr in adj[u].items(): + if (visited[v] == False) and (attr.get("weight", 1) > 0): + queue.append(v) + visited[v] = True + parent[v] = u + return visited[sink] diff --git a/easygraph/functions/centrality/katz_centrality.py b/easygraph/functions/centrality/katz_centrality.py new file mode 100644 index 0000000..9cc5296 --- /dev/null +++ b/easygraph/functions/centrality/katz_centrality.py @@ -0,0 +1,105 @@ +from easygraph.utils import * +import numpy as np +from easygraph.utils.decorators import * + +__all__ = ["katz_centrality"] + +@not_implemented_for("multigraph") +@hybrid("cpp_katz_centrality") +def katz_centrality(G, alpha=0.1, beta=1.0, max_iter=1000, tol=1e-6, normalized=True): + r""" + Compute the Katz centrality for nodes in a graph. + + Katz centrality computes the influence of a node based on the total number + of walks between nodes, attenuated by a factor of their length. It is + defined as the solution to the linear system: + + .. math:: + + x = \alpha A x + \beta + + where: + - \( A \) is the adjacency matrix of the graph, + - \( \alpha \) is a scalar attenuation factor, + - \( \beta \) is the bias vector (typically all ones), + - and \( x \) is the resulting centrality vector. + + The algorithm runs an iterative fixed-point method until convergence. + + Parameters + ---------- + G : easygraph.Graph + An EasyGraph graph instance. Must be simple (non-multigraph). + + alpha : float, optional (default=0.1) + Attenuation factor, must be smaller than the reciprocal of the largest + eigenvalue of the adjacency matrix to ensure convergence. + + beta : float or dict, optional (default=1.0) + Bias term. Can be a constant scalar applied to all nodes, or a dictionary + mapping node IDs to values. + + max_iter : int, optional (default=1000) + Maximum number of iterations before the algorithm terminates. + + tol : float, optional (default=1e-6) + Convergence tolerance. Iteration stops when the L1 norm of the difference + between successive iterations is below this threshold. + + normalized : bool, optional (default=True) + If True, the result vector will be normalized to unit norm (L2). + + Returns + ------- + dict + A dictionary mapping node IDs to Katz centrality scores. + + Raises + ------ + RuntimeError + If the algorithm fails to converge within `max_iter` iterations. + + Examples + -------- + >>> import easygraph as eg + >>> from easygraph import katz_centrality + >>> G = eg.Graph() + >>> G.add_edges_from([(0, 1), (1, 2), (2, 3)]) + >>> katz_centrality(G, alpha=0.05) + {0: 0.370..., 1: 0.447..., 2: 0.447..., 3: 0.370...} + """ + # Create node ordering + nodes = list(G.nodes) + n = len(nodes) + node_to_index = {node: i for i, node in enumerate(nodes)} + index_to_node = {i: node for i, node in enumerate(nodes)} + + # Build adjacency matrix + A = np.zeros((n, n), dtype=np.float64) + for u in G.nodes: + for v in G.adj[u]: + A[node_to_index[u], node_to_index[v]] = 1.0 + + # Initialize x and beta + x = np.ones(n, dtype=np.float64) + if isinstance(beta, dict): + b = np.array([beta.get(index_to_node[i], 1.0) for i in range(n)]) + else: + b = np.ones(n, dtype=np.float64) * beta + + # Iterative update using vectorized ops + for _ in range(max_iter): + x_new = alpha * A @ x + b + if np.linalg.norm(x_new - x, ord=1) < tol: + break + x = x_new + else: + raise RuntimeError(f"Katz centrality failed to converge in {max_iter} iterations") + + if normalized: + norm = np.linalg.norm(x) + if norm > 0: + x /= norm + + result = {index_to_node[i]: float(x[i]) for i in range(n)} + return result diff --git a/easygraph/functions/centrality/laplacian.py b/easygraph/functions/centrality/laplacian.py new file mode 100644 index 0000000..914afd3 --- /dev/null +++ b/easygraph/functions/centrality/laplacian.py @@ -0,0 +1,134 @@ +from easygraph.utils import * + + +__all__ = ["laplacian"] + + +@not_implemented_for("multigraph") +def laplacian(G, n_workers=None): + """Returns the laplacian centrality of each node in the weighted graph + + Parameters + ---------- + G : graph + weighted graph + + Returns + ------- + CL : dict + the laplacian centrality of each node in the weighted graph + + Examples + -------- + Returns the laplacian centrality of each node in the weighted graph G + + >>> laplacian(G) + + Reference + --------- + .. [1] Xingqin Qi, Eddie Fuller, Qin Wu, Yezhou Wu, Cun-Quan Zhang. + "Laplacian centrality: A new centrality measure for weighted networks." + Information Sciences, Volume 194, Pages 240-253, 2012. + + """ + adj = G.adj + from collections import defaultdict + + X = defaultdict(int) + W = defaultdict(int) + CL = {} + + if n_workers is not None: + # use the parallel version for large graph + import random + + from functools import partial + from multiprocessing import Pool + + nodes = list(G.nodes) + random.shuffle(nodes) + + if len(nodes) > n_workers * 30000: + nodes = split_len(nodes, step=30000) + else: + nodes = split(nodes, n_workers) + + local_function = partial(initialize_parallel, G=G, adj=adj) + with Pool(n_workers) as p: + ret = p.imap(local_function, nodes) + resX, resW = [], [] + for i in ret: + for x in i: + resX.append(x[0]) + resW.append(x[1]) + X = dict(resX) + W = dict(resW) + ELG = sum(X[i] * X[i] for i in G) + sum(W[i] for i in G) + local_function = partial(laplacian_parallel, G=G, X=X, W=W, adj=adj, ELG=ELG) + with Pool(n_workers) as p: + ret = p.imap(local_function, nodes) + res = [x for i in ret for x in i] + CL = dict(res) + + else: + # use np-parallel version for small graph + for i in G: + for j in G: + if i in G and j in G[i]: + X[i] += adj[i][j].get("weight", 1) + W[i] += adj[i][j].get("weight", 1) * adj[i][j].get("weight", 1) + ELG = sum(X[i] * X[i] for i in G) + sum(W[i] for i in G) + for i in G: + import copy + + Xi = copy.deepcopy(X) + for j in G: + if j in adj.keys() and i in adj[j].keys(): + Xi[j] -= adj[j][i].get("weight", 1) + Xi[i] = 0 + ELGi = sum(Xi[i] * Xi[i] for i in G) + sum(W[i] for i in G) - 2 * W[i] + if ELG: + CL[i] = (float)(ELG - ELGi) / ELG + return CL + + +def initialize_parallel(nodes, G, adj): + ret = [] + for i in nodes: + X = 0 + W = 0 + for j in G: + if j in G[i]: + X += adj[i][j].get("weight", 1) + W += adj[i][j].get("weight", 1) * adj[i][j].get("weight", 1) + ret.append([[i, X], [i, W]]) + return ret + + +def laplacian_parallel(nodes, G, X, W, adj, ELG): + ret = [] + for i in nodes: + import copy + + Xi = copy.deepcopy(X) + for j in G: + if j in adj.keys() and i in adj[j].keys(): + Xi[j] -= adj[j][i].get("weight", 1) + Xi[i] = 0 + ELGi = sum(Xi[i] * Xi[i] for i in G) + sum(W[i] for i in G) - 2 * W[i] + if ELG: + ret.append([i, (float)(ELG - ELGi) / ELG]) + return ret + + +def sort(data): + return dict(sorted(data.items(), key=lambda x: x[0], reverse=True)) + + +def output(data, path): + import json + + data = sort(data) + json_str = json.dumps(data, ensure_ascii=False, indent=4) + with open(path, "w", encoding="utf-8") as json_file: + json_file.write(json_str) diff --git a/easygraph/functions/centrality/pagerank.py b/easygraph/functions/centrality/pagerank.py new file mode 100644 index 0000000..fa8112f --- /dev/null +++ b/easygraph/functions/centrality/pagerank.py @@ -0,0 +1,58 @@ +import easygraph as eg + +from easygraph.utils import * + + +__all__ = ["pagerank"] + + +@not_implemented_for("multigraph") +@hybrid("cpp_pagerank") +def pagerank(G, alpha=0.85, weight=None): + """ + Returns the PageRank value of each node in G. + + Parameters + ---------- + G : graph + Undirected graph will be considered as directed graph with two directed edges for each undirected edge. + + alpha : float + The damping factor. Default is 0.85 + + weight : None or string, optional (default=None) + If None, all edge weights are considered equal. + Otherwise holds the name of the edge attribute used as weight. + """ + import numpy as np + + if len(G) == 0: + return {} + M = google_matrix(G, alpha=alpha, weight=weight) + + # use numpy LAPACK solver + eigenvalues, eigenvectors = np.linalg.eig(M.T) + ind = np.argmax(eigenvalues) + # eigenvector of largest eigenvalue is at ind, normalized + largest = np.array(eigenvectors[:, ind]).flatten().real + norm = float(largest.sum()) + return dict(zip(G, map(float, largest / norm))) + + +def google_matrix(G, alpha, weight=None): + import numpy as np + + M = eg.to_numpy_array(G, weight=weight).astype(float) + N = len(G) + if N == 0: + return M + + # Get dangling nodes(nodes with no out link) + dangling_nodes = np.where(M.sum(axis=1) == 0)[0] + dangling_weights = np.repeat(1.0 / N, N) + for node in dangling_nodes: + M[node] = dangling_weights + + M /= M.sum(axis=1)[:, np.newaxis] + + return alpha * M + (1 - alpha) * np.repeat(1.0 / N, N) diff --git a/easygraph/functions/centrality/tests/__init__.py b/easygraph/functions/centrality/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easygraph/functions/centrality/tests/test_betweenness.py b/easygraph/functions/centrality/tests/test_betweenness.py new file mode 100644 index 0000000..82d6a55 --- /dev/null +++ b/easygraph/functions/centrality/tests/test_betweenness.py @@ -0,0 +1,99 @@ +import unittest + +import easygraph as eg + + +class Test_betweenness(unittest.TestCase): + def setUp(self): + self.edges = [ + (1, 4), + (2, 4), + ("String", "Bool"), + (4, 1), + (0, 4), + (4, 256), + ((None, None), (None, None)), + ] + self.test_graphs = [eg.Graph(), eg.DiGraph()] + self.test_graphs.append(eg.classes.DiGraph(self.edges)) + + self.undirected = eg.Graph() + self.undirected.add_edges_from([(0, 1), (1, 2), (2, 3), (3, 4)]) + + self.directed = eg.DiGraph() + self.directed.add_edges_from([(0, 1), (1, 2), (2, 3), (3, 4)]) + + self.disconnected = eg.Graph() + self.disconnected.add_edges_from([(0, 1), (2, 3)]) + + self.single_node = eg.Graph() + self.single_node.add_node(42) + + self.two_node = eg.Graph() + self.two_node.add_edge("A", "B") + + self.named_nodes = eg.Graph() + self.named_nodes.add_edges_from([("X", "Y"), ("Y", "Z")]) + + def test_betweenness(self): + for i in self.test_graphs: + print(eg.functions.betweenness_centrality(i)) + + def test_basic_undirected(self): + result = eg.functions.betweenness_centrality(self.undirected) + self.assertEqual(len(result), len(self.undirected.nodes)) + self.assertTrue(all(isinstance(x, float) for x in result)) + + def test_basic_directed(self): + result = eg.functions.betweenness_centrality(self.directed) + self.assertEqual(len(result), len(self.directed.nodes)) + + def test_disconnected(self): + result = eg.functions.betweenness_centrality(self.disconnected) + self.assertEqual(len(result), len(self.disconnected.nodes)) + self.assertTrue(all(v == 0.0 for v in result)) + + def test_single_node_graph(self): + result = eg.functions.betweenness_centrality(self.single_node) + self.assertEqual(result, [0.0]) + + def test_two_node_graph(self): + result = eg.functions.betweenness_centrality(self.two_node) + self.assertEqual(len(result), 2) + self.assertTrue(all(v == 0.0 for v in result)) + + def test_named_nodes_graph(self): + result = eg.functions.betweenness_centrality(self.named_nodes) + self.assertEqual(len(result), 3) + + def test_with_endpoints(self): + result = eg.functions.betweenness_centrality(self.undirected, endpoints=True) + self.assertEqual(len(result), len(self.undirected.nodes)) + + def test_unormalized(self): + result = eg.functions.betweenness_centrality(self.undirected, normalized=False) + self.assertEqual(len(result), len(self.undirected.nodes)) + + def test_subset_sources(self): + result = eg.functions.betweenness_centrality(self.undirected, sources=[1, 2]) + self.assertEqual(len(result), len(self.undirected.nodes)) + + def test_parallel_workers(self): + result = eg.functions.betweenness_centrality(self.undirected, n_workers=2) + self.assertEqual(len(result), len(self.undirected.nodes)) + + def test_multigraph_error(self): + G = eg.MultiGraph() + G.add_edges_from([(0, 1), (0, 1)]) + with self.assertRaises(eg.EasyGraphNotImplemented): + eg.functions.betweenness_centrality(G) + + def test_all_nodes_type_mix(self): + G = eg.Graph() + G.add_edges_from([(1, 2), ("A", "B"), ((1, 2), (3, 4))]) + result = eg.functions.betweenness_centrality(G) + self.assertEqual(len(result), len(G.nodes)) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/centrality/tests/test_closeness.py b/easygraph/functions/centrality/tests/test_closeness.py new file mode 100644 index 0000000..af04860 --- /dev/null +++ b/easygraph/functions/centrality/tests/test_closeness.py @@ -0,0 +1,86 @@ +import unittest + +import easygraph as eg + +from easygraph.classes.multigraph import MultiGraph +from easygraph.functions.centrality import closeness_centrality + + +class Test_closeness(unittest.TestCase): + def setUp(self): + self.edges = [ + (1, 4), + (2, 4), + ("String", "Bool"), + (4, 1), + (0, 4), + (4, 256), + ((None, None), (None, None)), + ] + self.test_graphs = [eg.Graph(), eg.DiGraph()] + self.test_graphs.append(eg.classes.DiGraph(self.edges)) + + self.simple_graph = eg.Graph() + self.simple_graph.add_edges_from([(0, 1), (1, 2), (2, 3)]) + + self.directed_graph = eg.DiGraph() + self.directed_graph.add_edges_from([(0, 1), (1, 2), (2, 3)]) + + self.weighted_graph = eg.Graph() + self.weighted_graph.add_edges_from([(0, 1), (1, 2), (2, 3)]) + for u, v, data in self.weighted_graph.edges: + data["weight"] = 2 + + self.disconnected_graph = eg.Graph() + self.disconnected_graph.add_edges_from([(0, 1), (2, 3)]) + + self.single_node_graph = eg.Graph() + self.single_node_graph.add_node(42) + + self.mixed_nodes_graph = eg.Graph() + self.mixed_nodes_graph.add_edges_from([(1, 2), ("X", "Y"), ((1, 2), (3, 4))]) + + def test_closeness(self): + for i in self.test_graphs: + result = closeness_centrality(i) + self.assertEqual(len(result), len(i)) + + def test_simple_graph(self): + result = closeness_centrality(self.simple_graph) + self.assertEqual(len(result), len(self.simple_graph)) + self.assertTrue(all(isinstance(x, float) for x in result)) + + def test_directed_graph(self): + result = closeness_centrality(self.directed_graph) + self.assertEqual(len(result), len(self.directed_graph)) + + def test_weighted_graph(self): + result = closeness_centrality(self.weighted_graph, weight="weight") + self.assertEqual(len(result), len(self.weighted_graph)) + + def test_disconnected_graph(self): + result = closeness_centrality(self.disconnected_graph) + self.assertEqual(len(result), len(self.disconnected_graph)) + self.assertTrue(all(v <= 1.0 for v in result)) + + def test_single_node_graph(self): + result = closeness_centrality(self.single_node_graph) + self.assertEqual(result, [0.0]) + + def test_mixed_node_types(self): + result = closeness_centrality(self.mixed_nodes_graph) + self.assertEqual(len(result), len(self.mixed_nodes_graph)) + + def test_parallel_workers(self): + result = closeness_centrality(self.simple_graph, n_workers=2) + self.assertEqual(len(result), len(self.simple_graph)) + + def test_multigraph_raises(self): + G = MultiGraph() + G.add_edges_from([(0, 1), (0, 1)]) + with self.assertRaises(eg.EasyGraphNotImplemented): + closeness_centrality(G) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/centrality/tests/test_degree.py b/easygraph/functions/centrality/tests/test_degree.py new file mode 100644 index 0000000..24a3079 --- /dev/null +++ b/easygraph/functions/centrality/tests/test_degree.py @@ -0,0 +1,78 @@ +import unittest + +import easygraph as eg + +from easygraph.utils.exception import EasyGraphNotImplemented + + +class Test_degree(unittest.TestCase): + def setUp(self): + self.edges = [ + (1, 4), + (2, 4), + ("String", "Bool"), + (4, 1), + (0, 4), + (4, 256), + ((None, None), (None, None)), + ] + self.test_graphs = [eg.Graph(), eg.DiGraph()] + self.test_graphs.append(eg.classes.DiGraph(self.edges)) + + self.undirected_graph = eg.Graph() + self.undirected_graph.add_edges_from([(0, 1), (1, 2), (2, 3)]) + + # Directed graph + self.directed_graph = eg.DiGraph() + self.directed_graph.add_edges_from([(0, 1), (1, 2), (2, 3)]) + + # Single-node graph + self.single_node_graph = eg.Graph() + self.single_node_graph.add_node(0) + + # Empty graph + self.empty_graph = eg.Graph() + + # Multigraph + self.multigraph = eg.MultiGraph() + self.multigraph.add_edges_from([(0, 1), (0, 1)]) + + def test_degree(self): + for i in self.test_graphs: + print(i.edges) + print(eg.functions.degree_centrality(i)) + print(eg.functions.in_degree_centrality(i)) + print(eg.functions.out_degree_centrality(i)) + + def test_degree_centrality_undirected(self): + result = eg.functions.degree_centrality(self.undirected_graph) + self.assertEqual(len(result), len(self.undirected_graph)) + self.assertTrue(all(isinstance(v, float) for v in result.values())) + + def test_degree_centrality_directed(self): + result = eg.functions.degree_centrality(self.directed_graph) + self.assertEqual(len(result), len(self.directed_graph)) + + def test_degree_centrality_single_node(self): + result = eg.functions.degree_centrality(self.single_node_graph) + self.assertEqual(result, {0: 1}) + + def test_degree_centrality_empty_graph(self): + result = eg.functions.degree_centrality(self.empty_graph) + self.assertEqual(result, {}) + + def test_in_out_degree_centrality_directed(self): + in_deg = eg.functions.in_degree_centrality(self.directed_graph) + out_deg = eg.functions.out_degree_centrality(self.directed_graph) + self.assertEqual(len(in_deg), len(self.directed_graph)) + self.assertEqual(len(out_deg), len(self.directed_graph)) + + def test_in_out_degree_centrality_single_node(self): + G = eg.DiGraph() + G.add_node(1) + self.assertEqual(eg.functions.in_degree_centrality(G), {1: 1}) + self.assertEqual(eg.functions.out_degree_centrality(G), {1: 1}) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/centrality/tests/test_egobetweenness.py b/easygraph/functions/centrality/tests/test_egobetweenness.py new file mode 100644 index 0000000..48e7024 --- /dev/null +++ b/easygraph/functions/centrality/tests/test_egobetweenness.py @@ -0,0 +1,73 @@ +import unittest + +import easygraph as eg + +from easygraph.utils.exception import EasyGraphNotImplemented + + +class Test_egobetweenness(unittest.TestCase): + def setUp(self): + self.edges = [ + (1, 4), + (2, 4), + ("String", "Bool"), + (4, 1), + (0, 4), + (4, 256), + ((None, None), (None, None)), + ] + self.test_graphs = [eg.Graph(), eg.DiGraph()] + self.test_graphs.append(eg.classes.DiGraph(self.edges)) + print(self.test_graphs[-1].edges) + + self.graph = eg.Graph() + self.graph.add_edges_from([(0, 1), (1, 2), (2, 3)]) + + self.directed_graph = eg.DiGraph() + self.directed_graph.add_edges_from([(0, 1), (1, 2), (2, 0)]) + + self.mixed_nodes_graph = eg.Graph() + self.mixed_nodes_graph.add_edges_from([(1, "A"), ("A", (2, 3)), ((2, 3), "B")]) + + self.single_node_graph = eg.Graph() + self.single_node_graph.add_node(42) + + self.disconnected_graph = eg.Graph() + self.disconnected_graph.add_edges_from([(0, 1), (2, 3)]) # two components + + self.multigraph = eg.MultiGraph() + self.multigraph.add_edges_from([(0, 1), (0, 1)]) # parallel edges + + def test_egobetweenness(self): + print(eg.functions.ego_betweenness(self.test_graphs[-1], 4)) + + def test_small_undirected_graph(self): + result = eg.functions.ego_betweenness(self.graph, 1) + self.assertIsInstance(result, float) + self.assertGreaterEqual(result, 0) + + def test_directed_graph(self): + result = eg.functions.ego_betweenness(self.directed_graph, 0) + self.assertIsInstance(result, int) + + def test_mixed_node_types(self): + result = eg.functions.ego_betweenness(self.mixed_nodes_graph, "A") + self.assertIsInstance(result, float) + + def test_single_node_graph(self): + result = eg.functions.ego_betweenness(self.single_node_graph, 42) + self.assertEqual(result, 0.0) + + def test_disconnected_graph_component(self): + result_0 = eg.functions.ego_betweenness(self.disconnected_graph, 0) + result_2 = eg.functions.ego_betweenness(self.disconnected_graph, 2) + self.assertIsInstance(result_0, float) + self.assertIsInstance(result_2, float) + + def test_raises_on_multigraph(self): + with self.assertRaises(EasyGraphNotImplemented): + eg.functions.ego_betweenness(self.multigraph, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/centrality/tests/test_flowbetweenness.py b/easygraph/functions/centrality/tests/test_flowbetweenness.py new file mode 100644 index 0000000..82759aa --- /dev/null +++ b/easygraph/functions/centrality/tests/test_flowbetweenness.py @@ -0,0 +1,90 @@ +import unittest + +import easygraph as eg + +from easygraph.utils.exception import EasyGraphNotImplemented + + +class Test_flowbetweenness(unittest.TestCase): + def setUp(self): + self.edges = [ + (1, 2), + (2, 3), + ("String", "Bool"), + (2, 1), + (0, 0), + (-99, 256), + ((None, None), (None, None)), + ] + self.test_graphs = [eg.Graph(), eg.DiGraph()] + self.test_graphs.append(eg.classes.DiGraph(self.edges)) + + self.directed_graph = eg.DiGraph() + self.directed_graph.add_edges_from( + [ + (0, 1, {"weight": 3}), + (1, 2, {"weight": 1}), + (0, 2, {"weight": 1}), + (2, 3, {"weight": 2}), + (1, 3, {"weight": 4}), + ] + ) + + self.graph_with_self_loop = eg.DiGraph() + self.graph_with_self_loop.add_edges_from([(0, 1), (1, 2), (2, 2), (2, 3)]) + + self.disconnected_graph = eg.DiGraph() + self.disconnected_graph.add_edges_from([(0, 1), (2, 3)]) + + self.undirected_graph = eg.Graph() + self.undirected_graph.add_edges_from([(0, 1), (1, 2)]) + + self.single_node_graph = eg.DiGraph() + self.single_node_graph.add_node(0) + + self.mixed_type_graph = eg.DiGraph() + self.mixed_type_graph.add_edges_from([(1, "A"), ("A", (2, 3)), ((2, 3), "B")]) + + self.multigraph = eg.MultiDiGraph() + self.multigraph.add_edges_from([(0, 1), (0, 1)]) + + def test_flowbetweenness_centrality(self): + for i in self.test_graphs: + print(i.edges) + print(eg.functions.flowbetweenness_centrality(i)) + + def test_flowbetweenness_on_directed(self): + result = eg.functions.flowbetweenness_centrality(self.directed_graph) + self.assertIsInstance(result, dict) + self.assertTrue( + all(isinstance(v, float) or isinstance(v, int) for v in result.values()) + ) + + def test_flowbetweenness_on_self_loop(self): + result = eg.functions.flowbetweenness_centrality(self.graph_with_self_loop) + self.assertIsInstance(result, dict) + + def test_flowbetweenness_on_disconnected(self): + result = eg.functions.flowbetweenness_centrality(self.disconnected_graph) + self.assertIsInstance(result, dict) + + def test_flowbetweenness_on_single_node(self): + result = eg.functions.flowbetweenness_centrality(self.single_node_graph) + self.assertIsInstance(result, dict) + self.assertEqual(result, {0: 0}) + + def test_flowbetweenness_on_mixed_types(self): + result = eg.functions.flowbetweenness_centrality(self.mixed_type_graph) + self.assertIsInstance(result, dict) + + def test_flowbetweenness_on_undirected_warns(self): + result = eg.functions.flowbetweenness_centrality(self.undirected_graph) + self.assertIsNone(result) + + def test_flowbetweenness_raises_on_multigraph(self): + with self.assertRaises(EasyGraphNotImplemented): + eg.functions.flowbetweenness_centrality(self.multigraph) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/centrality/tests/test_laplacian.py b/easygraph/functions/centrality/tests/test_laplacian.py new file mode 100644 index 0000000..d7dda7e --- /dev/null +++ b/easygraph/functions/centrality/tests/test_laplacian.py @@ -0,0 +1,106 @@ +import unittest + +import easygraph as eg + +from easygraph.utils.exception import EasyGraphNotImplemented + + +class Test_laplacian(unittest.TestCase): + def setUp(self): + self.edges = [ + (1, 2), + (2, 3), + ("String", "Bool"), + (2, 1), + (0, 0), + (-99, 256), + ((None, None), (None, None)), + ] + self.test_graphs = [eg.Graph(), eg.DiGraph()] + self.test_graphs.append(eg.classes.DiGraph(self.edges)) + self.weighted_graph = eg.Graph() + self.weighted_graph.add_edges_from( + [ + (0, 1, {"weight": 2}), + (1, 2, {"weight": 3}), + (2, 3, {"weight": 4}), + (3, 0, {"weight": 1}), + ] + ) + + self.unweighted_graph = eg.Graph() + self.unweighted_graph.add_edges_from( + [ + (0, 1), + (1, 2), + (2, 3), + ] + ) + + self.directed_graph = eg.DiGraph() + self.directed_graph.add_edges_from( + [ + (0, 1, {"weight": 2}), + (1, 2, {"weight": 1}), + (2, 0, {"weight": 3}), + ] + ) + + self.self_loop_graph = eg.Graph() + self.self_loop_graph.add_edges_from( + [ + (0, 0, {"weight": 2}), + (0, 1, {"weight": 1}), + ] + ) + + self.mixed_type_graph = eg.Graph() + self.mixed_type_graph.add_edges_from( + [ + ("A", "B"), + ("B", (1, 2)), + ] + ) + + self.single_node_graph = eg.Graph() + self.single_node_graph.add_node(42) + + self.multigraph = eg.MultiGraph() + self.multigraph.add_edges_from([(0, 1), (0, 1)]) + + def test_laplacian(self): + for i in self.test_graphs: + print(i.edges) + print(eg.functions.laplacian(i)) + + def test_weighted_graph(self): + result = eg.functions.laplacian(self.weighted_graph) + self.assertEqual(set(result.keys()), set(self.weighted_graph.nodes)) + + def test_unweighted_graph(self): + result = eg.functions.laplacian(self.unweighted_graph) + self.assertEqual(set(result.keys()), set(self.unweighted_graph.nodes)) + + def test_directed_graph(self): + result = eg.functions.laplacian(self.directed_graph) + self.assertEqual(set(result.keys()), set(self.directed_graph.nodes)) + + def test_self_loop_graph(self): + result = eg.functions.laplacian(self.self_loop_graph) + self.assertEqual(set(result.keys()), set(self.self_loop_graph.nodes)) + + def test_mixed_node_types(self): + result = eg.functions.laplacian(self.mixed_type_graph) + self.assertEqual(set(result.keys()), set(self.mixed_type_graph.nodes)) + + def test_single_node_graph(self): + result = eg.functions.laplacian(self.single_node_graph) + self.assertEqual(result, {}) + + def test_multigraph_raises(self): + with self.assertRaises(EasyGraphNotImplemented): + eg.functions.laplacian(self.multigraph) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/centrality/tests/test_pagerank.py b/easygraph/functions/centrality/tests/test_pagerank.py new file mode 100644 index 0000000..6b1cfe8 --- /dev/null +++ b/easygraph/functions/centrality/tests/test_pagerank.py @@ -0,0 +1,90 @@ +import unittest + +import easygraph as eg + +from easygraph.utils.exception import EasyGraphNotImplemented + + +class Test_pagerank(unittest.TestCase): + def setUp(self): + edges = [ + (1, 2), + (2, 3), + ("String", "Bool"), + (2, 1), + (0, 0), + ((None, None), (None, None)), + ] + self.g = eg.classes.DiGraph(edges) + self.directed_graph = eg.DiGraph() + self.directed_graph.add_edges_from([(0, 1), (1, 2), (2, 0)]) + + self.undirected_graph = eg.Graph() + self.undirected_graph.add_edges_from([(0, 1), (1, 2), (2, 0)]) + + self.disconnected_graph = eg.DiGraph() + self.disconnected_graph.add_edges_from([(0, 1), (2, 3)]) + + self.self_loop_graph = eg.DiGraph() + self.self_loop_graph.add_edges_from([(0, 0), (0, 1), (1, 2)]) + + self.mixed_graph = eg.DiGraph() + self.mixed_graph.add_edges_from([("A", "B"), ("B", "C"), ("C", (1, 2))]) + + self.single_node_graph = eg.DiGraph() + self.single_node_graph.add_node("solo") + + self.multigraph = eg.MultiDiGraph() + self.multigraph.add_edges_from([(0, 1), (0, 1)]) + + def test_pagerank(self): + test_graphs = [eg.Graph(), eg.DiGraph()] + for i in test_graphs: + print(eg.functions.pagerank(i)) + + print(self.g.nodes) + print(eg.functions.pagerank(self.g)) + + """ + def test_google_matrix(self): + test_graphs = [eg.Graph(), eg.DiGraph(), eg.MultiGraph(), eg.MultiDiGraph()] + for g in test_graphs: + print(eg.functions.pagerank.(g)) + """ + + def test_directed_graph(self): + result = eg.functions.pagerank(self.directed_graph) + self.assertEqual(set(result.keys()), set(self.directed_graph.nodes)) + + def test_undirected_graph(self): + result = eg.functions.pagerank(self.undirected_graph) + self.assertEqual(set(result.keys()), set(self.undirected_graph.nodes)) + + def test_disconnected_graph(self): + result = eg.functions.pagerank(self.disconnected_graph) + self.assertEqual(set(result.keys()), set(self.disconnected_graph.nodes)) + + def test_self_loop_graph(self): + result = eg.functions.pagerank(self.self_loop_graph) + self.assertEqual(set(result.keys()), set(self.self_loop_graph.nodes)) + + def test_mixed_node_types(self): + result = eg.functions.pagerank(self.mixed_graph) + self.assertEqual(set(result.keys()), set(self.mixed_graph.nodes)) + + def test_single_node_graph(self): + result = eg.functions.pagerank(self.single_node_graph) + self.assertEqual(result, {"solo": 1.0}) + + def test_empty_graph(self): + empty_graph = eg.DiGraph() + result = eg.functions.pagerank(empty_graph) + self.assertEqual(result, {}) + + def test_multigraph_raises(self): + with self.assertRaises(EasyGraphNotImplemented): + eg.functions.pagerank(self.multigraph) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/community/LPA.py b/easygraph/functions/community/LPA.py new file mode 100644 index 0000000..9c43f18 --- /dev/null +++ b/easygraph/functions/community/LPA.py @@ -0,0 +1,761 @@ +import copy +import random + +from collections import defaultdict +from queue import Queue + +import easygraph as eg +import numpy as np + +from easygraph.utils import * + + +__all__ = [ + "LPA", + "SLPA", + "HANP", + "BMLPA", +] + + +@not_implemented_for("multigraph") +def LPA(G): + """Detect community by label propagation algorithm + Return the detected communities. But the result is random. + Each node in the network is initially assigned to its own community. At every iteration,nodes have + a label that the maximum number of their neighbors have. If there are more than one nodes fit and + available, choose a label randomly. Finally, nodes having the same labels are grouped together as + communities. In case two or more disconnected groups of nodes have the same label, we run a simple + breadth-first search to separate the disconnected communities + + Parameters + ---------- + G : graph + A easygraph graph + + Returns + ---------- + communities : dictionary + key: serial number of community , value: nodes in the community. + + Examples + ---------- + >>> LPA(G) + + References + ---------- + .. [1] Usha Nandini Raghavan, Réka Albert, and Soundar Kumara: + Near linear time algorithm to detect community structures in large-scale networks + """ + i = 0 + label_dict = dict() + cluster_community = dict() + Next_label_dict = dict() + nodes = list(G.nodes.keys()) + if len(nodes) == 1: + return {1: [nodes[0]]} + for node in nodes: + label_dict[node] = i + i = i + 1 + loop_count = 0 + while True: + loop_count += 1 + random.shuffle(nodes) + for node in nodes: + labels = SelectLabels(G, node, label_dict) + if labels == []: + Next_label_dict[node] = label_dict[node] + continue + Next_label_dict[node] = random.choice(labels) + # Asynchronous updates. If you want to use synchronous updates, comment the line below + label_dict[node] = Next_label_dict[node] + label_dict = Next_label_dict + if estimate_stop_cond(G, label_dict) is True: + break + for node in label_dict.keys(): + label = label_dict[node] + if label not in cluster_community.keys(): + cluster_community[label] = [node] + else: + cluster_community[label].append(node) + + result_community = CheckConnectivity(G, cluster_community) + return result_community + + +@not_implemented_for("multigraph") +def SLPA(G, T, r): + """Detect Overlapping Communities by Speaker-listener Label Propagation Algorithm + Return the detected Overlapping communities. But the result is random. + + Parameters + ---------- + G : graph + A easygraph graph. + T : int + The number of iterations, In general, T is set greater than 20, which produces relatively stable outputs. + r : int + a threshold between 0 and 1. + + Returns + ------- + communities : dictionary + key: serial number of community , value: nodes in the community. + + Examples + ---------- + >>> SLPA(G, + ... T = 20, + ... r = 0.05 + ... ) + + References + ---------- + .. [1] Jierui Xie, Boleslaw K. Szymanski, Xiaoming Liu: + SLPA: Uncovering Overlapping Communities in Social Networks via A Speaker-listener Interaction Dynamic Process + """ + nodes = list(G.nodes.keys()) + if len(nodes) == 1: + return {1: [nodes[0]]} + nodes = G.nodes + adj = G.adj + memory = {i: {i: 1} for i in nodes} + for i in range(0, T): + listenerslist = list(G.nodes) + random.shuffle(listenerslist) + for listener in listenerslist: + speakerlist = adj[listener] + if len(speakerlist) == 0: + continue + labels = defaultdict(int) + for speaker in speakerlist: + # Speaker Rule + total = float(sum(memory[speaker].values())) + keys = list(memory[speaker].keys()) + index = np.random.multinomial( + 1, [round(freq / total, 2) for freq in memory[speaker].values()] + ).argmax() + chosen_label = keys[index] + labels[chosen_label] += 1 + # Listener Rule + maxlabel = max(labels.items(), key=lambda x: x[1])[0] + if maxlabel in memory[listener]: + memory[listener][maxlabel] += 1 + else: + memory[listener][maxlabel] = 1 + + for node, labels in memory.items(): + name_list = [] + for label_name, label_number in labels.items(): + if round(label_number / float(T + 1), 2) < r: + name_list.append(label_name) + for name in name_list: + del labels[name] + + # Find nodes membership + communities = {} + for node, labels in memory.items(): + for label in labels: + if label in communities: + communities[label].add(node) + else: + communities[label] = {node} + + # Remove nested communities + RemoveNested(communities) + + # Check Connectivity + result_community = CheckConnectivity(G, communities) + return result_community + + +@not_implemented_for("multigraph") +def HANP(G, m, delta, threshod=1, hier_open=0, combine_open=0): + """Detect community by Hop attenuation & node preference algorithm + + Return the detected communities. But the result is random. + + Implement the basic HANP algorithm and give more freedom through the parameters, e.g., you can use threshod + to set the condition for node updating. If network are known to be Hierarchical and overlapping communities, + it's recommended to choose geodesic distance as the measure(instead of receiving the current hop scores + from the neighborhood and carry out a subtraction) and When an equilibrium is reached, treat newly combined + communities as a single node. + + For using Floyd to get the shortest distance, the time complexity is a little high. + + Parameters + ---------- + G : graph + A easygraph graph + m : float + Used to calculate score, when m > 0, more preference is given to node with more neighbors; m < 0, less + delta : float + Hop attenuation + threshod : float + Between 0 and 1, only update node whose number of neighbors sharing the maximal label is less than the threshod. + e.g., threshod == 1 means updating all nodes. + hier_open : + 1 means using geodesic distance as the score measure. + 0 means not. + combine_open : + this option is valid only when hier_open = 1 + 1 means When an equilibrium is reached, treat newly combined communities as a single node. + 0 means not. + + Returns + ---------- + communities : dictionary + key: serial number of community , value: nodes in the community. + + Examples + ---------- + >>> HANP(G, + ... m = 0.1, + ... delta = 0.05, + ... threshod = 1, + ... hier_open = 0, + ... combine_open = 0 + ... ) + + References + ---------- + .. [1] Ian X. Y. Leung, Pan Hui, Pietro Liò, and Jon Crowcrof: + Towards real-time community detection in large networks + + """ + nodes = list(G.nodes.keys()) + if len(nodes) == 1: + return {1: [nodes[0]]} + label_dict = dict() + score_dict = dict() + node_dict = dict() + Next_label_dict = dict() + cluster_community = dict() + nodes = list(G.nodes.keys()) + degrees = G.degree() + records = [] + loop_count = 0 + i = 0 + old_score = 1 + ori_G = G + if hier_open == 1: + distance_dict = eg.Floyd(G) + for node in nodes: + label_dict[node] = i + score_dict[i] = 1 + node_dict[i] = node + i = i + 1 + while True: + loop_count += 1 + random.shuffle(nodes) + score = 1 + for node in nodes: + labels = SelectLabels_HANP( + G, node, label_dict, score_dict, degrees, m, threshod + ) + if labels == []: + Next_label_dict[node] = label_dict[node] + continue + old_label = label_dict[node] + Next_label_dict[node] = random.choice(labels) + # Asynchronous updates. If you want to use synchronous updates, comment the line below + label_dict[node] = Next_label_dict[node] + if hier_open == 1: + score_dict[Next_label_dict[node]] = UpdateScore_Hier( + G, node, label_dict, node_dict, distance_dict + ) + score = min(score, score_dict[Next_label_dict[node]]) + else: + if old_label == Next_label_dict[node]: + cdelta = 0 + else: + cdelta = delta + score_dict[Next_label_dict[node]] = UpdateScore( + G, node, label_dict, score_dict, cdelta + ) + if hier_open == 1 and combine_open == 1: + if old_score - score > 1 / 3: + old_score = score + ( + records, + G, + label_dict, + score_dict, + node_dict, + Next_label_dict, + nodes, + degrees, + distance_dict, + ) = CombineNodes( + records, + G, + label_dict, + score_dict, + node_dict, + Next_label_dict, + nodes, + degrees, + distance_dict, + ) + label_dict = Next_label_dict + if ( + estimate_stop_cond_HANP(G, label_dict, score_dict, degrees, m, threshod) + is True + ): + break + """As mentioned in the paper, it's suggested that the number of iterations + required is independent to the number of nodes and that after + five iterations, 95% of their nodes are already accurately clustered + """ + if loop_count > 20: + break + print("After %d iterations, HANP complete." % loop_count) + for node in label_dict.keys(): + label = label_dict[node] + if label not in cluster_community.keys(): + cluster_community[label] = [node] + else: + cluster_community[label].append(node) + if hier_open == 1 and combine_open == 1: + records.append(cluster_community) + cluster_community = ShowRecord(records) + result_community = CheckConnectivity(ori_G, cluster_community) + return result_community + + +@not_implemented_for("multigraph") +def BMLPA(G, p): + """Detect community by Balanced Multi-Label Propagation algorithm + + Return the detected communities. + + Firstly, initialize 'old' using cores generated by RC function, the propagate label till the number and size + of communities stay no change, check if there are subcommunity and delete it. Finally, split discontinuous + communities. + + For some directed graphs lead to oscillations of labels, modify the stop condition. + + Parameters + ---------- + G : graph + A easygraph graph + p : float + Between 0 and 1, judge Whether a community identifier should be retained + + Returns + ---------- + communities : dictionary + key: serial number of community , value: nodes in the community. + + Examples + ---------- + >>> BMLPA(G, + ... p = 0.1, + ... ) + + References + ---------- + .. [1] Wu Zhihao, Lin You-Fang, Gregory Steve, Wan Huai-Yu, Tian Sheng-Feng + Balanced Multi-Label Propagation for Overlapping Community Detection in Social Networks + + """ + nodes = list(G.nodes.keys()) + if len(nodes) == 1: + return {1: [nodes[0]]} + cores = Rough_Cores(G) + nodes = G.nodes + i = 0 + old_label_dict = dict() + new_label_dict = dict() + for core in cores: + for node in core: + if node not in old_label_dict: + old_label_dict[node] = {i: 1} + else: + old_label_dict[node][i] = 1 + i += 1 + oldMin = dict() + loop_count = 0 + old_label_dictx = dict() + while True: + loop_count += 1 + old_label_dictx = old_label_dict + for node in nodes: + Propagate_bbc(G, node, old_label_dict, new_label_dict, p) + if loop_count > 50 and old_label_dict == old_label_dictx: + break + Min = dict() + if Id(old_label_dict) == Id(new_label_dict): + Min = mc(count(old_label_dict), count(new_label_dict)) + else: + Min = count(new_label_dict) + if loop_count > 500: + break + if Min != oldMin: + old_label_dict = copy.deepcopy(new_label_dict) + oldMin = copy.deepcopy(Min) + else: + break + print("After %d iterations, BMLPA complete." % loop_count) + communities = dict() + for node in nodes: + for label, _ in old_label_dict[node].items(): + if label in communities: + communities[label].add(node) + else: + communities[label] = {node} + RemoveNested(communities) + result_community = CheckConnectivity(G, communities) + return result_community + + +def RemoveNested(communities): + nestedCommunities = set() + keys = list(communities.keys()) + for i, label0 in enumerate(keys[:-1]): + comm0 = communities[label0] + for label1 in keys[i + 1 :]: + comm1 = communities[label1] + if comm0.issubset(comm1): + nestedCommunities.add(label0) + elif comm0.issuperset(comm1): + nestedCommunities.add(label1) + for comm in nestedCommunities: + del communities[comm] + + +def SelectLabels(G, node, label_dict): + adj = G.adj + count = {} + count_items = [] + for neighbor in adj[node]: + neighbor_label = label_dict[neighbor] + count[neighbor_label] = count.get(neighbor_label, 0) + 1 + count_items = sorted(count.items(), key=lambda x: x[1], reverse=True) + labels = [k for k, v in count_items if v == count_items[0][1]] + return labels + + +def estimate_stop_cond(G, label_dict): + for node in G.nodes: + if SelectLabels(G, node, label_dict) != [] and ( + label_dict[node] not in SelectLabels(G, node, label_dict) + ): + return False + return True + + +def SelectLabels_HANP(G, node, label_dict, score_dict, degrees, m, threshod): + adj = G.adj + count = defaultdict(float) + cnt = defaultdict(int) + for neighbor in adj[node]: + neighbor_label = label_dict[neighbor] + cnt[neighbor_label] += 1 + count[neighbor_label] += ( + score_dict[neighbor_label] + * (degrees[neighbor] ** m) + * adj[node][neighbor].get("weight", 1) + ) + count_items = sorted(count.items(), key=lambda x: x[1], reverse=True) + labels = [k for k, v in count_items if v == count_items[0][1]] + # only update node whose number of neighbors sharing the maximal label is less than a certain percentage. + if count_items == []: + return [] + if round(cnt[count_items[0][0]] / len(adj[node]), 2) > threshod: + return [label_dict[node]] + return labels + + +def HopAttenuation_Hier(G, node, label_dict, node_dict, distance_dict): + distance = float("inf") + Max_distance = 0 + adj = G.adj + label = label_dict[node] + ori_node = node_dict[label] + for _, distancex in distance_dict[ori_node].items(): + Max_distance = max(Max_distance, distancex) + for neighbor in adj[node]: + if label_dict[neighbor] == label: + distance = min(distance, distance_dict[ori_node][neighbor]) + return round((1 + distance) / Max_distance, 2) + + +def UpdateScore_Hier(G, node, label_dict, node_dict, distance_dict): + return 1 - HopAttenuation_Hier(G, node, label_dict, node_dict, distance_dict) + + +def UpdateScore(G, node, label_dict, score_dict, delta): + adj = G.adj + Max_score = 0 + label = label_dict[node] + for neighbor in adj[node]: + if label_dict[neighbor] == label: + Max_score = max(Max_score, score_dict[label_dict[neighbor]]) + return Max_score - delta + + +def estimate_stop_cond_HANP(G, label_dict, score_dict, degrees, m, threshod): + for node in G.nodes: + if SelectLabels_HANP( + G, node, label_dict, score_dict, degrees, m, threshod + ) != [] and label_dict[node] not in SelectLabels_HANP( + G, node, label_dict, score_dict, degrees, m, threshod + ): + return False + return True + + +def CombineNodes( + records, + G, + label_dict, + score_dict, + node_dict, + Next_label_dict, + nodes, + degrees, + distance_dict, +): + onerecord = dict() + for node, label in label_dict.items(): + if label in onerecord: + onerecord[label].append(node) + else: + onerecord[label] = [node] + records.append(onerecord) + Gx = eg.Graph() + label_dictx = dict() + score_dictx = dict() + node_dictx = dict() + nodesx = [] + cnt = 0 + for record_label in onerecord: + nodesx.append(cnt) + label_dictx[cnt] = record_label + score_dictx[record_label] = score_dict[record_label] + node_dictx[record_label] = cnt + cnt += 1 + record_labels = list(onerecord.keys()) + i = 0 + edge = dict() + adj = G.adj + for i in range(0, len(record_labels)): + edge[i] = dict() + for j in range(0, len(record_labels)): + if i == j: + continue + inodes = onerecord[record_labels[i]] + jnodes = onerecord[record_labels[j]] + for unode in inodes: + for vnode in jnodes: + if unode in adj and vnode in adj[unode]: + if j not in edge[i]: + edge[i][j] = 0 + edge[i][j] += adj[unode][vnode].get("weight", 1) + for unode in edge: + for vnode, w in edge[unode].items(): + if unode < vnode: + Gx.add_edge(unode, vnode, weight=w) + G = Gx + label_dict = label_dictx + score_dict = score_dictx + node_dict = node_dictx + Next_label_dict = label_dictx + nodes = nodesx + degrees = G.degree() + distance_dict = eg.Floyd(G) + return ( + records, + G, + label_dict, + score_dict, + node_dict, + Next_label_dict, + nodes, + degrees, + distance_dict, + ) + + +def ShowRecord(records): + """ + e.g. + records : [ {1:[1,2,3,4],2:[5,6,7,8],3:[9],4:[10],5:[11],6:[12]}, + {2:[0,1,3],3:[2,4,5]}, + {2:[0,1]} ] + + process : {1:[1,2,3,4],2:[5,6,7,8],3:[9],4:[10],5:[11],6:[12]} -> + {2:[ [1,2,3,4] + [5,6,7,8] + [10] ], 3:[ [9] + [11] + [12] ]} -> + {2:[ ([ [1,2,3,4] + [5,6,7,8] + [10] ]) + ([ [9] + [11] + [12] ] ]) } -> + + return : {2:[1,2,3,4,5,6,7,8,10,9,11,12]} + """ + result = dict() + first = records[0] + for i in range(1, len(records)): + keys = list(first.keys()) + onerecord = records[i] + result = {} + for label, nodes in onerecord.items(): + for unode in nodes: + for vnode in first[keys[unode]]: + if label not in result: + result[label] = [] + result[label].append(vnode) + first = result + return first + + +def CheckConnectivity(G, communities): + result_community = dict() + community = [list(community) for label, community in communities.items()] + communityx = [] + for nodes in community: + BFS(G, nodes, communityx) + i = 0 + for com in communityx: + i += 1 + result_community[i] = com + return result_community + + +def BFS(G, nodes, result): + # check the nodes in G are connected or not. if not, desperate the nodes into different connected subgraphs. + if len(nodes) == 0: + return + if len(nodes) == 1: + result.append(nodes) + return + adj = G.adj + queue = Queue() + queue.put(nodes[0]) + seen = set() + seen.add(nodes[0]) + count = 0 + while queue.empty() == 0: + vertex = queue.get() + count += 1 + for w in adj[vertex]: + if w in nodes and w not in seen: + queue.put(w) + seen.add(w) + if count != len(nodes): + result.append([w for w in seen]) + return BFS(G, [w for w in nodes if w not in seen], result) + else: + result.append(nodes) + return + + +def Rough_Cores(G): + nodes = G.nodes + degrees = G.degree() + adj = G.adj + seen_dict = dict() + label_dict = dict() + cores = [] + i = 0 + for node in nodes: + label_dict[node] = i + seen_dict[node] = 1 + i += 1 + degree_list = sorted(degrees.items(), key=lambda x: x[1], reverse=True) + for node, _ in degree_list: + core = [] + if degrees[node] >= 3 and seen_dict[node] == 1: + for neighbor in adj[node]: + max_degree = 0 + j = node + if seen_dict[neighbor] == 1: + if degrees[neighbor] > max_degree: + max_degree = degrees[neighbor] + j = neighbor + elif degrees[neighbor] == max_degree: + pass + if j != []: + core = [node] + [j] + commNeiber = [i for i in adj[node] if i in adj[j]] + commNeiber = [node for node, _ in degree_list if node in commNeiber] + commNeiber = commNeiber[::-1] + while commNeiber != []: + for h in commNeiber: + core.append(h) + for x in commNeiber: + if x not in adj[h]: + commNeiber.remove(x) + if h in commNeiber: + commNeiber.remove(h) + if len(core) >= 3: + for i in core: + seen_dict[i] = 0 + cores.append(core) + core_node = [] + for core in cores: + core_node += core + for node in nodes: + if node not in core_node: + cores.append([node]) + return cores + + +def Normalizer(l): + Sum = 0 + for identifier, coefficient in l.items(): + Sum += coefficient + for identifier, coefficient in l.items(): + l[identifier] = round(coefficient / Sum, 2) + + +def Propagate_bbc(G, x, source, dest, p): + adj = G.adj + dest[x] = dict() + max_b = 0 + for y in adj[x]: + for identifier, coefficient in source[y].items(): + b = coefficient + if identifier in dest[x]: + dest[x][identifier] += b + else: + dest[x][identifier] = b + max_b = max(dest[x][identifier], max_b) + if max_b == 0: + dest[x] = source[x] + return + for identifier in list(dest[x].keys()): + if dest[x][identifier] / max_b < p: + del dest[x][identifier] + Normalizer(dest[x]) + + +def Id(l): + ids = dict() + for x in l: + ids[x] = Id1(l[x]) + return ids + + +def Id1(x): + ids = [] + for identifier, _ in x.items(): + if identifier not in ids: + ids.append(identifier) + return ids + + +def count(l): + counts = dict() + for x in l: + for identifier, _ in l[x].items(): + if identifier in counts: + counts[identifier] += 1 + else: + counts[identifier] = 1 + return counts + + +def mc(cs1, cs2): + cs = dict() + for identifier, _ in cs1.items(): + cs[identifier] = min(cs1[identifier], cs2[identifier]) + return cs diff --git a/easygraph/functions/community/__init__.py b/easygraph/functions/community/__init__.py new file mode 100644 index 0000000..149e816 --- /dev/null +++ b/easygraph/functions/community/__init__.py @@ -0,0 +1,7 @@ +from .ego_graph import * +from .louvain import * +from .LPA import * +from .modularity import * +from .modularity_max_detection import * +from .motif import * +from .localsearch import * \ No newline at end of file diff --git a/easygraph/functions/community/ego_graph.py b/easygraph/functions/community/ego_graph.py new file mode 100644 index 0000000..d7c6a36 --- /dev/null +++ b/easygraph/functions/community/ego_graph.py @@ -0,0 +1,66 @@ +__all__ = ["ego_graph"] + +# import easygraph as eg +from easygraph.functions.path import single_source_dijkstra + + +def ego_graph(G, n, radius=1, center=True, undirected=False, distance=None): + """Returns induced subgraph of neighbors centered at node n within + a given radius. + + Parameters + ---------- + G : graph + A EasyGraph Graph or DiGraph + + n : node + A single node + + radius : number, optional + Include all neighbors of distance<=radius from n. + + center : bool, optional + If False, do not include center node in graph + + undirected : bool, optional + If True use both in- and out-neighbors of directed graphs. + + distance : key, optional + Use specified edge data key as distance. For example, setting + distance='weight' will use the edge weight to measure the + distance from the node n. + + Notes + ----- + For directed graphs D this produces the "out" neighborhood + or successors. If you want the neighborhood of predecessors + first reverse the graph with D.reverse(). If you want both + directions use the keyword argument undirected=True. + + Node, edge, and graph attributes are copied to the returned subgraph. + """ + if undirected: + """ + if distance is not None: + sp, _ = eg.single_source_dijkstra( + G.to_undirected(), n, cutoff=radius, weight=distance + ) + else: + sp = dict( + eg.single_source_shortest_path_length( + G.to_undirected(), n, cutoff=radius + ) + ) + """ + else: + if distance is not None: + sp = single_source_dijkstra(G, n, weight=distance) + else: + sp = single_source_dijkstra(G, n) + nodes = [key for key, value in sp.items() if value <= radius] + nodes = list(nodes) + + H = G.nodes_subgraph(nodes) + if not center: + H.remove_node(n) + return H diff --git a/easygraph/functions/community/localsearch.py b/easygraph/functions/community/localsearch.py new file mode 100644 index 0000000..5391a9d --- /dev/null +++ b/easygraph/functions/community/localsearch.py @@ -0,0 +1,689 @@ +# -*- coding: utf-8 -*- +""" +Created on Tue Dec 21 11:00:36 2021 +Updated on Sun Jun 09 12:33:06 2024 + +Local Search (LS) algorithm proposed in +Dingyi Shi, Fan Shang, Bingsheng Chen, Paul Expert, Linyuan Lv, H. Eugene Stanley, Renaud Lambiotte, Tim S. Evans, Ruiqi Li, +Local dominance unveils clusters in networks, Communications Physics, 2024, 7:170 [PDF: https://rdcu.be/dJxY0] + +"Hidden directionality unifies community detection and cluster analysis" + +@authors: Fan Shang & Tim S. Evans & Ruiqi Li & Dingyi Shi +""" + +import os +import random +import easygraph as eg +import numpy as np +from queue import Queue +from datetime import datetime +# from LS_other_function import plot_combination + +font = {'family': 'Times New Roman', + 'style': 'italic', + 'weight': 'normal', + 'size': 22, + } + +def plot_combination( + x, + y, + text, + x1, + y1, + text1, + center_id, + subplot_location, + xlim_start_end, + ylim_start_end, + font_location, + filepath='./', + dataname='LS_default', + save=False, + show=False): + ''' + input: + x:节点的度值(数据类型:list)k + y:节点的最短路径(数据类型:list)l + x1:节点按照乘积~{k_i} * ~{l_i}的rank排序 (数据类型:list) + y1:~{k_i} * ~{l_i}(数据类型:list) + text:节点的id(数据类型:list) + filepath:需要存储的文件路径(数据类型:str) + center_id: LS算法识别的社团中心节点集合(数据类型:list) + dataname: 当前网络的名称(数据类型:str) + save:是否需要存储文件(数据类型:boolean) + return: + plot + ''' + + try: + import matplotlib.pyplot as plt + from matplotlib.ticker import MultipleLocator + import matplotlib.colors as mc + import colorsys + except ImportError as exc: + raise ImportError("plot_combination requires matplotlib to be installed") from exc + + def adjust_lightness(color, amount=0.5): + try: + c = mc.cnames[color] + except KeyError: + c = color + hls_color = colorsys.rgb_to_hls(*mc.to_rgb(c)) + return colorsys.hls_to_rgb(hls_color[0], max(0, min(1, amount * hls_color[1])), hls_color[2]) + + fig = plt.figure(figsize=(8, 7)) + basecolor = '#FFA900' + edgecolor = adjust_lightness(basecolor, amount=1) + left, bottom, width, height = 0.1, 0.1, 0.8, 0.8 + ax = fig.add_axes([left, bottom, width, height]) + for i in range(len(x)): + # ax.scatter(x[i], y[i], c=basecolor, marker='o', s=200, edgecolor=edgecolor) + if text[i] in center_id: + ax.text(x[i], y[i] + font_location, str(text[i]), ha='center', fontsize=12, fontweight='bold') + ax.scatter(x, y, c=basecolor, marker='o', s=200) + + if np.max(np.array(x)) // 10 < 1: + x_unit = 1 + else: + x_unit = np.max(np.array(x)) // 10 + if np.max(np.array(y)) // 10 < 1: + y_unit = 1 + else: + y_unit = np.max(np.array(y)) // 10 + x_major_locator = MultipleLocator(x_unit) + y_major_locator = MultipleLocator(y_unit) + ax.xaxis.set_major_locator(x_major_locator) + ax.yaxis.set_major_locator(y_major_locator) + ax.set_xlim(xlim_start_end[0], max(x) + xlim_start_end[1]) + ax.set_ylim(ylim_start_end[0], max(y) + ylim_start_end[1]) + ax.set_xlabel(r'$k_i$', font) + ax.set_ylabel(r'$l_i$', font) + ax.tick_params(labelsize=16) + + font1 = {'family': 'Times New Roman', + 'style': 'italic', + 'weight': 'normal', + 'size': 16, + } + basecolor = '#A73489' + edgecolor = adjust_lightness(basecolor, amount=1) + # left, bottom, width, height = 0.25,0.595,0.35,0.3 # darkar + # left, bottom, width, height = 0.18,0.55,0.35,0.3 # Abidjan + # left, bottom, width, height = 0.25,0.55,0.35,0.3 # Beijing + # 添加子图 + left, bottom, width, height = subplot_location[0], subplot_location[1], subplot_location[2], subplot_location[3] + ax1 = fig.add_axes([left, bottom, width, height]) + # 对x1,y1进行log-log处理 + x1_new = np.log(np.array(x1) + 1) + y1_new = [] + y1_min = min(filter(lambda x: x > 0, y1)) + for i in range(len(y1)): + if y1[i] != 0: + y1_new.append(np.log(y1[i])) + else: + y1_new.append(np.log(y1_min / np.e)) + # for i in range(len(x1_new)): + # # if text1[i] in center_id: + # # ax1.scatter(x1_new[i], y1_new[i], color=basecolor, marker='^', s=20, edgecolor=edgecolor) + # # else: + # # ax1.scatter(x1_new[i], y1_new[i], color=basecolor, marker='o', s=2, edgecolor=edgecolor) + # # # ax1.text(x1_new[i], y1_new[i]-0.1, str(int(text1[i])), ha='center', fontsize=10,fontweight='bold') + ax1.scatter(x1_new, y1_new, color=basecolor, marker='o', s=2) + center_x = [] + center_y = [] + for i in range(len(x1_new)): + if text1[i] in center_id: + center_x.append(x1_new[i]) + center_y.append(y1_new[i]) + ax1.scatter(center_x, center_y, color=basecolor, marker='^', s=20) + + ax1.set_xlabel(r'$\ln \, rank$', font1) + ax1.set_ylabel(r'$\ln \, ( \~{k_i} \times \~{l_i} ) $', font1) + ax1.tick_params(labelsize=16) + fig.tight_layout() + if save: + os.makedirs(filepath, exist_ok=True) + filename = os.path.join(filepath, f"{dataname}.pdf") + fig.savefig(filename, bbox_inches='tight', dpi=300) + if show: + plt.show() + else: + plt.close(fig) + return fig + +def max_degree_hierarchy_dag(G, selfloop_nodes=None): + ''' + Create a maximum degree hierarchy DAG from a graph G + + All edges present are from a source node to neighbours which have a larger degree + and the degree of these neigthbours is is larger than or equal to than the degree + of all the neighbours of the source vertex. + + The difference from the full_degree_hierarchy_dag method is that this + does not inlcude links to neighbours which have a higher degree than the source node + but still that neighbouir has a degree which is less than the largest degree + of all the neighbours. + + This subroutine create the DAG in Fig.1b in the maintext of our paper + + Input + ----- + G -- a simple graph of one component + + Return + ------ + D -- A directed acyclic graph + ''' + D = eg.DiGraph() + D.add_nodes_from(G) + for v in G.nodes: + # degree_list = [G.degree(nn) for nn in G.neighbors(v)] + degree_list = [] + for nn in G.neighbors(v): + if nn in selfloop_nodes: + degree_list.append(G.degree()(nn) + 1) + else: + degree_list.append(G.degree()[nn]) + if len(degree_list) > 0: + knnmax = max(degree_list) + # print(G.degree()[v]) + if knnmax >= G.degree()[v]: # can also use np.argmax() here + # has neighbours with the largest degree so add all of the edges to this neighbour + # here edge points from low degree to high degree, points towards tree root + e_list = [(v, nn) for nn in G.neighbors(v) if G.degree()[nn] == knnmax and (not D.has_edge(nn, v))] + D.add_edges_from(e_list) + else: + continue + # print("! With "+str(G.number_of_nodes())+" nodes, from "+str(G.number_of_edges())+" to "+str(D.number_of_edges())+" edges in Maximum Degree DAG") + # print(D.edges()) + return D + + +# def full_degree_hierarchy_dag(G,selfloop_nodes=None): +# ''' +# [DEPRECATED] Create a full degree hierarchy DAG from a simple graph G, +# which is a variant of the algorithm presented in our paper. +# All edges are directed from lower to higher degree nodes, only edges not included are those between equal degree nodes. +# Input: G -- a simple graph of one component +# Return: D -- A directed acyclic graph +# ''' +# D = eg.DiGraph() +# D.add_nodes_from(G) +# for v in G.nodes: +# kv = G.degree(v) +# if v in selfloop_nodes: +# kv+=1 +# e_list = [(v,nn) for nn in G.neighbors(v) if G.degree(nn)>kv] # point to larger degree node +# D.add_edges_from( e_list ) +# # print("! With "+str(G.number_of_nodes())+" nodes, from "+str(G.number_of_edges())+" to "+str(D.number_of_edges())+" edges in Full Degree DAG") +# return D + +def degree_hierarchy_random_tree(G, maximum_tree=True, random_seed=None, selfloop_nodes=None): + ''' + Create a degree hierarchy tree from a graph G. + + Unless seed=None, this uses a certain random number series to break ties + where neighbours have same (maximum) degree and they are + both at the same distance from a root node. + + This subroutine create the DAG comprising all short-dahsed-arrows in Fig.1c in the maintext of our paper + + Input + ----- + G -- an simple graph of one component + maximum_tree=True -- If true uses maximum dgree DAG as input, otherwise uses full degree DAG + random_seed -- an specific integer to determine the random number series + selfloop_nodes -- In the default setting (None), self-loops are not considered; if not None, self-loop will add influence (degree) to the node + + Return + ------ + D, tree_edge_list + + D --- A directed acyclic graph (DAG) + tree_edge_list --- list of edges in terms of node ID used in G of a shortest path tree in G + ''' + if random_seed != None: + random.seed(random_seed) + + if maximum_tree: + D = max_degree_hierarchy_dag(G, selfloop_nodes) + # D is a DAG in Fig. 1b in the main text of our paper + # else: + # D=full_degree_hierarchy_dag(G,selfloop_nodes) + # This is a DEPRECATED variant DAG (not the one we used in our paper) + + node_queue = Queue(maxsize=0) + # start queue for BFS from all the root nodes + # Each entry in queue is tuple (parent_node, node, shortest_distance_to_root) + parent_node = None + shortest_distance_to_root = 0 + for root_node in D: + if D.out_degree()[root_node] == 0: + # print("Adding root node "+str(root_node)) + node_queue.put((None, root_node, shortest_distance_to_root)) + number_of_ties = 0 + # now we have all local leaders in the queue + + while not node_queue.empty(): + parent_node, next_node, shortest_distance_to_root = node_queue.get() + + if "distancetoroot" in D.nodes[next_node]: + if D.nodes[next_node]["distancetoroot"] < shortest_distance_to_root: + continue # already found a quicker way from next_node to a root node + if D.nodes[next_node]["distancetoroot"] == shortest_distance_to_root: + number_of_ties += 1 + if random.random() < 0.5: + continue # a simple way to implement randomness where there is a choice of shortest path roots + + if parent_node == None: # Must be a root node (i.e., a local leader) + D.nodes[next_node]["rootnode"] = next_node + else: + D.nodes[next_node]["rootnode"] = D.nodes[parent_node]["rootnode"] + D.nodes[next_node]["parentnode"] = parent_node + D.nodes[next_node]["distancetoroot"] = shortest_distance_to_root + # print(next_node,parent_node,shortest_distance_to_root) + nn_list = [(next_node, nn, shortest_distance_to_root + 1) for nn in + D.predecessors(next_node)] # get all neighbors of the next_node + for nn in nn_list: + node_queue.put(nn) + tree_edge_list = [] + + for node in D: + parent_node = D.nodes[node]["parentnode"] + if parent_node != None: + tree_edge_list.append((parent_node, node)) + + # print("! In degree_hierarchy_random_tree broke "+str(number_of_ties)+" ties at random") + return D, tree_edge_list + + +# now we break all ties in Fig.1b (e.g., d->c,d->e; l->b,l->m), and tree_edge_list are short-dahsed-arrows in Fig.1c, and add information (rootnode,parentnode,distoroot), which are useful for community label backpropagation, of nodes in the DAG + + +# prelimenary functions for computing normalized ki*li (see Supplementary Information) +def get_indicator_rank(x): + set_x = set(x) + sorted_x = sorted(set_x, reverse=False) + set_x_dict = {} + k = 1 + for i in sorted_x: + if i not in set_x_dict.keys(): + set_x_dict[i] = k + k += 1 + rank_x = [] + for i in x: + rank_x.append(set_x_dict[i]) + return rank_x + + +def get_square(x): + square_x = [] + square_x = [np.power(i, 2) for i in x] + return square_x + + +# min-max normalization +def standard_data(x): + x_max = np.max(x) + x_min = np.min(x) + if x_max - x_min == 0: + trans_data = np.array([1 / len(x) for i in range(len(x))]) + else: + trans_data = (x - x_min) / (x_max - x_min) + return trans_data + + +# When there are multi-scale community structure in the network, we may want to get the first-level partion automatically sometimes. Here, we present a very simple algorithm to determine the number of first-level comunity centers: we calculate the differences between consecutive candicates in the decision graph (see Fig. 1f in our paper), and if the gap below a certain candicate is larger than the mean+std, then this gap might be a notable gap (this works relatively well for real networks we tested in our paper) #在存在多尺度社团(Multi-scale community structure)的情况下,根据y之间的差值自动选择第一层级的聚类中心的个数 +# You can REPLACE this algorithm by a more rigorous and sophisticaed one, if you want to do automatic multi-scale community detection +# Otherwise, in our default setting, we will give the community partion at the finest resolution +def choose_center(multi_sort): + y = multi_sort[:, 1] + delta = [] + for i in range(len(y))[1:]: + delta.append(abs(y[i] - y[i - 1])) + # delta = np.array(delta) # + delta_nozero = [i for i in delta if i != 0] + delta_std = np.std(delta_nozero) + center_num = 0 + for i in range(len(delta)): + if delta[i] > delta_std + np.mean(delta_nozero): + center_num = i + 1 + break + return center_num + + +# Local-BFS (LBFS) from a local leader to determine its superior along hierarchy (or termed as finding hidden directionality of a local leader) +# This LBFS will stop right after enountering another local leader with a higher influence (e.g., influence can be measured by degree or other centrality measurements. This LBFS will not traverse the whole network, thus much less costly than normal BFS +def BFS_from_s(G, s, roots): + ''' + input: + G: graph #图结构 + s: index of the source/start local leader (type:int) #[BFS开始的起始节点(数据类型:int)] + roots: the set of all local leaders (type: list) + return: + w: the index of the superior local leader along the hierarchy; if no such superior, return itself #指向节点的id(数据类型:int),不存在时返回自己 + p: the shortest path from the local leader s to its superior local leader; when no superior, return -1 #最短路经长度(数据类型:int),不存在时返回-1 + ''' + queue = [] + queue.append(s) + seen = set() # visited nodes in BFS #看是否访问过该结点 + seen.add(s) + path_dict = {} # path length to other nodes #记录root到每个节点的距离 + path_dict[s] = 0 + while (len(queue) > 0): + vertex = queue.pop(0) # 保存第一结点,并弹出,方便把他下面的子节点接入 + neighbors = [(neighbor, G.degree()[neighbor]) for neighbor in list(G.adj[vertex]) if + neighbor not in seen] # 子节点的数组 + nodes = [node[0] for node in + sorted(neighbors, key=lambda k: k[1], reverse=True)] # the sorting here is not necessary + # print('nodes',vertex,nodes) + for w in nodes: + if w not in seen: # not uncessary, just to make sure w is not in seen #判断是否访问过,使用一个数组 + path_dict[w] = path_dict[vertex] + 1 + queue.append(w) + seen.add(w) + if w in roots and G.degree()[w] > G.degree()[s]: ### + return w, path_dict[w] + return s, -1 + + +def hierarchical_degree_communities( + G, + center_num=None, + auto_choose_centers=False, + maximum_tree=True, + isdraw=False, + seed=None, + self_loop=False, + plot_filepath="./", + plot_dataname="LS_default", + plot_show=False, +): + ''' + Produces hierarchical degree forest (HDF) of trees and hence communities. + The main part of our Local Search (LS) algorithm + + Input + ----- + G -- simple graph for which communities are required + maximum_tree=True -- If true uses maximum dgree DAG as input, otherwise uses full degree DAG + seed=None -- an integer to use as a seed to break ties at random. Use None to remove random element + self_loop -- If true means the self-loop makes sense + plot_filepath -- directory to save the decision graph when isdraw is True + plot_dataname -- filename (without extension) for the saved decision graph; saved as ".pdf" + plot_show -- whether to display the decision graph window when isdraw is True + + Output + ------ + On screen statistics of communities + + ''' + # Ensure we work on an EasyGraph Graph copy so downstream methods (e.g., remove_edges_from) exist + if not hasattr(G, "remove_edges_from"): + converted = eg.Graph() + try: + converted.add_nodes_from(G.nodes) + except Exception: + converted.add_nodes_from(G.nodes()) + try: + converted.add_edges_from(G.edges) + except Exception: + converted.add_edges_from(G.edges()) + G = converted + else: + G = G.copy() + + # Empty graph + if not G.nodes: + print("Warning: Empty graph detected. Returning empty results.") + D = None + center_dcd = set() + y_dcd = set() + y_partition = [] + grouped_dict = {} + plot_combination_data = None + return D, center_dcd, y_dcd, y_partition, grouped_dict, plot_combination_data + + # Disconnected graph + if not G.edges: + print("Warning: Disconnected graph detected.") + D = None + center_dcd = set(G.nodes.keys()) + y_dcd = set() + y_partition = [] + grouped_dict = G.nodes + plot_combination_data = None + return D, center_dcd, y_dcd, y_partition, grouped_dict, plot_combination_data + + + selfloop_edges = [] + if eg.number_of_selfloops(G) > 0: + selfloop_edges = list(eg.selfloop_edges(G)) + G.remove_edges_from(selfloop_edges) + selfloop_nodes = [] + for item in selfloop_edges: + selfloop_nodes.append(item[0]) + if self_loop == False: + selfloop_nodes = [] + + start_time = datetime.now() + treename = "Hierarchical Maximum Degree Forest" + # treeabv="HMDF" + if not maximum_tree: + treename = "Hierarchical Full Degree Forest" + # treeabv="HFDF" + + # print ("\n===== "+treename+" seed "+str(seed)+" =====") + print("\n====Local Search Algorithm (random seed " + str(seed) + ")==========") + print("Network: " + str(len(G.nodes)) + " nodes," + str(len(G.edges)) + " edges") + D, tree_edge_list = degree_hierarchy_random_tree(G, maximum_tree=maximum_tree, random_seed=seed, + selfloop_nodes=selfloop_nodes) + # D is the DAG comprising short-dahsed-arrows in Fig.1c in the main text of our paper + # print("With "+str(G.number_of_nodes())+" nodes, now left with "+str(len(tree_edge_list))+" edges in tree" ) + + # Now find all the nodes with the same root_node (i.e., local leaders) + root_to_node = {} + for node in D: + if "rootnode" in D.nodes[node]: + root_node = D.nodes[node]["rootnode"] + else: + print("*** ERROR Node " + str(node) + " has no rootnode") + continue + if root_node not in root_to_node: + root_to_node[root_node] = [] + root_to_node[root_node].append(node) + ## + + # determine centers from root_to_node + # (1). using Local-BFS to determine the hidden directionalilty of each local leader (i.e., finding its superior among local leaders along the hierarchy & calculate shortest path lengh between it and its superior l_i #通过local-BFS计算local leader的指向和最短路径 + root_to_node = {key: value for key, value in root_to_node.items() if len(value) > 1} + Potential_Center = list(root_to_node.keys()) + # print("! Number of Communities (root nodes) found "+str(len(root_to_node))) + # print(" Root Nodes: ",Potential_Center) + + root_number = len(root_to_node) + root_decision = {} + avg_l = 0 + # print('Intermediate process of determining the center: ') + for node in root_to_node.keys(): + e, p = BFS_from_s(G, node, Potential_Center) # Local-BFS, e is the superior, p is the path length to it + root_decision[node] = [e, p, G.degree()[node]] + + # For local leaders with the maximal degree in the network and noisy nodes (isolated ones), setting their l_i as the maximum of l_i of all other local leaders [or the diameter of the network #度值最大的节点和噪声节点的最短路径长度设置为所有节点中最短路径长度的最大值 + max_path_temp = max(np.array(list(root_decision.values()))[:, 1]) + # print("max_path_temp == ",max_path_temp," type",type(max_path_temp)) + max_path_temp = int(max_path_temp) + max_path = max_path_temp if max_path_temp > -1 else 2 + for node in root_decision: + if root_decision[node][1] == -1: # maximal local leader(s) + root_decision[node] = [root_decision[node][0], max_path, root_decision[node][2]] + + # (2). calculate normalized influence (here, degree k_i) & path length l_i of all nodes (yields result in Fig. 1f in the main text of our paper) #计算所有节点规一化后的度值ki和最短路径li + node_plot = root_decision.copy() + for n in G.nodes: + if n not in node_plot: + node_plot[n] = [D.nodes[n]['parentnode'], 1, G.degree()[n]] + root_array = np.array(list(node_plot.values())) + # print('degree, path',root_array[:,2],root_array[:,1]) + root_array[root_array[:, 2] <= 1, 1] = 1 # Set l_i=1 for nodes whose degree k_i=1 ### + degree = get_indicator_rank(root_array[:, 2]) + shortest_path = get_square(root_array[:, 1]) + degree_standard = standard_data(np.array(degree)) + shortest_path_standard = standard_data(np.array(shortest_path)) + multi = degree_standard * shortest_path_standard # noralized k_i*l_i + nodeid = list(node_plot.keys()) + multi_dict = {} + for i in range(len(nodeid)): + multi_dict[nodeid[i]] = multi[i] + multi_sort = np.array(sorted(multi_dict.items(), key=lambda kv: (kv[1], kv[0]), reverse=True)) + multi_sort = np.array([[int(i[0]), i[1]] for i in multi_sort]) + multi_x = [i for i in range(len(multi_sort))] + # print('Determine centers by muti:',multi_sort[:40]) + + # choosing the first-level community centers automatically when there is multi-scale communities + if auto_choose_centers == True: + auto_centernum = choose_center(multi_sort) + center_num = auto_centernum if center_num < auto_centernum else center_num + if not center_num: + center_num = len(root_to_node) + center_dcd = [] + local_cnt = 0 + # for i in multi_sort[:,1]: + for i in multi_sort[:center_num]: + if i[1] > 0: + local_cnt += 1 + center_dcd.append(int(i[0])) + print("The number of local leaders: " + str(local_cnt)) + # saving related data for visualization #保存绘图需要的数据 + plot_combination_data = [root_array[:, 2], root_array[:, 1], nodeid, multi_x, multi_sort[:, 1], multi_sort[:, 0], + center_dcd] + plot_process_degree_shortpath_data = [degree, shortest_path, nodeid] + + + # (3). For local leaders, record their superior along the hierarchy in the DAG + for node in root_to_node.keys(): + D.nodes[node]["parentnode"] = root_decision[node][0] + D.nodes[node]["rootnode"] = D.nodes[node]["parentnode"] + for node in D.nodes: + recent_node = [] # prevent loop + recent_node.append(node) + flag = 0 + if node in center_dcd: + D.nodes[node]["rootnode"] = node + else: + while D.nodes[node]["rootnode"] not in center_dcd and flag == 0: + j = D.nodes[node]["rootnode"] + if j not in recent_node and j != None: + recent_node.append(j) + D.nodes[node]["rootnode"] = D.nodes[j]["rootnode"] + else: + D.nodes[node]["rootnode"] = None + flag = 1 + + # (4). get the classes and partition + y_dcd = [] + y_partition = {} + for node in D.nodes: + if D.nodes[node]["rootnode"] == None: + y_dcd.append(-1) + y_partition[node] = -1 + else: + y_dcd.append(D.nodes[node]["rootnode"]) + y_partition[node] = D.nodes[node]["rootnode"] + + end_time = datetime.now() + stamp = (end_time - start_time).total_seconds() * 1000 + print('Running Time: %d ms' % stamp) + + # print('The number of community centers: '+str(center_dcd)) + print('The number of community centers: ' + str(len(plot_combination_data[6]))) + print('The id of the centers are: ' + str(plot_combination_data[6])) + # print('Modularity of the partition by LS: '+str(community.modularity(y_partition, G))) + + print("The decision graph for determining the number of centers, " + + "where centers are nodes with both a large influence k_i and path length l_i to other local leaders with a higher influence.") + + from collections import defaultdict + + grouped_dict = defaultdict(list) + for key, value in y_partition.items(): + grouped_dict[value].append(key) + + # Print partition summary + if grouped_dict: + print("Communities (center: members):") + for center, members in grouped_dict.items(): + print(f" {center}: {sorted(members)}") + + # just for better visualization, can be safely modified + if isdraw == True: + subplot_location = [0.25, 0.55, 0.35, 0.3] + xlim_start_end = [0.3, 0.7] + ylim_start_end = [0.7, 0.3] + font_location = -0.04 + plot_combination(plot_combination_data[0], plot_combination_data[1], plot_combination_data[2], + plot_combination_data[3], plot_combination_data[4], plot_combination_data[5], + plot_combination_data[6], subplot_location, xlim_start_end, ylim_start_end, font_location, + filepath=plot_filepath, dataname=plot_dataname, save=True, show=plot_show) + + print( + "Note: If multi-scale community structure, which can be common in real networks, is of interest, the number of communities at different level can be explicitly set by some sophisticaed methods or simply by visual inspection for notable gaps in the decision graph. In the default setting, LS alorithm returns community partition at the finest level.") + return D, center_dcd, y_dcd, y_partition, grouped_dict, plot_combination_data + + +def LS_degree_communities( + G, + center_num=None, + auto_choose_centers=False, + maximum_tree=True, + isdraw=True, + seed=None, + self_loop=False, + plot_filepath="./", + plot_dataname="LS_default", + plot_show=False, +): + """Alias for hierarchical_degree_communities with the same parameters.""" + return hierarchical_degree_communities( + G, + center_num=center_num, + auto_choose_centers=auto_choose_centers, + maximum_tree=maximum_tree, + isdraw=isdraw, + seed=seed, + self_loop=self_loop, + plot_filepath=plot_filepath, + plot_dataname=plot_dataname, + ) + + +# if __name__ == '__main__': +# print("### Simple (extreme) example of network where this method does not produce a unique community ###") +# G=eg.Graph() +# #G.add_edges_from(EdgeList) +# # # load the network data +# seed = 163 +# G.add_edges_from([ (0,2), (0,3), (0,4), (0,5), (1,2), (1,3), (1,4), (1,5) ]) #here is a simple example +# # G.add_edges_from([(0, 1), (2, 3), (4, 5)]) +# print(G.nodes) +# print(type(G.nodes)) +# # If you want to use your own dataset, use to read and set label = "id" e.g. eg.read_gml("your dataset",label="id") +# # G=eg.read_gml("net_SBM_compact_nb_groups_100_block_size_5_p_in_0.8_k_out_8_i_0.gml",label="id") + + +# D, center_dcd, y_dcd, y_partition, grouped_dict, plot_combination_data = hierarchical_degree_communities(G, maximum_tree=True, seed=seed) +# print("Key represents the community center, and Value represents the nodes within the community.") +# print(grouped_dict) +# # hierarchical_degree_communities(G, maximum_tree=False, seed=seed) +# # print('If there is multi-scale community structure, you can type the number of communities:') +# # nc = int(input()) +# # hierarchical_degree_communities(G, maximum_tree = True, isdraw = False, seed=seed, center_num=nc) +# # print("Key represents the community center, and Value represents the nodes within the community.") +# # print(grouped_dict) + +# # # Other examples +# # print("\n\n ### Karate Club Network ###") +# # G=eg.karate_club_graph() +# # hierarchical_degree_communities(G, maximum_tree=True, seed=seed) diff --git a/easygraph/functions/community/louvain.py b/easygraph/functions/community/louvain.py new file mode 100644 index 0000000..b8f6500 --- /dev/null +++ b/easygraph/functions/community/louvain.py @@ -0,0 +1,355 @@ +from collections import defaultdict +from collections import deque + +import easygraph as eg + +from easygraph.functions.community.modularity import * + + +__all__ = ["louvain_communities", "louvain_partitions"] + + +def louvain_communities(G, weight="weight", threshold=0.00002): + r"""Find the best partition of a graph using the Louvain Community Detection + Algorithm. + + Louvain Community Detection Algorithm is a simple method to extract the community + structure of a network. This is a heuristic method based on modularity optimization. [1]_ + + The algorithm works in 2 steps. On the first step it assigns every node to be + in its own community and then for each node it tries to find the maximum positive + modularity gain by moving each node to all of its neighbor communities. If no positive + gain is achieved the node remains in its original community. + + The modularity gain obtained by moving an isolated node $i$ into a community $C$ can + easily be calculated by the following formula (combining [1]_ [2]_ and some algebra): + + .. math:: + \Delta Q = \frac{k_{i,in}}{2m} - \gamma\frac{ \Sigma_{tot} \cdot k_i}{2m^2} + + where $m$ is the size of the graph, $k_{i,in}$ is the sum of the weights of the links + from $i$ to nodes in $C$, $k_i$ is the sum of the weights of the links incident to node $i$, + $\Sigma_{tot}$ is the sum of the weights of the links incident to nodes in $C$ and $\gamma$ + is the resolution parameter. + + For the directed case the modularity gain can be computed using this formula according to [3]_ + + .. math:: + \Delta Q = \frac{k_{i,in}}{m} + - \gamma\frac{k_i^{out} \cdot\Sigma_{tot}^{in} + k_i^{in} \cdot \Sigma_{tot}^{out}}{m^2} + + where $k_i^{out}$, $k_i^{in}$ are the outer and inner weighted degrees of node $i$ and + $\Sigma_{tot}^{in}$, $\Sigma_{tot}^{out}$ are the sum of in-going and out-going links incident + to nodes in $C$. + + The first phase continues until no individual move can improve the modularity. + + The second phase consists in building a new network whose nodes are now the communities + found in the first phase. To do so, the weights of the links between the new nodes are given by + the sum of the weight of the links between nodes in the corresponding two communities. Once this + phase is complete it is possible to reapply the first phase creating bigger communities with + increased modularity. + + The above two phases are executed until no modularity gain is achieved (or is less than + the `threshold`). + + Parameters + ---------- + threshold + G : easygraph + weight : string or None, optional (default="weight") + The name of an edge attribute that holds the numerical value + used as a weight. If None then each edge has weight 1. + + Returns + ------- + list + A list of sets (partition of `G`). Each set represents one community and contains + all the nodes that constitute it. + + Notes + ----- + The order in which the nodes are considered can affect the final output. In the algorithm + the ordering happens using a random shuffle. + + References + ---------- + .. [1] Blondel, V.D. et al. Fast unfolding of communities in + large networks. J. Stat. Mech 10008, 1-12(2008). https://doi.org/10.1088/1742-5468/2008/10/P10008 + .. [2] Traag, V.A., Waltman, L. & van Eck, N.J. From Louvain to Leiden: guaranteeing + well-connected communities. Sci Rep 9, 5233 (2019). https://doi.org/10.1038/s41598-019-41695-z + .. [3] Nicolas Dugu��, Anthony Perez. Directed Louvain : maximizing modularity in directed networks. + [Research Report] Universit�� d��Orl��ans. 2015. hal-01231784. https://hal.archives-ouvertes.fr/hal-01231784 + + See Also + -------- + louvain_partitions + """ + if len(G) == 0 or G.size(weight=weight) == 0: + return [{n} for n in G.nodes] + d = louvain_partitions(G, weight, threshold) + q = deque(d, maxlen=1) + # q.append(d) + return q.pop() + + +def louvain_partitions(G, weight="weight", threshold=0.0000001): + """Yields partitions for each level of the Louvain Community Detection Algorithm + + Louvain Community Detection Algorithm is a simple method to extract the community + structure of a network. This is a heuristic method based on modularity optimization. [1]_ + + The partitions at each level (step of the algorithm) form a dendogram of communities. + A dendrogram is a diagram representing a tree and each level represents + a partition of the G graph. The top level contains the smallest communities + and as you traverse to the bottom of the tree the communities get bigger + and the overall modularity increases making the partition better. + + Each level is generated by executing the two phases of the Louvain Community + Detection Algorithm. + + Parameters + ---------- + threshold + G : easygraph + weight : string or None, optional (default="weight") + The name of an edge attribute that holds the numerical value + used as a weight. If None then each edge has weight 1. + + Yields + ------ + list + A list of sets (partition of `G`). Each set represents one community and contains + all the nodes that constitute it. + + References + ---------- + .. [1] Blondel, V.D. et al. Fast unfolding of communities in + large networks. J. Stat. Mech 10008, 1-12(2008) + + See Also + -------- + louvain_communities + """ + if len(G) == 0 or G.size(weight=weight) == 0: + yield [{n} for n in G.nodes] + return + partition = [{u} for u in G.nodes] + mod = modularity(G, partition) + is_directed = G.is_directed() + if G.is_multigraph(): + G = _convert_multigraph(G, weight, is_directed) + else: + graph = G.__class__() + graph.add_nodes_from(G) + graph.add_edges_from(G.edges, weight=1) + G = graph + + m = G.size(weight="weight") + partition, inner_partition, improvement = _one_level(G, m, partition, is_directed) + improvement = True + while improvement: + # gh-5901 protect the sets in the yielded list from further manipulation here + + yield [s.copy() for s in partition] + new_mod = modularity(G, inner_partition, weight="weight") + if new_mod - mod <= threshold: + return + mod = new_mod + """ + for node1, node2, wt in G.edges: + print(node1,node2,wt) + print("\n") + """ + G = _gen_graph(G, inner_partition) + """ + for node1, node2, wt in G.edges: + print(node1,node2,wt) + """ + partition, inner_partition, improvement = _one_level( + G, m, partition, is_directed, 1 + ) + + +def _one_level(G, m, partition, resolution=1, is_directed=False, seed=None, tes=0): + """Calculate one level of the Louvain partitions tree + + Parameters + ---------- + G : EasyGraph Graph/DiGraph + The graph from which to detect communities + m : number + The size of the graph `G`. + partition : list of sets of nodes + A valid partition of the graph `G` + resolution : positive number + The resolution parameter for computing the modularity of a partition + is_directed : bool + True if `G` is a directed graph. + seed : integer, random_state, or None (default) + Indicator of random number generation state. + See :ref:`Randomness`. + + """ + node2com = {u: i for i, u in enumerate(G.nodes)} + inner_partition = [{u} for u in G.nodes] + """ + if is_directed: + in_degrees = dict(G.in_degree(weight="weight")) + out_degrees = dict(G.out_degree(weight="weight")) + Stot_in = list(in_degrees.values()) + Stot_out = list(out_degrees.values()) + # Calculate weights for both in and out neighbours + nbrs = {} + for u in G: + nbrs[u] = defaultdict(float) + for _, n, wt in G.out_edges(u, data="weight"): + nbrs[u][n] += wt + for n, _, wt in G.in_edges(u, data="weight"): + nbrs[u][n] += wt + pass + else: + """ + degrees = dict(G.degree(weight="weight")) + Stot = [] + for i in G: + Stot.append(len(G[i])) + + # for c in Stot: + # print(c) + + nbrs = {u: {v: data["weight"] for v, data in G[u].items() if v != u} for u in G} + rand_nodes = list(G.nodes) + # seed.shuffle(rand_nodes) + nb_moves = 1 + improvement = False + while nb_moves > 0: + # print(nb_moves) + + nb_moves = 0 + for u in rand_nodes: + best_mod = 0 + best_com = node2com[u] + weights2com = _neighbor_weights(nbrs[u], node2com) + """ + if is_directed: + in_degree = in_degrees[u] + out_degree = out_degrees[u] + Stot_in[best_com] -= in_degree + Stot_out[best_com] -= out_degree + remove_cost = ( + -weights2com[best_com] / m + + (out_degree * Stot_in[best_com] + in_degree * Stot_out[best_com]) + / m**2 + ) + else: + """ + degree = degrees[u] + Stot[best_com] -= degree + remove_cost = -weights2com[best_com] / m + (Stot[best_com] * degree) / ( + 2 * m**2 + ) + for nbr_com, wt in weights2com.items(): + """ + if is_directed: + gain = ( + remove_cost + + wt / m + - ( + out_degree * Stot_in[nbr_com] + + in_degree * Stot_out[nbr_com] + ) + / m**2 + ) + else: + """ + gain = remove_cost + wt / m - (Stot[nbr_com] * degree) / (2 * m**2) + if gain > best_mod: + best_mod = gain + best_com = nbr_com + """ + if is_directed: + Stot_in[best_com] += in_degree + Stot_out[best_com] += out_degree + else: + """ + Stot[best_com] += degree + + if best_com != node2com[u]: + com = G.nodes[u].get("nodes", {u}) + partition[node2com[u]].difference_update(com) + inner_partition[node2com[u]].remove(u) + partition[best_com].update(com) + inner_partition[best_com].add(u) + improvement = True + nb_moves += 1 + node2com[u] = best_com + partition = list(filter(len, partition)) + inner_partition = list(filter(len, inner_partition)) + + # for c in partition: + # print(c) + + return partition, inner_partition, improvement + + +def _neighbor_weights(nbrs, node2com): + """Calculate weights between node and its neighbor communities. + + Parameters + ---------- + nbrs : dictionary + Dictionary with nodes' neighbours as keys and their edge weight as value. + node2com : dictionary + Dictionary with all graph's nodes as keys and their community index as value. + + """ + weights = defaultdict(float) + for nbr, wt in nbrs.items(): + weights[node2com[nbr]] += wt + return weights + + +def _gen_graph(G, partition): + """Generate a new graph based on the partitions of a given graph""" + H = G.__class__() + node2com = {} + for i, part in enumerate(partition): + nodes = set() + for node in part: + node2com[node] = i + nodes.update(G.nodes[node].get("nodes", {node})) + H.add_node(i, nodes=nodes) + + for node1, node2, wt in G.edges: + com1 = node2com[node1] + com2 = node2com[node2] + wt = wt["weight"] + try: + temp = H[com1][com2]["weight"] + except KeyError: + temp = 0 + H.add_edge(com1, com2, weight=wt + temp) + """ + if wt: + wt = wt["weight"] + H.add_edge(com1, com2, weight=wt) + else: + H.add_edge(com1, com2, weight=1) + """ + return H + + +def _convert_multigraph(G, weight, is_directed): + """Convert a Multigraph to normal Graph""" + if is_directed: + H = eg.DiGraph() + else: + H = eg.Graph() + H.add_nodes_from(G) + for u, v, wt in G.edges(data=weight, default=1): + if H.has_edge(u, v): + H[u][v]["weight"] += wt + else: + H.add_edge(u, v, weight=wt) + return H diff --git a/easygraph/functions/community/modularity.py b/easygraph/functions/community/modularity.py new file mode 100644 index 0000000..aac3dfd --- /dev/null +++ b/easygraph/functions/community/modularity.py @@ -0,0 +1,75 @@ +from itertools import product + +from easygraph.utils import * + + +__all__ = ["modularity"] + + +@not_implemented_for("multigraph") +def modularity(G, communities, weight="weight"): + r""" + Returns the modularity of the given partition of the graph. + Modularity is defined in [1]_ as + + .. math:: + + Q = \frac{1}{2m} \sum_{ij} \left( A_{ij} - \frac{k_ik_j}{2m}\right) + \delta(c_i,c_j) + + where m is the number of edges, A is the adjacency matrix of + `G`, + + .. math:: + + k_i\ is\ the\ degree\ of\ i\ and\ \delta(c_i, c_j)\ is\ 1\ if\ i\ and\ j\ are\ in\ the\ same\ community\ and\ 0\ otherwise. + + Parameters + ---------- + G : easygraph.Graph or easygraph.DiGraph + + communities : list or iterable of set of nodes + These node sets must represent a partition of G's nodes. + + weight : string, optional (default : 'weight') + The key for edge weight. + + Returns + ---------- + Q : float + The modularity of the partition. + + References + ---------- + .. [1] M. E. J. Newman *Networks: An Introduction*, page 224. + Oxford University Press, 2011. + + """ + # TODO: multigraph not included. + + if not isinstance(communities, list): + communities = list(communities) + + directed = G.is_directed() + m = G.size(weight=weight) + if directed: + out_degree = dict(G.out_degree(weight=weight)) + in_degree = dict(G.in_degree(weight=weight)) + norm = 1 / m + else: + out_degree = dict(G.degree(weight=weight)) + in_degree = out_degree + norm = 1 / (2 * m) + + def val(u, v): + try: + w = G[u][v].get(weight, 1) + except KeyError: + w = 0 + # Double count self-loops if the graph is undirected. + if u == v and not directed: + w *= 2 + return w - in_degree[u] * out_degree[v] * norm + + Q = sum(val(u, v) for c in communities for u, v in product(c, repeat=2)) + return Q * norm diff --git a/easygraph/functions/community/modularity_max_detection.py b/easygraph/functions/community/modularity_max_detection.py new file mode 100644 index 0000000..75f37cf --- /dev/null +++ b/easygraph/functions/community/modularity_max_detection.py @@ -0,0 +1,200 @@ +from easygraph.functions.community.modularity import modularity +from easygraph.utils import * +from easygraph.utils.mapped_queue import MappedQueue + + +__all__ = ["greedy_modularity_communities"] + + +@not_implemented_for("multigraph") +def greedy_modularity_communities(G, weight="weight"): + """Communities detection via greedy modularity method. + + Find communities in graph using Clauset-Newman-Moore greedy modularity + maximization. This method currently supports the Graph class. + + Greedy modularity maximization begins with each node in its own community + and joins the pair of communities that most increases modularity until no + such pair exists. + + Parameters + ---------- + G : easygraph.Graph or easygraph.DiGraph + + weight : string (default : 'weight') + The key for edge weight. For undirected graph, it will regard each edge + weight as 1. + + Returns + ---------- + Yields sets of nodes, one for each community. + + References + ---------- + .. [1] Newman, M. E. J. "Networks: An Introduction Oxford Univ." (2010). + .. [2] Clauset, Aaron, Mark EJ Newman, and Cristopher Moore. + "Finding community structure in very large networks." Physical review E 70.6 (2004): 066111. + """ + + # Count nodes and edges + + N = len(G.nodes) + m = sum(d.get(weight, 1) for u, v, d in G.edges) + if N == 0 or m == 0: + print("Please input the graph which has at least one edge!") + exit() + q0 = 1.0 / (2.0 * m) + + # Map node labels to contiguous integers + label_for_node = {i: v for i, v in enumerate(G.nodes)} + node_for_label = {label_for_node[i]: i for i in range(N)} + + # Calculate degrees + k_for_label = G.degree(weight=weight) + k = [k_for_label[label_for_node[i]] for i in range(N)] + + # Initialize community and merge lists + communities = {i: frozenset([i]) for i in range(N)} + merges = [] + + # Initial modularity + partition = [[label_for_node[x] for x in c] for c in communities.values()] + q_cnm = modularity(G, partition) + + # Initialize data structures + # CNM Eq 8-9 (Eq 8 was missing a factor of 2 (from A_ij + A_ji) + # a[i]: fraction of edges within community i + # dq_dict[i][j]: dQ for merging community i, j + # dq_heap[i][n] : (-dq, i, j) for communitiy i nth largest dQ + # H[n]: (-dq, i, j) for community with nth largest max_j(dQ_ij) + a = [k[i] * q0 for i in range(N)] + dq_dict = { + i: { + node_for_label[u]: 2 * q0 * d.get(weight, 1) - 2 * k[i] * k[node_for_label[u]] * q0 * q0 + for u, d in G.adj[label_for_node[i]].items() + if node_for_label[u] != i + } + for i in range(N) + } + dq_heap = [ + MappedQueue([(-dq, i, j) for j, dq in dq_dict[i].items()]) for i in range(N) + ] + H = MappedQueue([dq_heap[i].h[0] for i in range(N) if len(dq_heap[i]) > 0]) + + # Merge communities until we can't improve modularity + while len(H) > 1: + # Find best merge + # Remove from heap of row maxes + # Ties will be broken by choosing the pair with lowest min community id + try: + dq, i, j = H.pop() + except IndexError: + break + dq = -dq + # Remove best merge from row i heap + dq_heap[i].pop() + # Push new row max onto H + if len(dq_heap[i]) > 0: + H.push(dq_heap[i].h[0]) + # If this element was also at the root of row j, we need to remove the + # duplicate entry from H + if dq_heap[j].h[0] == (-dq, j, i): + H.remove((-dq, j, i)) + # Remove best merge from row j heap + dq_heap[j].remove((-dq, j, i)) + # Push new row max onto H + if len(dq_heap[j]) > 0: + H.push(dq_heap[j].h[0]) + else: + # Duplicate wasn't in H, just remove from row j heap + dq_heap[j].remove((-dq, j, i)) + # Stop when change is non-positive + if dq <= 0: + break + + # Perform merge + communities[j] = frozenset(communities[i] | communities[j]) + del communities[i] + merges.append((i, j, dq)) + # New modularity + q_cnm += dq + # Get list of communities connected to merged communities + i_set = set(dq_dict[i].keys()) + j_set = set(dq_dict[j].keys()) + all_set = (i_set | j_set) - {i, j} + both_set = i_set & j_set + # Merge i into j and update dQ + for k in all_set: + # Calculate new dq value + if k in both_set: + dq_jk = dq_dict[j][k] + dq_dict[i][k] + elif k in j_set: + dq_jk = dq_dict[j][k] - 2.0 * a[i] * a[k] + else: + # k in i_set + dq_jk = dq_dict[i][k] - 2.0 * a[j] * a[k] + # Update rows j and k + for row, col in [(j, k), (k, j)]: + # Save old value for finding heap index + if k in j_set: + d_old = (-dq_dict[row][col], row, col) + else: + d_old = None + # Update dict for j,k only (i is removed below) + dq_dict[row][col] = dq_jk + # Save old max of per-row heap + if len(dq_heap[row]) > 0: + d_oldmax = dq_heap[row].h[0] + else: + d_oldmax = None + # Add/update heaps + d = (-dq_jk, row, col) + if d_old is None: + # We're creating a new nonzero element, add to heap + dq_heap[row].push(d) + else: + # Update existing element in per-row heap + dq_heap[row].update(d_old, d) + # Update heap of row maxes if necessary + if d_oldmax is None: + # No entries previously in this row, push new max + H.push(d) + else: + # We've updated an entry in this row, has the max changed? + if dq_heap[row].h[0] != d_oldmax: + H.update(d_oldmax, dq_heap[row].h[0]) + + # Remove row/col i from matrix + i_neighbors = dq_dict[i].keys() + for k in i_neighbors: + # Remove from dict + dq_old = dq_dict[k][i] + del dq_dict[k][i] + # Remove from heaps if we haven't already + if k != j: + # Remove both row and column + for row, col in [(k, i), (i, k)]: + # Check if replaced dq is row max + d_old = (-dq_old, row, col) + if dq_heap[row].h[0] == d_old: + # Update per-row heap and heap of row maxes + dq_heap[row].remove(d_old) + H.remove(d_old) + # Update row max + if len(dq_heap[row]) > 0: + H.push(dq_heap[row].h[0]) + else: + # Only update per-row heap + dq_heap[row].remove(d_old) + + del dq_dict[i] + # Mark row i as deleted, but keep placeholder + dq_heap[i] = MappedQueue() + # Merge i into j and update a + a[j] += a[i] + a[i] = 0 + + communities = [ + frozenset(label_for_node[i] for i in c) for c in communities.values() + ] + return sorted(communities, key=len, reverse=True) diff --git a/easygraph/functions/community/motif.py b/easygraph/functions/community/motif.py new file mode 100644 index 0000000..fde69cf --- /dev/null +++ b/easygraph/functions/community/motif.py @@ -0,0 +1,122 @@ +import random + +import easygraph as eg + +from easygraph.utils import * + + +__all__ = ["enumerate_subgraph", "random_enumerate_subgraph"] + + +@not_implemented_for("multigraph") +def enumerate_subgraph(G, k: int): + """ + Returns the motifs. + Motifs are small weakly connected induced subgraphs of a given structure in a graph. + + Parameters + ---------- + G : easygraph.Graph or easygraph.DiGraph. + + k : int + The size of the motifs to search for. + + Returns + ---------- + k_subgraphs : list + The motifs. + + References + ---------- + .. [1] Wernicke, Sebastian. "Efficient detection of network motifs." + IEEE/ACM transactions on computational biology and bioinformatics 3.4 (2006): 347-359. + + """ + k_subgraphs = [] + for v, _ in G.nodes.items(): + Vextension = {u for u in G.adj[v] if u > v} + extend_subgraph(G, {v}, Vextension, v, k, k_subgraphs) + return k_subgraphs + + +def extend_subgraph( + G, Vsubgraph: set, Vextension: set, v: int, k: int, k_subgraphs: list +): + if len(Vsubgraph) == k: + k_subgraphs.append(Vsubgraph) + return + while len(Vextension) > 0: + w = random.choice(tuple(Vextension)) + Vextension.remove(w) + NexclwVsubgraph = exclusive_neighborhood(G, w, Vsubgraph) + VpExtension = Vextension | {u for u in NexclwVsubgraph if u > v} + extend_subgraph(G, Vsubgraph | {w}, VpExtension, v, k, k_subgraphs) + + +def exclusive_neighborhood(G, v: int, vp: set): + Nv = set(G.adj[v]) + NVp = {u for n in vp for u in G.adj[n]} | vp + return Nv - NVp + + +@not_implemented_for("multigraph") +def random_enumerate_subgraph(G, k: int, cut_prob: list): + """ + Returns the motifs. + Motifs are small weakly connected induced subgraphs of a given structure in a graph. + + Parameters + ---------- + G : easygraph.Graph or easygraph.DiGraph. + + k : int + The size of the motifs to search for. + + cut_prob : list + list of probabilities for cutting the search tree at a given level. + + Returns + ---------- + k_subgraphs : list + The motifs. + + References + ---------- + .. [1] Wernicke, Sebastian. "A faster algorithm for detecting network motifs." + International Workshop on Algorithms in Bioinformatics. Springer, Berlin, Heidelberg, 2005. + + """ + if len(cut_prob) != k: + raise eg.EasyGraphError("length of cut_prob invalid, should equal to k") + + k_subgraphs = [] + for v, _ in G.nodes.items(): + if random.random() > cut_prob[0]: + continue + Vextension = {u for u in G.adj[v] if u > v} + random_extend_subgraph(G, {v}, Vextension, v, k, k_subgraphs, cut_prob) + return k_subgraphs + + +def random_extend_subgraph( + G, + Vsubgraph: set, + Vextension: set, + v: int, + k: int, + k_subgraphs: list, + cut_prob: list, +): + if len(Vsubgraph) == k: + k_subgraphs.append(Vsubgraph) + return + while len(Vextension) > 0: + w = random.choice(tuple(Vextension)) + Vextension.remove(w) + NexclwVsubgraph = exclusive_neighborhood(G, w, Vsubgraph) + VpExtension = Vextension | {u for u in NexclwVsubgraph if u > v} + if random.random() > cut_prob[len(Vsubgraph)]: + continue + random_extend_subgraph( + G, Vsubgraph | {w}, VpExtension, v, k, k_subgraphs, cut_prob + ) diff --git a/easygraph/functions/community/tests/__init__.py b/easygraph/functions/community/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easygraph/functions/community/tests/test_LPA.py b/easygraph/functions/community/tests/test_LPA.py new file mode 100644 index 0000000..34c96fb --- /dev/null +++ b/easygraph/functions/community/tests/test_LPA.py @@ -0,0 +1,65 @@ +import unittest + +import easygraph as eg + + +class TestLabelPropagationAlgorithms(unittest.TestCase): + def setUp(self): + self.graph_simple = eg.Graph() + self.graph_simple.add_edges_from([(0, 1), (1, 2), (3, 4)]) + + self.graph_weighted = eg.Graph() + self.graph_weighted.add_edges_from( + [ + (0, 1, {"weight": 3}), + (1, 2, {"weight": 2}), + (2, 0, {"weight": 4}), + (3, 4, {"weight": 1}), + ] + ) + + self.graph_disconnected = eg.Graph() + self.graph_disconnected.add_edges_from([(0, 1), (2, 3), (4, 5)]) + + self.graph_single_node = eg.Graph() + self.graph_single_node.add_node(42) + + self.graph_empty = eg.Graph() + + def test_lpa(self): + self.assertEqual(eg.functions.community.LPA(self.graph_single_node), {1: [42]}) + self.assertTrue(eg.functions.community.LPA(self.graph_simple)) + self.assertTrue(eg.functions.community.LPA(self.graph_weighted)) + self.assertTrue(eg.functions.community.LPA(self.graph_disconnected)) + + def test_slpa(self): + self.assertEqual( + eg.functions.community.SLPA(self.graph_single_node, T=5, r=0.01), {1: [42]} + ) + self.assertTrue(eg.functions.community.SLPA(self.graph_simple, T=10, r=0.1)) + self.assertTrue( + eg.functions.community.SLPA(self.graph_disconnected, T=15, r=0.1) + ) + + def test_hanp(self): + self.assertEqual( + eg.functions.community.HANP(self.graph_single_node, m=0.1, delta=0.05), + {1: [42]}, + ) + self.assertTrue( + eg.functions.community.HANP(self.graph_simple, m=0.3, delta=0.1) + ) + self.assertTrue( + eg.functions.community.HANP(self.graph_weighted, m=0.5, delta=0.2) + ) + + def test_bmlpa(self): + self.assertEqual( + eg.functions.community.BMLPA(self.graph_single_node, p=0.1), {1: [42]} + ) + self.assertTrue(eg.functions.community.BMLPA(self.graph_simple, p=0.3)) + self.assertTrue(eg.functions.community.BMLPA(self.graph_weighted, p=0.2)) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/community/tests/test_LS.py b/easygraph/functions/community/tests/test_LS.py new file mode 100644 index 0000000..9a10f83 --- /dev/null +++ b/easygraph/functions/community/tests/test_LS.py @@ -0,0 +1,48 @@ +import unittest + +import easygraph as eg + +from easygraph import LS_degree_communities + +class TestLSDetection(unittest.TestCase): + def setUp(self): + self.graph_simple = eg.Graph() + self.graph_simple.add_edges_from([(0,2), (0,3), (0,4), (0,5), (1,2), (1,3), (1,4), (1,5)]) + + self.graph_disconnected = eg.Graph() + self.graph_disconnected.add_edges_from([(0, 1), (2, 3), (4, 5)]) + + self.graph_single_node = eg.Graph() + self.graph_single_node.add_node(42) + + self.graph_empty = eg.Graph() + + def test_LS_simple(self): + _, _, _, _, communities, _ = LS_degree_communities(self.graph_simple, maximum_tree=True, isdraw = False, seed=163) + flat = set().union(*communities.values()) + self.assertSetEqual(flat, set(self.graph_simple.nodes)) + + def test_LS_disconnected(self): + _, _, _, _, communities, _ = LS_degree_communities(self.graph_disconnected, maximum_tree=True, isdraw = False, seed=163) + flat =set().union(*communities.values()) + self.assertSetEqual(flat, set(self.graph_disconnected.nodes)) + + def test_LS_single_node(self): + _, _, _, _, communities, _ = LS_degree_communities(self.graph_single_node, maximum_tree=True, isdraw = False, seed=163) + flat = set().union(communities.keys()) + self.assertEqual(len(flat), 1) + self.assertSetEqual(flat, {42}) + + def test_LS_empty_graph(self): + _, _, _, _, communities, _ = LS_degree_communities(self.graph_empty, maximum_tree=True, isdraw = False, seed=163) + self.assertEqual(communities, {}) + + def test_LS_partitions_progressive_size(self): + _, _, _, _, communities, _ = LS_degree_communities(self.graph_simple, maximum_tree=True, isdraw = False, seed=163) + total_nodes = sum(len(members) for center, members in communities.items()) + self.assertEqual(total_nodes, len(self.graph_simple.nodes)) + flat = [node for _,members in communities.items() for node in members] + self.assertEqual(len(flat), len(set(flat))) + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/community/tests/test_ego_graph.py b/easygraph/functions/community/tests/test_ego_graph.py new file mode 100644 index 0000000..e0cdbb7 --- /dev/null +++ b/easygraph/functions/community/tests/test_ego_graph.py @@ -0,0 +1,65 @@ +import unittest + +import easygraph as eg + + +class TestEgoGraph(unittest.TestCase): + def setUp(self): + self.simple_graph = eg.Graph() + self.simple_graph.add_edges_from([(0, 1), (1, 2), (2, 3), (3, 4)]) + + self.directed_graph = eg.DiGraph() + self.directed_graph.add_edges_from([(0, 1), (1, 2), (2, 3)]) + + self.weighted_graph = eg.Graph() + self.weighted_graph.add_edges_from( + [(0, 1, {"weight": 1}), (1, 2, {"weight": 2}), (2, 3, {"weight": 3})] + ) + + self.disconnected_graph = eg.Graph() + self.disconnected_graph.add_edges_from([(0, 1), (2, 3)]) + + self.single_node_graph = eg.Graph() + self.single_node_graph.add_node(42) + + def test_simple_graph_radius_1(self): + ego = eg.functions.community.ego_graph(self.simple_graph, 2, radius=1) + self.assertSetEqual(set(ego.nodes), {1, 2, 3}) + + def test_simple_graph_radius_2(self): + ego = eg.functions.community.ego_graph(self.simple_graph, 2, radius=2) + self.assertSetEqual(set(ego.nodes), {0, 1, 2, 3, 4}) + + def test_directed_graph(self): + ego = eg.functions.community.ego_graph(self.directed_graph, 1, radius=1) + self.assertSetEqual(set(ego.nodes), {1, 2}) + + def test_weighted_graph_with_distance(self): + ego = eg.functions.community.ego_graph( + self.weighted_graph, 0, radius=2, distance="weight" + ) + self.assertSetEqual(set(ego.nodes), {0, 1}) + + def test_disconnected_graph(self): + ego = eg.functions.community.ego_graph(self.disconnected_graph, 0, radius=1) + self.assertSetEqual(set(ego.nodes), {0, 1}) + + def test_single_node_graph(self): + ego = eg.functions.community.ego_graph(self.single_node_graph, 42, radius=1) + self.assertSetEqual(set(ego.nodes), {42}) + + def test_center_false(self): + ego = eg.functions.community.ego_graph( + self.simple_graph, 2, radius=1, center=False + ) + self.assertSetEqual(set(ego.nodes), {1, 3}) + + def test_empty_graph(self): + G = eg.Graph() + G.add_node("x") + ego = eg.functions.community.ego_graph(G, "x", radius=1) + self.assertSetEqual(set(ego.nodes), {"x"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/community/tests/test_louvian.py b/easygraph/functions/community/tests/test_louvian.py new file mode 100644 index 0000000..31f11ad --- /dev/null +++ b/easygraph/functions/community/tests/test_louvian.py @@ -0,0 +1,65 @@ +import unittest + +import easygraph as eg + + +class TestLouvainCommunityDetection(unittest.TestCase): + def setUp(self): + self.graph_simple = eg.Graph() + self.graph_simple.add_edges_from([(0, 1), (1, 2), (3, 4)]) + + self.graph_weighted = eg.Graph() + self.graph_weighted.add_edges_from( + [(0, 1, {"weight": 5}), (1, 2, {"weight": 3}), (3, 4, {"weight": 2})] + ) + + self.graph_directed = eg.DiGraph() + self.graph_directed.add_edges_from([(0, 1), (1, 2), (2, 0), (3, 4)]) + + self.graph_disconnected = eg.Graph() + self.graph_disconnected.add_edges_from([(0, 1), (2, 3), (4, 5)]) + + self.graph_single_node = eg.Graph() + self.graph_single_node.add_node(42) + + self.graph_empty = eg.Graph() + + def test_louvain_communities_simple(self): + communities = eg.functions.community.louvain_communities(self.graph_simple) + flat = {node for comm in communities for node in comm} + self.assertSetEqual(flat, set(self.graph_simple.nodes)) + + def test_louvain_communities_weighted(self): + communities = eg.functions.community.louvain_communities( + self.graph_weighted, weight="weight" + ) + flat = {node for comm in communities for node in comm} + self.assertSetEqual(flat, set(self.graph_weighted.nodes)) + + def test_louvain_communities_disconnected(self): + communities = eg.functions.community.louvain_communities( + self.graph_disconnected + ) + flat = {node for comm in communities for node in comm} + self.assertSetEqual(flat, set(self.graph_disconnected.nodes)) + + def test_louvain_communities_single_node(self): + communities = eg.functions.community.louvain_communities(self.graph_single_node) + self.assertEqual(len(communities), 1) + self.assertSetEqual(communities[0], {42}) + + def test_louvain_communities_empty_graph(self): + communities = eg.functions.community.louvain_communities(self.graph_empty) + self.assertEqual(communities, []) + + def test_louvain_partitions_progressive_size(self): + partitions = list(eg.functions.community.louvain_partitions(self.graph_simple)) + for partition in partitions: + total_nodes = sum(len(p) for p in partition) + self.assertEqual(total_nodes, len(self.graph_simple.nodes)) + flat = [node for part in partition for node in part] + self.assertEqual(len(flat), len(set(flat))) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/community/tests/test_modularity.py b/easygraph/functions/community/tests/test_modularity.py new file mode 100644 index 0000000..5ca1fd6 --- /dev/null +++ b/easygraph/functions/community/tests/test_modularity.py @@ -0,0 +1,71 @@ +import unittest + +import easygraph as eg + + +class TestModularity(unittest.TestCase): + def setUp(self): + self.G = eg.Graph() + self.G.add_edges_from([(0, 1), (1, 2), (2, 3), (3, 0)]) + + self.DG = eg.DiGraph() + self.DG.add_edges_from([(0, 1), (1, 2), (2, 0)]) + + self.G_weighted = eg.Graph() + self.G_weighted.add_edge(0, 1, weight=2) + self.G_weighted.add_edge(1, 2, weight=3) + self.G_weighted.add_edge(2, 0, weight=1) + + self.G_selfloop = eg.Graph() + self.G_selfloop.add_edges_from([(0, 0), (1, 1), (0, 1)]) + + self.G_empty = eg.Graph() + + def test_undirected_modularity(self): + communities = [{0, 1}, {2, 3}] + q = eg.functions.community.modularity(self.G, communities) + self.assertIsInstance(q, float) + + def test_directed_modularity(self): + communities = [{0, 1, 2}] + q = eg.functions.community.modularity(self.DG, communities) + self.assertIsInstance(q, float) + + def test_weighted_graph(self): + communities = [{0, 1}, {2}] + q = eg.functions.community.modularity( + self.G_weighted, communities, weight="weight" + ) + self.assertIsInstance(q, float) + + def test_self_loops(self): + communities = [{0, 1}] + q = eg.functions.community.modularity(self.G_selfloop, communities) + self.assertIsInstance(q, float) + + def test_single_community(self): + communities = [{0, 1, 2, 3}] + q = eg.functions.community.modularity(self.G, communities) + self.assertIsInstance(q, float) + + def test_each_node_its_own_community(self): + communities = [{0}, {1}, {2}, {3}] + q = eg.functions.community.modularity(self.G, communities) + self.assertIsInstance(q, float) + + def test_empty_graph(self): + with self.assertRaises(ZeroDivisionError): + eg.functions.community.modularity(self.G_empty, []) + + def test_empty_community_list(self): + q = eg.functions.community.modularity(self.G, []) + self.assertEqual(q, 0.0) + + def test_non_list_communities(self): + communities = (set([0, 1]), set([2, 3])) + q = eg.functions.community.modularity(self.G, communities) + self.assertIsInstance(q, float) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/community/tests/test_modularity_max_detection.py b/easygraph/functions/community/tests/test_modularity_max_detection.py new file mode 100644 index 0000000..a7ffbe1 --- /dev/null +++ b/easygraph/functions/community/tests/test_modularity_max_detection.py @@ -0,0 +1,81 @@ +import unittest + +import easygraph as eg + + +class TestGreedyModularityCommunities(unittest.TestCase): + def setUp(self): + # A simple connected graph + self.graph_simple = eg.Graph() + self.graph_simple.add_edges_from([(0, 1), (1, 2), (3, 4)]) + + # A weighted graph + self.graph_weighted = eg.Graph() + self.graph_weighted.add_edges_from( + [(0, 1, {"weight": 3}), (1, 2, {"weight": 2}), (3, 4, {"weight": 1})] + ) + + # A fully connected graph (clique) + self.graph_clique = eg.Graph() + self.graph_clique.add_edges_from([(0, 1), (0, 2), (1, 2)]) + + # A disconnected graph + self.graph_disconnected = eg.Graph() + self.graph_disconnected.add_edges_from([(0, 1), (2, 3), (4, 5)]) + + # A graph with a single node + self.graph_single_node = eg.Graph() + self.graph_single_node.add_node(42) + + # An empty graph + self.graph_empty = eg.Graph() + + def test_communities_simple(self): + result = eg.functions.community.greedy_modularity_communities(self.graph_simple) + flat_nodes = {node for group in result for node in group} + self.assertSetEqual(flat_nodes, set(self.graph_simple.nodes)) + + def test_communities_weighted(self): + result = eg.functions.community.greedy_modularity_communities( + self.graph_weighted + ) + flat_nodes = {node for group in result for node in group} + self.assertSetEqual(flat_nodes, set(self.graph_weighted.nodes)) + + def test_communities_clique(self): + result = eg.functions.community.greedy_modularity_communities(self.graph_clique) + self.assertEqual(len(result), 1) + self.assertSetEqual(result[0], set(self.graph_clique.nodes)) + + def test_communities_disconnected(self): + result = eg.functions.community.greedy_modularity_communities( + self.graph_disconnected + ) + flat_nodes = {node for group in result for node in group} + self.assertSetEqual(flat_nodes, set(self.graph_disconnected.nodes)) + + def test_communities_single_node(self): + with self.assertRaises(SystemExit): + eg.functions.community.greedy_modularity_communities(self.graph_single_node) + + def test_communities_empty_graph(self): + with self.assertRaises(SystemExit): + eg.functions.community.greedy_modularity_communities(self.graph_empty) + + def test_correct_partition_disjoint(self): + result = eg.functions.community.greedy_modularity_communities( + self.graph_disconnected + ) + all_nodes = [node for group in result for node in group] + self.assertEqual(len(all_nodes), len(set(all_nodes))) + + def test_communities_sorted_by_size(self): + result = eg.functions.community.greedy_modularity_communities( + self.graph_disconnected + ) + sizes = [len(group) for group in result] + self.assertEqual(sizes, sorted(sizes, reverse=True)) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/community/tests/test_motif.py b/easygraph/functions/community/tests/test_motif.py new file mode 100644 index 0000000..5b5fca9 --- /dev/null +++ b/easygraph/functions/community/tests/test_motif.py @@ -0,0 +1,89 @@ +import random +import unittest + +import easygraph as eg + + +class TestMotif: + @classmethod + def setup_class(self): + self.G = eg.Graph() + self.G.add_nodes_from([1, 2, 3, 4, 5]) + self.G.add_edges_from([(1, 3), (2, 3), (3, 4), (4, 5), (3, 5)]) + + def test_esu(self): + res = eg.enumerate_subgraph(self.G, 3) + res = [list(x) for x in res] + exp_res = [{1, 3, 4}, {1, 2, 3}, {1, 3, 5}, {2, 3, 5}, {2, 3, 4}, {3, 4, 5}] + exp_res = [list(x) for x in exp_res] + assert sorted(res) == sorted(exp_res) + + +class TestMotifEnumeration(unittest.TestCase): + def setUp(self): + # Triangle plus a tail + self.G = eg.Graph() + self.G.add_edges_from( + [(1, 2), (2, 3), (3, 1), (3, 4), (4, 5)] # triangle # tail + ) + + def test_esu_enumeration_correct(self): + motifs = eg.enumerate_subgraph(self.G, 3) + motifs = [frozenset(m) for m in motifs] + expected = [{1, 2, 3}, {2, 3, 4}, {3, 4, 5}] + expected = [frozenset(x) for x in expected] + self.assertTrue(all(m in motifs for m in expected)) + for m in motifs: + self.assertEqual(len(m), 3) + self.assertTrue(isinstance(m, frozenset)) + + def test_empty_graph(self): + G = eg.Graph() + motifs = eg.enumerate_subgraph(G, 3) + self.assertEqual(motifs, []) + + def test_graph_smaller_than_k(self): + G = eg.Graph() + G.add_edges_from([(1, 2)]) + motifs = eg.enumerate_subgraph(G, 3) + self.assertEqual(motifs, []) + + def test_k_equals_1(self): + G = eg.Graph() + G.add_nodes_from([1, 2, 3]) + motifs = eg.enumerate_subgraph(G, 1) + expected = [{1}, {2}, {3}] + motifs = [set(m) for m in motifs] + self.assertEqual(sorted(motifs), sorted(expected)) + + def test_random_enumerate_cut_prob_valid(self): + random.seed(0) + cut_prob = [1.0] * 3 + motifs = eg.random_enumerate_subgraph(self.G, 3, cut_prob) + for m in motifs: + self.assertEqual(len(m), 3) + + def test_random_enumerate_cut_prob_invalid_length(self): + cut_prob = [1.0, 0.9] + with self.assertRaises(eg.EasyGraphError): + eg.random_enumerate_subgraph(self.G, 3, cut_prob) + + def test_random_enumerate_zero_cut_prob(self): + cut_prob = [0.0, 0.0, 0.0] + motifs = eg.random_enumerate_subgraph(self.G, 3, cut_prob) + self.assertEqual(motifs, []) + + def test_directed_graph_enumeration(self): + DG = eg.DiGraph() + DG.add_edges_from([(1, 2), (2, 3), (3, 1)]) + motifs = eg.enumerate_subgraph(DG, 3) + motifs = [set(m) for m in motifs] + self.assertIn({1, 2, 3}, motifs) + + def test_multigraph_error(self): + MG = eg.MultiGraph() + MG.add_edges_from([(1, 2), (2, 3)]) + with self.assertRaises(eg.EasyGraphNotImplemented): + eg.enumerate_subgraph(MG, 3) + with self.assertRaises(eg.EasyGraphNotImplemented): + eg.random_enumerate_subgraph(MG, 3, [1.0] * 3) diff --git a/easygraph/functions/components/__init__.py b/easygraph/functions/components/__init__.py new file mode 100644 index 0000000..e2fb2c0 --- /dev/null +++ b/easygraph/functions/components/__init__.py @@ -0,0 +1,4 @@ +from .biconnected import * +from .connected import * +from .strongly_connected import * +from .weakly_connected import * diff --git a/easygraph/functions/components/biconnected.py b/easygraph/functions/components/biconnected.py new file mode 100644 index 0000000..7c0dbbf --- /dev/null +++ b/easygraph/functions/components/biconnected.py @@ -0,0 +1,247 @@ +from itertools import chain + +from easygraph.utils import * + + +__all__ = [ + "is_biconnected", + "biconnected_components", + "generator_biconnected_components_nodes", + "generator_biconnected_components_edges", + "generator_articulation_points", +] + + +@not_implemented_for("multigraph", "directed") +def is_biconnected(G): + """Returns whether the graph is biconnected or not. + + Parameters + ---------- + G : easygraph.Graph or easygraph.DiGraph + + Returns + ------- + is_biconnected : boolean + `True` if the graph is biconnected. + + Examples + -------- + + >>> is_biconnected(G) + + """ + bc_nodes = list(generator_biconnected_components_nodes(G)) + if len(bc_nodes) == 1: + return len(bc_nodes[0]) == len( + G + ) # avoid situations where there is isolated vertex + return False + + +@not_implemented_for("multigraph", "directed") +# TODO: get the subgraph of each biconnected graph +def biconnected_components(G): + """Returns a list of biconnected components, each of which denotes the edges set of a biconnected component. + + Parameters + ---------- + G : easygraph.Graph or easygraph.DiGraph + + Returns + ------- + biconnected_components : list of list + Each element list is the edges set of a biconnected component. + + Examples + -------- + >>> connected_components(G) + + """ + return list(generator_biconnected_components_edges(G)) + + +@not_implemented_for("multigraph", "directed") +def generator_biconnected_components_nodes(G): + """Returns a generator of nodes in each biconnected component. + + Parameters + ---------- + G : easygraph.Graph or easygraph.DiGraph + + Returns + ------- + Yields nodes set of each biconnected component. + + See Also + -------- + generator_biconnected_components_edges + + Examples + -------- + >>> generator_biconnected_components_nodes(G) + + + """ + for component in _biconnected_dfs_record_edges(G, need_components=True): + # TODO: only one edge = biconnected_component? + yield set(chain.from_iterable(component)) + + +@not_implemented_for("multigraph", "directed") +def generator_biconnected_components_edges(G): + """Returns a generator of nodes in each biconnected component. + + Parameters + ---------- + G : easygraph.Graph or easygraph.DiGraph + + Returns + ------- + Yields edges set of each biconnected component. + + See Also + -------- + generator_biconnected_components_nodes + + Examples + -------- + >>> generator_biconnected_components_edges(G) + + """ + yield from _biconnected_dfs_record_edges(G, need_components=True) + + +@not_implemented_for("multigraph", "directed") +def generator_articulation_points(G): + """Returns a generator of articulation points. + + Parameters + ---------- + G : easygraph.Graph or easygraph.DiGraph + + Returns + ------- + Yields the articulation point in *G*. + + Examples + -------- + >>> generator_articulation_points(G) + + """ + seen = set() + for cut_vertex in _biconnected_dfs_record_edges(G, need_components=False): + if cut_vertex not in seen: + seen.add(cut_vertex) + yield cut_vertex + + +@hybrid("cpp_biconnected_dfs_record_edges") +def _biconnected_dfs_record_edges(G, need_components=True): + """ + References + ---------- + https://www.cnblogs.com/nullzx/p/7968110.html + https://blog.csdn.net/gauss_acm/article/details/43493903 + """ + # record edges of each biconnected component in traversal + # Copied version from EasyGraph + # depth-first search algorithm to generate articulation points + # and biconnected components + visited = set() + for start in G: + if start in visited: + continue + discovery = {start: 0} # time of first discovery of node during search + low = {start: 0} + root_children = 0 + visited.add(start) + edge_stack = [] + stack = [(start, start, iter(G[start]))] + while stack: + grandparent, parent, children = stack[-1] + try: + child = next(children) + if grandparent == child: + continue + if child in visited: + if discovery[child] <= discovery[parent]: # back edge + low[parent] = min(low[parent], discovery[child]) + if need_components: + edge_stack.append((parent, child)) + else: + low[child] = discovery[child] = len(discovery) + visited.add(child) + stack.append((parent, child, iter(G[child]))) + if need_components: + edge_stack.append((parent, child)) + except StopIteration: + stack.pop() + if len(stack) > 1: + if low[parent] >= discovery[grandparent]: + if need_components: + ind = edge_stack.index((grandparent, parent)) + yield edge_stack[ind:] + edge_stack = edge_stack[:ind] + else: + yield grandparent + low[grandparent] = min(low[parent], low[grandparent]) + elif stack: # length 1 so grandparent is root + root_children += 1 + if need_components: + ind = edge_stack.index((grandparent, parent)) + yield edge_stack[ind:] + if not need_components: + # root node is articulation point if it has more than 1 child + if root_children > 1: + yield start + + +def _biconnected_dfs_record_nodes(G, need_components=True): + # record nodes of each biconnected component in traversal + # Not used. + visited = set() + for start in G: + if start in visited: + continue + discovery = {start: 0} # time of first discovery of node during search + low = {start: 0} + root_children = 0 + visited.add(start) + node_stack = [start] + stack = [(start, start, iter(G[start]))] + while stack: + grandparent, parent, children = stack[-1] + try: + child = next(children) + if grandparent == child: + continue + if child in visited: + if discovery[child] <= discovery[parent]: # back edge + low[parent] = min(low[parent], discovery[child]) + else: + low[child] = discovery[child] = len(discovery) + visited.add(child) + stack.append((parent, child, iter(G[child]))) + if need_components: + node_stack.append(child) + except StopIteration: + stack.pop() + if len(stack) > 1: + if low[parent] >= discovery[grandparent]: + if need_components: + ind = node_stack.index(grandparent) + yield node_stack[ind:] + node_stack = node_stack[: ind + 1] + else: + yield grandparent + low[grandparent] = min(low[parent], low[grandparent]) + elif stack: # length 1 so grandparent is root + root_children += 1 + if need_components: + ind = node_stack.index(grandparent) + yield node_stack[ind:] + if not need_components: + # root node is articulation point if it has more than 1 child + if root_children > 1: + yield start diff --git a/easygraph/functions/components/connected.py b/easygraph/functions/components/connected.py new file mode 100644 index 0000000..fce963f --- /dev/null +++ b/easygraph/functions/components/connected.py @@ -0,0 +1,160 @@ +from easygraph.utils.decorators import * + + +__all__ = [ + "is_connected", + "number_connected_components", + "connected_components", + "connected_components_directed", + "connected_component_of_node", +] + + +@not_implemented_for("multigraph") +def is_connected(G): + """Returns whether the graph is connected or not. + + Parameters + ---------- + G : easygraph.Graph or easygraph.DiGraph + + Returns + ------- + is_biconnected : boolean + `True` if the graph is connected. + + Examples + -------- + + >>> is_connected(G) + + """ + assert len(G) != 0, "No node in the graph." + arbitrary_node = next(iter(G)) # Pick an arbitrary node to run BFS + return len(G) == sum(1 for node in _plain_bfs(G, arbitrary_node)) + + +@not_implemented_for("multigraph") +def number_connected_components(G): + """Returns the number of connected components. + + Parameters + ---------- + G : easygraph.Graph + + Returns + ------- + number_connected_components : int + The number of connected components. + + Examples + -------- + >>> number_connected_components(G) + + """ + return sum(1 for component in _generator_connected_components(G)) + + +@not_implemented_for("multigraph") +@hybrid("cpp_connected_components_undirected") +def connected_components(G): + """Returns a list of connected components, each of which denotes the edges set of a connected component. + + Parameters + ---------- + G : easygraph.Graph + Returns + ------- + connected_components : list of list + Each element list is the edges set of a connected component. + + Examples + -------- + >>> connected_components(G) + + """ + seen = set() + for v in G: + if v not in seen: + c = set(_plain_bfs(G, v)) + seen.update(c) + yield c + + +@not_implemented_for("multigraph") +@hybrid("cpp_connected_components_directed") +def connected_components_directed(G): + """Returns a list of connected components, each of which denotes the edges set of a connected component. + + Parameters + ---------- + G : easygraph.DiGraph + Returns + ------- + connected_components : list of list + Each element list is the edges set of a connected component. + + Examples + -------- + >>> connected_components(G) + + """ + seen = set() + for v in G: + if v not in seen: + c = set(_plain_bfs(G, v)) + seen.update(c) + yield c + + +def _generator_connected_components(G): + seen = set() + for v in G: + if v not in seen: + component = set(_plain_bfs(G, v)) + yield component + seen.update(component) + + +@not_implemented_for("multigraph") +def connected_component_of_node(G, node): + """Returns the connected component that *node* belongs to. + + Parameters + ---------- + G : easygraph.Graph + + node : object + The target node + + Returns + ------- + connected_component_of_node : set + The connected component that *node* belongs to. + + Examples + -------- + Returns the connected component of one node `Jack`. + + >>> connected_component_of_node(G, node='Jack') + + """ + return set(_plain_bfs(G, node)) + + +@hybrid("cpp_plain_bfs") +def _plain_bfs(G, source): + """ + A fast BFS node generator + """ + G_adj = G.adj + seen = set() + nextlevel = {source} + while nextlevel: + thislevel = nextlevel + nextlevel = set() + for v in thislevel: + if v not in seen: + yield v + seen.add(v) + nextlevel.update(G_adj[v]) diff --git a/easygraph/functions/components/strongly_connected.py b/easygraph/functions/components/strongly_connected.py new file mode 100644 index 0000000..e9c6aa7 --- /dev/null +++ b/easygraph/functions/components/strongly_connected.py @@ -0,0 +1,244 @@ +import easygraph as eg + +from easygraph.utils.decorators import * + + +__all__ = [ + "number_strongly_connected_components", + "strongly_connected_components", + "is_strongly_connected", + "condensation", +] + + +@not_implemented_for("undirected") +@hybrid("cpp_strongly_connected_components") +def strongly_connected_components(G): + """Generate nodes in strongly connected components of graph. + + Parameters + ---------- + G : EasyGraph Graph + A directed graph. + + Returns + ------- + comp : generator of sets + A generator of sets of nodes, one for each strongly connected + component of G. + + Raises + ------ + EasyGraphNotImplemented + If G is undirected. + + Examples + -------- + Generate a sorted list of strongly connected components, largest first. + + If you only want the largest component, it's more efficient to + use max instead of sort. + + >>> largest = max(eg.strongly_connected_components(G), key=len) + + See Also + -------- + connected_components + + Notes + ----- + Uses Tarjan's algorithm[1]_ with Nuutila's modifications[2]_. + Nonrecursive version of algorithm. + + References + ---------- + .. [1] Depth-first search and linear graph algorithms, R. Tarjan + SIAM Journal of Computing 1(2):146-160, (1972). + + .. [2] On finding the strongly connected components in a directed graph. + E. Nuutila and E. Soisalon-Soinen + Information Processing Letters 49(1): 9-14, (1994).. + + """ + preorder = {} + lowlink = {} + scc_found = set() + scc_queue = [] + i = 0 # Preorder counter + neighbors = {v: iter(G[v]) for v in G} + for source in G: + if source not in scc_found: + queue = [source] + while queue: + v = queue[-1] + if v not in preorder: + i = i + 1 + preorder[v] = i + done = True + for w in neighbors[v]: + if w not in preorder: + queue.append(w) + done = False + break + if done: + lowlink[v] = preorder[v] + for w in G[v]: + if w not in scc_found: + if preorder[w] > preorder[v]: + lowlink[v] = min([lowlink[v], lowlink[w]]) + else: + lowlink[v] = min([lowlink[v], preorder[w]]) + queue.pop() + if lowlink[v] == preorder[v]: + scc = {v} + while scc_queue and preorder[scc_queue[-1]] > preorder[v]: + k = scc_queue.pop() + scc.add(k) + scc_found.update(scc) + yield scc + else: + scc_queue.append(v) + + +def number_strongly_connected_components(G): + """Returns number of strongly connected components in graph. + + Parameters + ---------- + G : Easygraph graph + A directed graph. + + Returns + ------- + n : integer + Number of strongly connected components + + Raises + ------ + EasygraphNotImplemented + If G is undirected. + + Examples + -------- + >>> G = eg.DiGraph([(0, 1), (1, 2), (2, 0), (2, 3), (4, 5), (3, 4), (5, 6), (6, 3), (6, 7)]) + >>> eg.number_strongly_connected_components(G) + 3 + + See Also + -------- + strongly_connected_components + number_connected_components + + Notes + ----- + For directed graphs only. + """ + return sum(1 for scc in strongly_connected_components(G)) + + +@not_implemented_for("undirected") +def is_strongly_connected(G): + """Test directed graph for strong connectivity. + + A directed graph is strongly connected if and only if every vertex in + the graph is reachable from every other vertex. + + Parameters + ---------- + G : EasyGraph Graph + A directed graph. + + Returns + ------- + connected : bool + True if the graph is strongly connected, False otherwise. + + Examples + -------- + >>> G = eg.DiGraph([(0, 1), (1, 2), (2, 3), (3, 0), (2, 4), (4, 2)]) + >>> eg.is_strongly_connected(G) + True + >>> G.remove_edge(2, 3) + >>> eg.is_strongly_connected(G) + False + + Raises + ------ + EasyGraphNotImplemented + If G is undirected. + + See Also + -------- + is_connected + is_biconnected + strongly_connected_components + + Notes + ----- + For directed graphs only. + """ + if len(G) == 0: + raise eg.EasyGraphPointlessConcept( + """Connectivity is undefined for the null graph.""" + ) + + return len(next(strongly_connected_components(G))) == len(G) + + +@not_implemented_for("multigraph") +@only_implemented_for_Directed_graph +def condensation(G, scc=None): + """Returns the condensation of G. + The condensation of G is the graph with each of the strongly connected + components contracted into a single node. + Parameters + ---------- + G : easygraph.DiGraph + A directed graph. + scc: list or generator (optional, default=None) + Strongly connected components. If provided, the elements in + `scc` must partition the nodes in `G`. If not provided, it will be + calculated as scc=strongly_connected_components(G). + Returns + ------- + C : easygraph.DiGraph + The condensation graph C of G. The node labels are integers + corresponding to the index of the component in the list of + strongly connected components of G. C has a graph attribute named + 'mapping' with a dictionary mapping the original nodes to the + nodes in C to which they belong. Each node in C also has a node + attribute 'members' with the set of original nodes in G that + form the SCC that the node in C represents. + Examples + -------- + # >>> condensation(G) + Notes + ----- + After contracting all strongly connected components to a single node, + the resulting graph is a directed acyclic graph. + """ + if scc is None: + scc = strongly_connected_components(G) + mapping = {} + incoming_info = {} + members = {} + C = eg.DiGraph() + # Add mapping dict as graph attribute + C.graph["mapping"] = mapping + if len(G) == 0: + return C + for i, component in enumerate(scc): + members[i] = component + mapping.update((n, i) for n in component) + number_of_components = i + 1 + for i in range(number_of_components): + C.add_node(i, member=members[i], incoming=set()) + C.add_nodes(range(number_of_components)) + for edge in G.edges: + if mapping[edge[0]] != mapping[edge[1]]: + C.add_edge(mapping[edge[0]], mapping[edge[1]]) + if edge[1] not in incoming_info.keys(): + incoming_info[edge[1]] = set() + incoming_info[edge[1]].add(edge[0]) + C.graph["incoming_info"] = incoming_info + return C diff --git a/easygraph/functions/components/tests/__init__.py b/easygraph/functions/components/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easygraph/functions/components/tests/test_biconnected.py b/easygraph/functions/components/tests/test_biconnected.py new file mode 100644 index 0000000..ae54267 --- /dev/null +++ b/easygraph/functions/components/tests/test_biconnected.py @@ -0,0 +1,92 @@ +import unittest + +import easygraph as eg +import pytest + +from easygraph import biconnected_components +from easygraph import generator_articulation_points +from easygraph import generator_biconnected_components_edges +from easygraph import generator_biconnected_components_nodes +from easygraph import is_biconnected + + +class Test_biconnected(unittest.TestCase): + def setUp(self): + self.edges = [(1, 2), (2, 3), ("String", "Bool"), (2, 1), (0, 0), (-99, 256)] + self.test_graphs = [eg.Graph(), eg.MultiGraph(), eg.DiGraph()] + self.test_graphs.append(eg.classes.DiGraph(self.edges)) + + def test_is_biconnected(self): + for i in self.test_graphs: + print(eg.is_biconnected(i)) + + def test_biconnected_components(self): + for i in self.test_graphs: + eg.biconnected_components(i) + + def test_generator_biconnected_components_nodes(self): + for i in self.test_graphs: + eg.generator_biconnected_components_nodes(i) + + def test_generator_biconnected_components_edges(self): + for i in self.test_graphs: + eg.generator_biconnected_components_edges(i) + + def test_generator_articulation_points(self): + for i in self.test_graphs: + eg.generator_articulation_points(i) + + +class TestBiconnectedFunctions(unittest.TestCase): + def test_single_node(self): + G = eg.Graph() + G.add_node(1) + self.assertFalse(is_biconnected(G)) + self.assertEqual(list(biconnected_components(G)), []) + self.assertEqual(list(generator_articulation_points(G)), []) + + def test_disconnected_graph(self): + G = eg.Graph() + G.add_edges_from([(0, 1), (2, 3)]) + self.assertFalse(is_biconnected(G)) + self.assertGreaterEqual(len(list(generator_biconnected_components_edges(G))), 1) + + def test_triangle(self): + G = eg.Graph([(0, 1), (1, 2), (2, 0)]) + self.assertTrue(is_biconnected(G)) + comps = list(biconnected_components(G)) + self.assertEqual(len(comps), 1) + self.assertEqual(set(comps[0]), {(0, 1), (1, 2), (2, 0)}) + self.assertEqual(list(generator_articulation_points(G)), []) + + def test_with_articulation_point(self): + G = eg.Graph([(0, 1), (1, 2), (1, 3)]) + self.assertFalse(is_biconnected(G)) + arts = list(generator_articulation_points(G)) + self.assertIn(1, arts) + self.assertEqual(len(arts), 1) + + def test_cycle_plus_leaf(self): + G = eg.Graph([(0, 1), (1, 2), (2, 0), (2, 3)]) + self.assertFalse(is_biconnected(G)) + arts = list(generator_articulation_points(G)) + self.assertIn(2, arts) + + def test_multiple_biconnected_components(self): + G = eg.Graph() + G.add_edges_from([(1, 2), (2, 3), (3, 1)]) # triangle + G.add_edges_from([(3, 4), (4, 5)]) # path + components = list(generator_biconnected_components_edges(G)) + self.assertEqual(len(components), 3) + nodes_comps = list(generator_biconnected_components_nodes(G)) + self.assertTrue(any({1, 2, 3}.issubset(comp) for comp in nodes_comps)) + self.assertTrue(any({4, 5}.issubset(comp) for comp in nodes_comps)) + + def test_articulation_points_multiple(self): + G = eg.Graph([(0, 1), (1, 2), (2, 3), (3, 4)]) + aps = list(generator_articulation_points(G)) + self.assertEqual(aps, [3, 2, 1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/components/tests/test_connected.py b/easygraph/functions/components/tests/test_connected.py new file mode 100644 index 0000000..4d7db5b --- /dev/null +++ b/easygraph/functions/components/tests/test_connected.py @@ -0,0 +1,112 @@ +import inspect +import unittest + +import easygraph as eg + +from easygraph import connected_component_of_node +from easygraph import connected_components +from easygraph import connected_components_directed +from easygraph import is_connected +from easygraph import number_connected_components +from easygraph.utils.exception import EasyGraphNotImplemented + + +class TestConnected(unittest.TestCase): + def setUp(self): + self.edges = [(1, 2), (2, 3), (0, 4), (2, 1), (0, 0), (-99, 256)] + self.test_graphs = [eg.Graph([(4, -4)]), eg.DiGraph([(4, -4)])] + self.test_graphs.append(eg.classes.DiGraph(self.edges)) + + def test_is_connected(self): + for i in self.test_graphs: + print(eg.is_connected(i)) + + def test_number_connected_components(self): + for i in self.test_graphs: + print(eg.number_connected_components(i)) + + def test_connected_components(self): + for i in self.test_graphs: + print(eg.connected_components(i)) + + def test_connected_components_directed(self): + for i in self.test_graphs: + print(eg.connected_components_directed(i)) + + def test_connected_component_of_node(self): + for i in self.test_graphs: + print(eg.connected_component_of_node(i, 4)) + + def test_empty_graph(self): + G = eg.Graph() + with self.assertRaises(AssertionError): + is_connected(G) + self.assertEqual(number_connected_components(G), 0) + self.assertEqual(list(connected_components(G)), []) + + def test_single_node(self): + G = eg.Graph() + G.add_node(1) + self.assertTrue(is_connected(G)) + self.assertEqual(number_connected_components(G), 1) + self.assertEqual(list(connected_components(G)), [{1}]) + self.assertEqual(connected_component_of_node(G, 1), {1}) + + def test_disconnected_graph(self): + G = eg.Graph() + G.add_edges_from([(0, 1), (2, 3)]) + self.assertFalse(is_connected(G)) + self.assertEqual(number_connected_components(G), 2) + comps = list(connected_components(G)) + self.assertTrue({0, 1} in comps and {2, 3} in comps) + + def test_connected_graph(self): + G = eg.path_graph(5) + self.assertTrue(is_connected(G)) + self.assertEqual(number_connected_components(G), 1) + comps = list(connected_components(G)) + self.assertEqual(len(comps), 1) + self.assertEqual(comps[0], set(range(5))) + + def test_node_component_lookup(self): + G = eg.Graph() + G.add_edges_from([(0, 1), (2, 3)]) + comp = connected_component_of_node(G, 0) + self.assertEqual(comp, {0, 1}) + with self.assertRaises(KeyError): + connected_component_of_node(G, 999) # non-existent node + + def test_undirected_with_self_loops(self): + G = eg.Graph() + G.add_edges_from([(1, 1), (2, 2), (1, 2)]) + self.assertTrue(is_connected(G)) + self.assertEqual(number_connected_components(G), 1) + self.assertEqual(list(connected_components(G))[0], {1, 2}) + + def test_directed_components(self): + G = eg.DiGraph() + G.add_edges_from([(0, 1), (2, 3)]) + self.assertEqual(number_connected_components(G), 2) + components = list(connected_components_directed(G)) + self.assertTrue({0, 1} in components and {2, 3} in components) + + def test_directed_strong_vs_weak(self): + G = eg.DiGraph([(0, 1), (1, 0), (2, 3)]) + comps = list(connected_components_directed(G)) + self.assertTrue({0, 1} in comps) + self.assertTrue({2, 3} in comps) + + def test_multigraph_blocked(self): + G = eg.MultiGraph([(1, 2), (2, 3)]) + with self.assertRaises(EasyGraphNotImplemented): + is_connected(G) + with self.assertRaises(EasyGraphNotImplemented): + number_connected_components(G) + with self.assertRaises(EasyGraphNotImplemented): + list(connected_components(G)) + with self.assertRaises(EasyGraphNotImplemented): + connected_component_of_node(G, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/components/tests/test_strongly_connected.py b/easygraph/functions/components/tests/test_strongly_connected.py new file mode 100644 index 0000000..904f345 --- /dev/null +++ b/easygraph/functions/components/tests/test_strongly_connected.py @@ -0,0 +1,121 @@ +import inspect +import unittest + +import easygraph as eg + +from easygraph import condensation +from easygraph import is_strongly_connected +from easygraph import number_strongly_connected_components +from easygraph import strongly_connected_components +from easygraph.utils.exception import EasyGraphNotImplemented +from easygraph.utils.exception import EasyGraphPointlessConcept + + +class Test_strongly_connected(unittest.TestCase): + def setUp(self): + self.edges = [(1, 2), (2, 3), ("String", "Bool"), (2, 1), (0, 0), (-99, 256)] + self.test_graphs = [eg.Graph([(4, -4)]), eg.DiGraph([(4, False)])] + self.test_graphs.append(eg.classes.DiGraph(self.edges)) + + def test_empty_graph(self): + G = eg.DiGraph() + with self.assertRaises(EasyGraphPointlessConcept): + is_strongly_connected(G) + self.assertEqual(number_strongly_connected_components(G), 0) + self.assertEqual(list(strongly_connected_components(G)), []) + + def test_single_node(self): + G = eg.DiGraph() + G.add_node(1) + self.assertTrue(is_strongly_connected(G)) + self.assertEqual(number_strongly_connected_components(G), 1) + scc = list(strongly_connected_components(G)) + self.assertEqual(scc, [{1}]) + + def test_cycle_graph(self): + G = eg.DiGraph([(1, 2), (2, 3), (3, 1)]) + self.assertTrue(is_strongly_connected(G)) + self.assertEqual(number_strongly_connected_components(G), 1) + scc = list(strongly_connected_components(G)) + self.assertEqual(scc, [{1, 2, 3}]) + + def test_disconnected_scc(self): + G = eg.DiGraph([(0, 1), (1, 0), (2, 3), (3, 2), (4, 5)]) + scc = list(strongly_connected_components(G)) + self.assertEqual(len(scc), 4) + self.assertIn({0, 1}, scc) + self.assertIn({2, 3}, scc) + self.assertIn({4}, scc) + self.assertIn({5}, scc) + self.assertFalse(is_strongly_connected(G)) + self.assertEqual(number_strongly_connected_components(G), 4) + + def test_scc_with_self_loops(self): + G = eg.DiGraph([(1, 1), (2, 2), (3, 4), (4, 3)]) + scc = list(strongly_connected_components(G)) + self.assertEqual(len(scc), 3) + self.assertIn({1}, scc) + self.assertIn({2}, scc) + self.assertIn({3, 4}, scc) + + def test_condensation_structure(self): + G = eg.DiGraph( + [(0, 1), (1, 2), (2, 0), (2, 3), (4, 5), (3, 4), (5, 6), (6, 3), (6, 7)] + ) + cond = condensation(G) + self.assertTrue(cond.is_directed()) + self.assertIn("mapping", cond.graph) + self.assertEqual(len(cond), number_strongly_connected_components(G)) + + def has_cycle(G): + visited = set() + temp_mark = set() + + def visit(node): + if node in temp_mark: + return True + if node in visited: + return False + temp_mark.add(node) + for neighbor in G[node]: + if visit(neighbor): + return True + temp_mark.remove(node) + visited.add(node) + return False + + return any(visit(v) for v in G) + + self.assertFalse(has_cycle(cond)) + + def test_condensation_empty_graph(self): + G = eg.DiGraph() + C = condensation(G) + self.assertEqual(len(C), 0) + + def test_undirected_raises(self): + G = eg.Graph([(1, 2), (2, 3)]) + with self.assertRaises(EasyGraphNotImplemented): + list(strongly_connected_components(G)) + with self.assertRaises(EasyGraphNotImplemented): + is_strongly_connected(G) + with self.assertRaises(EasyGraphNotImplemented): + number_strongly_connected_components(G) + + def test_condensation_on_undirected_graph_raises(self): + G = eg.Graph([(1, 2), (2, 3)]) + with self.assertRaises(EasyGraphNotImplemented): + condensation(G) + + def test_condensation_manual_scc_input(self): + G = eg.DiGraph([(1, 2), (2, 1), (3, 4)]) + scc = list(strongly_connected_components(G)) + C = condensation(G, scc=scc) + self.assertEqual(len(C.nodes), len(scc)) + # Check if mapping is consistent + all_mapped = set(C.graph["mapping"].keys()) + self.assertEqual(all_mapped, set(G.nodes)) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/components/tests/test_weakly_connected.py b/easygraph/functions/components/tests/test_weakly_connected.py new file mode 100644 index 0000000..43941f4 --- /dev/null +++ b/easygraph/functions/components/tests/test_weakly_connected.py @@ -0,0 +1,82 @@ +import unittest + +import easygraph as eg + +from easygraph import is_weakly_connected +from easygraph import number_weakly_connected_components +from easygraph import weakly_connected_components +from easygraph.utils.exception import EasyGraphNotImplemented +from easygraph.utils.exception import EasyGraphPointlessConcept + + +class Test_weakly_connected(unittest.TestCase): + def test_empty_graph(self): + G = eg.DiGraph() + with self.assertRaises(EasyGraphPointlessConcept): + is_weakly_connected(G) + self.assertEqual(number_weakly_connected_components(G), 0) + self.assertEqual(list(weakly_connected_components(G)), []) + + def test_single_node(self): + G = eg.DiGraph() + G.add_node(1) + self.assertTrue(is_weakly_connected(G)) + self.assertEqual(number_weakly_connected_components(G), 1) + self.assertEqual(list(weakly_connected_components(G)), [{1}]) + + def test_connected_graph(self): + G = eg.DiGraph([(1, 2), (2, 3), (3, 4)]) + self.assertTrue(is_weakly_connected(G)) + self.assertEqual(number_weakly_connected_components(G), 1) + self.assertEqual(list(weakly_connected_components(G)), [{1, 2, 3, 4}]) + + def test_disconnected_graph(self): + G = eg.DiGraph([(1, 2), (3, 4)]) + self.assertFalse(is_weakly_connected(G)) + wcc = list(weakly_connected_components(G)) + self.assertEqual(len(wcc), 2) + self.assertIn({1, 2}, wcc) + self.assertIn({3, 4}, wcc) + + def test_self_loops(self): + G = eg.DiGraph([(1, 1), (2, 2)]) + wcc = list(weakly_connected_components(G)) + self.assertEqual(len(wcc), 2) + self.assertIn({1}, wcc) + self.assertIn({2}, wcc) + self.assertFalse(is_weakly_connected(G)) + + def test_multiple_components(self): + G = eg.DiGraph([(1, 2), (3, 4), (5, 6), (6, 5)]) + wcc = list(weakly_connected_components(G)) + self.assertEqual(number_weakly_connected_components(G), 3) + self.assertIn({1, 2}, wcc) + self.assertIn({3, 4}, wcc) + self.assertIn({5, 6}, wcc) + + def test_unconnected_nodes(self): + G = eg.DiGraph([(1, 2), (3, 4)]) + G.add_node(99) # isolated node + wcc = list(weakly_connected_components(G)) + self.assertEqual(len(wcc), 3) + self.assertIn({99}, wcc) + + def test_is_weakly_connected_after_adding_edge(self): + G = eg.DiGraph([(0, 1), (2, 1)]) + G.add_node(3) + self.assertFalse(is_weakly_connected(G)) + G.add_edge(2, 3) + self.assertTrue(is_weakly_connected(G)) + + def test_undirected_raises(self): + G = eg.Graph([(1, 2), (2, 3)]) + with self.assertRaises(EasyGraphNotImplemented): + is_weakly_connected(G) + with self.assertRaises(EasyGraphNotImplemented): + number_weakly_connected_components(G) + with self.assertRaises(EasyGraphNotImplemented): + list(weakly_connected_components(G)) + + +if __name__ == "__main__": + unittest.main() diff --git a/easygraph/functions/components/weakly_connected.py b/easygraph/functions/components/weakly_connected.py new file mode 100644 index 0000000..1e74116 --- /dev/null +++ b/easygraph/functions/components/weakly_connected.py @@ -0,0 +1,186 @@ +"""Weakly connected components.""" +import easygraph as eg + +from easygraph.utils.decorators import not_implemented_for + + +__all__ = [ + "number_weakly_connected_components", + "weakly_connected_components", + "is_weakly_connected", +] + + +@not_implemented_for("undirected") +def weakly_connected_components(G): + """Generate weakly connected components of G. + + Parameters + ---------- + G : EasyGraph graph + A directed graph + + Returns + ------- + comp : generator of sets + A generator of sets of nodes, one for each weakly connected + component of G. + + Raises + ------ + EasyGraphNotImplemented + If G is undirected. + + Examples + -------- + Generate a sorted list of weakly connected components, largest first. + + >>> G = eg.path_graph(4, create_using=eg.DiGraph()) + >>> eg.add_path(G, [10, 11, 12]) + >>> [ + ... len(c) + ... for c in sorted(eg.weakly_connected_components(G), key=len, reverse=True) + ... ] + [4, 3] + + If you only want the largest component, it's more efficient to + use max instead of sort: + + >>> largest_cc = max(eg.weakly_connected_components(G), key=len) + + See Also + -------- + connected_components + strongly_connected_components + + Notes + ----- + For directed graphs only. + + """ + seen = set() + for v in G: + if v not in seen: + c = set(_plain_bfs(G, v)) + seen.update(c) + yield c + + +@not_implemented_for("undirected") +def number_weakly_connected_components(G): + """Returns the number of weakly connected components in G. + + Parameters + ---------- + G : EasyGraph graph + A directed graph. + + Returns + ------- + n : integer + Number of weakly connected components + + Raises + ------ + EasyGraphNotImplemented + If G is undirected. + + Examples + -------- + >>> G = eg.DiGraph([(0, 1), (2, 1), (3, 4)]) + >>> eg.number_weakly_connected_components(G) + 2 + + See Also + -------- + weakly_connected_components + number_connected_components + number_strongly_connected_components + + Notes + ----- + For directed graphs only. + + """ + return sum(1 for wcc in weakly_connected_components(G)) + + +@not_implemented_for("undirected") +def is_weakly_connected(G): + """Test directed graph for weak connectivity. + + A directed graph is weakly connected if and only if the graph + is connected when the direction of the edge between nodes is ignored. + + Note that if a graph is strongly connected (i.e. the graph is connected + even when we account for directionality), it is by definition weakly + connected as well. + + Parameters + ---------- + G : EasyGraph Graph + A directed graph. + + Returns + ------- + connected : bool + True if the graph is weakly connected, False otherwise. + + Raises + ------ + EasyGraphNotImplemented + If G is undirected. + + Examples + -------- + >>> G = eg.DiGraph([(0, 1), (2, 1)]) + >>> G.add_node(3) + >>> eg.is_weakly_connected(G) # node 3 is not connected to the graph + False + >>> G.add_edge(2, 3) + >>> eg.is_weakly_connected(G) + True + + See Also + -------- + is_strongly_connected + is_semiconnected + is_connected + is_biconnected + weakly_connected_components + + Notes + ----- + For directed graphs only. + + """ + if len(G) == 0: + raise eg.EasyGraphPointlessConcept( + """Connectivity is undefined for the null graph.""" + ) + + return len(next(weakly_connected_components(G))) == len(G) + + +def _plain_bfs(G, source): + """A fast BFS node generator + + The direction of the edge between nodes is ignored. + + For directed graphs only. + + """ + Gsucc = G.adj + Gpred = G.pred + + seen = set() + nextlevel = {source} + while nextlevel: + thislevel = nextlevel + nextlevel = set() + for v in thislevel: + if v not in seen: + seen.add(v) + nextlevel.update(Gsucc[v]) + nextlevel.update(Gpred[v]) + yield v diff --git a/easygraph/functions/core/__init__.py b/easygraph/functions/core/__init__.py new file mode 100644 index 0000000..8dcc241 --- /dev/null +++ b/easygraph/functions/core/__init__.py @@ -0,0 +1 @@ +from .k_core import * diff --git a/easygraph/functions/core/k_core.py b/easygraph/functions/core/k_core.py new file mode 100644 index 0000000..3e81a48 --- /dev/null +++ b/easygraph/functions/core/k_core.py @@ -0,0 +1,72 @@ +import easygraph as eg + +from easygraph.utils import * + + +__all__ = [ + "k_core", +] + + +from typing import TYPE_CHECKING +from typing import List +from typing import Union + + +if TYPE_CHECKING: + from easygraph import Graph + + +@hybrid("cpp_k_core") +def k_core(G: "Graph") -> Union["Graph", List]: + """ + Returns the k-core of G. + + A k-core is a maximal subgraph that contains nodes of degree k or more. + + Parameters + ---------- + G : EasyGraph graph + A graph or directed graph + k : int, optional + The order of the core. If not specified return the main core. + return_graph : bool, optional + If True, return the k-core as a graph. If False, return a list of nodes. + + Returns + ------- + G : EasyGraph graph, if return_graph is True, else a list of nodes + The k-core subgraph + """ + # Create a shallow copy of the input graph + H = G.copy() + + # Initialize a dictionary to store the degrees of the nodes + degrees = dict(G.degree()) + # Sort nodes by degree. + nodes = sorted(degrees, key=degrees.get) + bin_boundaries = [0] + curr_degree = 0 + for i, v in enumerate(nodes): + if degrees[v] > curr_degree: + bin_boundaries.extend([i] * (degrees[v] - curr_degree)) + curr_degree = degrees[v] + node_pos = {v: pos for pos, v in enumerate(nodes)} + # The initial guess for the core number of a node is its degree. + core = degrees + nbrs = {v: list(G.neighbors(v)) for v in G} + for v in nodes: + for u in nbrs[v]: + if core[u] > core[v]: + nbrs[u].remove(v) + pos = node_pos[u] + bin_start = bin_boundaries[core[u]] + node_pos[u] = bin_start + node_pos[nodes[bin_start]] = pos + nodes[bin_start], nodes[pos] = nodes[pos], nodes[bin_start] + bin_boundaries[core[u]] += 1 + core[u] -= 1 + ret = [0.0 for i in range(len(G))] + for i in range(len(ret)): + ret[i] = core[G.index2node[i]] + return ret diff --git a/easygraph/functions/core/tests/__init__.py b/easygraph/functions/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easygraph/functions/core/tests/test_k_core.py b/easygraph/functions/core/tests/test_k_core.py new file mode 100644 index 0000000..55efcd6 --- /dev/null +++ b/easygraph/functions/core/tests/test_k_core.py @@ -0,0 +1,101 @@ +import easygraph as eg +import pytest + +from easygraph import k_core + + +@pytest.mark.parametrize( + "edges,k", + [ + ([(1, 2), (1, 3), (2, 3), (2, 4), (3, 4), (4, 5)], 2), + ([(1, 2), (1, 3), (2, 3), (2, 4), (3, 4), (4, 5)], 3), + ([(1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 2), + ([(1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 3), + ([(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)], 1), + ], +) +def test_k_core(edges, k): + nx = pytest.importorskip("networkx") + + from easygraph import Graph + from easygraph import k_core + + G = Graph() + G_nx = nx.Graph() + G.add_edges_from(edges) + G_nx.add_edges_from(edges) + + H = k_core(G) + H_nx = nx.core_number(G_nx) + assert H == list(H_nx.values()) + + +def test_k_core_empty_graph(): + G = eg.Graph() + result = k_core(G) + assert result == [] + + +def test_k_core_single_node_isolated(): + G = eg.Graph() + G.add_node(1) + result = k_core(G) + assert result == [0] + + +def test_k_core_clique(): + G = eg.complete_graph(5) # Each node has degree 4 + result = k_core(G) + assert set(result) == {4} + + +def test_k_core_star_graph(): + nx = pytest.importorskip("networkx") + G = eg.Graph() + G.add_node(0) + G.add_edges_from((0, i) for i in range(1, 6)) + result = k_core(G) + G_nx = nx.Graph() + G_nx.add_node(0) + G_nx.add_edges_from((0, i) for i in range(1, 6)) + expected = list(nx.core_number(G_nx).values()) + assert sorted(result) == sorted(expected) + + +def test_k_core_disconnected_components(): + G = eg.Graph() + # Component 1: triangle + G.add_edges_from([(0, 1), (1, 2), (2, 0)]) + # Component 2: line + G.add_edges_from([(3, 4)]) + result = k_core(G) + core_component_1 = {result[i] for i in [0, 1, 2]} + core_component_2 = {result[i] for i in [3, 4]} + assert core_component_1 == {2} + assert core_component_2 == {1} + + +def test_k_core_all_zero_core(): + G = eg.path_graph(5) + result = k_core(G) + assert all(isinstance(v, int) or isinstance(v, float) for v in result) + assert max(result) <= 2 + + +def test_k_core_index_to_node_mapping_consistency(): + G = eg.Graph() + edges = [(5, 10), (10, 15), (15, 20)] + G.add_edges_from(edges) + result = k_core(G) + for i, node in enumerate(G.index2node): + assert isinstance(result[i], (int, float)) + deg_map = dict(G.degree()) + if node in deg_map: + assert result[i] <= deg_map[node] + + +def test_k_core_large_k(): + G = eg.Graph() + G.add_edges_from([(1, 2), (2, 3)]) + result = k_core(G) + assert max(result) <= 2 diff --git a/easygraph/functions/drawing/__init__.py b/easygraph/functions/drawing/__init__.py new file mode 100644 index 0000000..1d8f953 --- /dev/null +++ b/easygraph/functions/drawing/__init__.py @@ -0,0 +1,3 @@ +from .drawing import * +from .plot import * +from .positioning import * diff --git a/easygraph/functions/drawing/defaults.py b/easygraph/functions/drawing/defaults.py new file mode 100644 index 0000000..4fc818f --- /dev/null +++ b/easygraph/functions/drawing/defaults.py @@ -0,0 +1,253 @@ +from typing import Any +from typing import List +from typing import Optional +from typing import Union + + +def default_style( + num_v: int, + num_e: int, + v_color: Union[str, list] = "r", + e_color: Union[str, list] = "gray", + e_fill_color: Union[str, list] = "whitesmoke", +): + _v_color = "r" + _e_color = "gray" + _e_fill_color = "whitesmoke" + + v_color = fill_color(v_color, _v_color, num_v) + e_color = fill_color(e_color, _e_color, num_e) + e_fill_color = fill_color(e_fill_color, _e_fill_color, num_e) + + return v_color, e_color, e_fill_color + + +def default_bipartite_style( + num_u: int, + num_v: int, + num_e: int, + u_color: Union[str, list] = "m", + v_color: Union[str, list] = "r", + e_color: Union[str, list] = "gray", + e_fill_color: Union[str, list] = "whitesmoke", +): + _u_color = "m" + _v_color = "r" + _e_color = "gray" + _e_fill_color = "whitesmoke" + + u_color = fill_color(u_color, _u_color, num_u) + v_color = fill_color(v_color, _v_color, num_v) + e_color = fill_color(e_color, _e_color, num_e) + e_fill_color = fill_color(e_fill_color, _e_fill_color, num_e) + + return u_color, v_color, e_color, e_fill_color + + +def default_hypergraph_style( + num_v: int, + num_e: int, + v_color: Union[str, list] = "r", + e_color: Union[str, list] = "gray", + e_fill_color: Union[str, list] = "whitesmoke", +): + _v_color = "r" + _e_color = "gray" + _e_fill_color = "whitesmoke" + + v_color = fill_color(v_color, _v_color, num_v) + e_color = fill_color(e_color, _e_color, num_e) + e_fill_color = fill_color(e_fill_color, _e_fill_color, num_e) + + return v_color, e_color, e_fill_color + + +def default_size( + num_v: int, + e_list: List[tuple], + v_size: Union[float, list] = 1.0, + v_line_width: Union[float, list] = 1.0, + e_line_width: Union[float, list] = 1.0, + font_size: float = None, +): + import numpy as np + + _v_size = 1 / np.sqrt(num_v + 10) * 0.1 + _v_line_width = 1 * np.exp(-num_v / 50) + _e_line_width = 1 * np.exp(-len(e_list) / 120) + _font_size = 20 * np.exp(-num_v / 100) + v_size = fill_sizes(v_size, _v_size, num_v) + v_line_width = fill_sizes(v_line_width, _v_line_width, num_v) + print("len(e_list):", e_list) + e_line_width = fill_sizes(e_line_width, _e_line_width, len(e_list)) + + font_size = _font_size if font_size is None else font_size + + return v_size, v_line_width, e_line_width, font_size + + +def default_bipartite_size( + num_u: int, + num_v: int, + e_list: List[tuple], + u_size: Union[float, list] = 1.0, + u_line_width: Union[float, list] = 1.0, + v_size: Union[float, list] = 1.0, + v_line_width: Union[float, list] = 1.0, + e_line_width: Union[float, list] = 1.0, + u_font_size: float = 1.0, + v_font_size: float = 1.0, +): + import numpy as np + + _u_size = 1 / np.sqrt(num_u + 12) * 0.08 + _u_line_width = 1 * np.exp(-num_u / 50) + _v_size = 1 / np.sqrt(num_v + 12) * 0.08 + _v_line_width = 1 * np.exp(-num_v / 50) + _e_line_width = 1 * np.exp(-len(e_list) / 50) + _u_font_size = 12 * np.exp(-((num_u / num_v) ** 0.3) * (num_u + num_v) / 100) + _v_font_size = 12 * np.exp(-((num_v / num_u) ** 0.3) * (num_u + num_v) / 100) + + u_size = fill_sizes(u_size, _u_size, num_u) + u_line_width = fill_sizes(u_line_width, _u_line_width, num_u) + v_size = fill_sizes(v_size, _v_size, num_v) + v_line_width = fill_sizes(v_line_width, _v_line_width, num_v) + e_line_width = fill_sizes(e_line_width, _e_line_width, len(e_list)) + + u_font_size = _u_font_size if u_font_size is None else u_font_size * _u_font_size + v_font_size = _v_font_size if v_font_size is None else v_font_size * _v_font_size + + return ( + u_size, + u_line_width, + v_size, + v_line_width, + e_line_width, + u_font_size, + v_font_size, + ) + + +def default_strength( + num_v: int, + e_list: List[tuple], + push_v_strength: float = 1.0, + push_e_strength: float = 1.0, + pull_e_strength: float = 1.0, + pull_center_strength: float = 1.0, +): + _push_v_strength = 0.006 + _push_e_strength = 0.0 + _pull_e_strength = 0.045 + _pull_center_strength = 0.01 + + push_v_strength = fill_strength(push_v_strength, _push_v_strength) + push_e_strength = fill_strength(push_e_strength, _push_e_strength) + pull_e_strength = fill_strength(pull_e_strength, _pull_e_strength) + pull_center_strength = fill_strength(pull_center_strength, _pull_center_strength) + + return push_v_strength, push_e_strength, pull_e_strength, pull_center_strength + + +def default_bipartite_strength( + num_u: int, + num_v: int, + e_list: List[tuple], + push_u_strength: float = 1.0, + push_v_strength: float = 1.0, + push_e_strength: float = 1.0, + pull_e_strength: float = 1.0, + pull_u_center_strength: float = 1.0, + pull_v_center_strength: float = 1.0, +): + _push_u_strength = 0.005 + _push_v_strength = 0.005 + _push_e_strength = 0.0 + _pull_e_strength = 0.03 + _pull_u_center_strength = 0.04 + _pull_v_center_strength = 0.04 + + push_u_strength = fill_strength(push_u_strength, _push_u_strength) + push_v_strength = fill_strength(push_v_strength, _push_v_strength) + push_e_strength = fill_strength(push_e_strength, _push_e_strength) + pull_e_strength = fill_strength(pull_e_strength, _pull_e_strength) + pull_u_center_strength = fill_strength( + pull_u_center_strength, _pull_u_center_strength + ) + pull_v_center_strength = fill_strength( + pull_v_center_strength, _pull_v_center_strength + ) + + return ( + push_u_strength, + push_v_strength, + push_e_strength, + pull_e_strength, + pull_u_center_strength, + pull_v_center_strength, + ) + + +def default_hypergraph_strength( + num_v: int, + e_list: List[tuple], + push_v_strength: float = 1.0, + push_e_strength: float = 1.0, + pull_e_strength: float = 1.0, + pull_center_strength: float = 1.0, +): + _push_v_strength = 0.006 + _push_e_strength = 0.008 + _pull_e_strength = 0.007 + _pull_center_strength = 0.001 + + push_v_strength = fill_strength(push_v_strength, _push_v_strength) + push_e_strength = fill_strength(push_e_strength, _push_e_strength) + pull_e_strength = fill_strength(pull_e_strength, _pull_e_strength) + pull_center_strength = fill_strength(pull_center_strength, _pull_center_strength) + + return push_v_strength, push_e_strength, pull_e_strength, pull_center_strength + + +def fill_color( + custom_color: Optional[Union[str, list]], default_color: Any, length: int +): + if custom_color is None: + return [default_color] * length + elif isinstance(custom_color, list): + if ( + isinstance(custom_color[0], str) + or isinstance(custom_color[0], tuple) + or isinstance(custom_color[0], list) + ): + return custom_color + else: + return [custom_color] * length + elif isinstance(custom_color, str): + return [custom_color] * length + else: + raise ValueError("The specified value is not a valid type.") + + +def fill_sizes( + custom_scales: Optional[Union[float, list]], default_value: Any, length: int +): + if custom_scales is None: + return [default_value] * length + elif isinstance(custom_scales, list): + assert ( + len(custom_scales) == length + ), "The specified value list has the wrong length." + return [default_value * scale for scale in custom_scales] + elif isinstance(custom_scales, float): + return [default_value * custom_scales] * length + elif isinstance(custom_scales, int): + return [default_value * float(custom_scales)] * length + else: + raise ValueError("The specified value is not a valid type.") + + +def fill_strength(custom_scale: Optional[float], default_value: float): + if custom_scale is None: + return default_value + return custom_scale * default_value diff --git a/easygraph/functions/drawing/drawing.py b/easygraph/functions/drawing/drawing.py new file mode 100644 index 0000000..fc32317 --- /dev/null +++ b/easygraph/functions/drawing/drawing.py @@ -0,0 +1,1722 @@ +import random + +from copy import deepcopy +from typing import List +from typing import Optional +from typing import Union + +import easygraph as eg + + +__all__ = [ + "draw_SHS_center", + "draw_SHS_center_kk", + "draw_kamada_kawai", + "draw_hypergraph", + "draw_dynamic_hypergraph", + "draw_easygraph_nodes", + "draw_easygraph_edges", + "draw_louvain_com", + "draw_lpa_com", + "draw_gm_com", + "draw_ego_graph", +] + +from easygraph.functions.drawing.defaults import default_hypergraph_strength +from easygraph.functions.drawing.defaults import default_hypergraph_style +from easygraph.functions.drawing.defaults import default_size +from easygraph.functions.drawing.layout import force_layout +from easygraph.functions.drawing.utils import draw_circle_edge +from easygraph.functions.drawing.utils import draw_vertex + + +def draw_hypergraph( + hg: "eg.Hypergraph", + e_style: str = "circle", + v_label: Optional[List[str]] = None, + v_size: Union[float, list] = 1.0, + v_color: Union[str, list] = "r", + v_line_width: Union[str, list] = 1.0, + e_color: Union[str, list] = "gray", + e_fill_color: Union[str, list] = "whitesmoke", + e_line_width: Union[str, list] = 1.0, + font_size: float = 1.0, + font_family: str = "sans-serif", + push_v_strength: float = 1.0, + push_e_strength: float = 1.0, + pull_e_strength: float = 1.0, + pull_center_strength: float = 1.0, +): + r"""Draw the hypergraph structure. + + Args: + ``hg`` (``eg.Hypergraph``): The EasyGraph's hypergraph object. + ``e_style`` (``str``): The style of hyperedges. The available styles are only ``'circle'``. Defaults to ``'circle'``. + ``v_label`` (``list``): The labels of vertices. Defaults to ``None``. + ``v_size`` (``float`` or ``list``): The size of vertices. Defaults to ``1.0``. + ``v_color`` (``str`` or ``list``): The `color `_ of vertices. Defaults to ``'r'``. + ``v_line_width`` (``float`` or ``list``): The line width of vertices. Defaults to ``1.0``. + ``e_color`` (``str`` or ``list``): The `color `_ of hyperedges. Defaults to ``'gray'``. + ``e_fill_color`` (``str`` or ``list``): The fill `color `_ of hyperedges. Defaults to ``'whitesmoke'``. + ``e_line_width`` (``float`` or ``list``): The line width of hyperedges. Defaults to ``1.0``. + ``font_size`` (``float``): The font size of labels. Defaults to ``1.0``. + ``font_family`` (``str``): The font family of labels. Defaults to ``'sans-serif'``. + ``push_v_strength`` (``float``): The strength of pushing vertices. Defaults to ``1.0``. + ``push_e_strength`` (``float``): The strength of pushing hyperedges. Defaults to ``1.0``. + ``pull_e_strength`` (``float``): The strength of pulling hyperedges. Defaults to ``1.0``. + ``pull_center_strength`` (``float``): The strength of pulling vertices to the center. Defaults to ``1.0``. + """ + import matplotlib.pyplot as plt + + assert isinstance( + hg, eg.Hypergraph + ), "The input object must be a EasyGraph's hypergraph object." + assert e_style in ["circle"], "e_style must be 'circle'" + assert hg.num_e > 0, "g must be a non-empty structure" + fig, ax = plt.subplots(figsize=(6, 6)) + + num_v, e_list = hg.num_v, deepcopy(hg.e[0]) + # default configures + v_color, e_color, e_fill_color = default_hypergraph_style( + hg.num_v, hg.num_e, v_color, e_color, e_fill_color + ) + v_size, v_line_width, e_line_width, font_size = default_size( + num_v, e_list, v_size, v_line_width, e_line_width + ) + ( + push_v_strength, + push_e_strength, + pull_e_strength, + pull_center_strength, + ) = default_hypergraph_strength( + num_v, + e_list, + push_v_strength, + push_e_strength, + pull_e_strength, + pull_center_strength, + ) + # layout + v_coor = force_layout( + num_v, + e_list, + push_v_strength, + push_e_strength, + pull_e_strength, + pull_center_strength, + ) + if e_style == "circle": + draw_circle_edge( + ax, + v_coor, + v_size, + e_list, + e_color, + e_fill_color, + e_line_width, + ) + else: + raise ValueError("e_style must be 'circle'") + + draw_vertex( + ax, + v_coor, + v_label, + font_size, + font_family, + v_size, + v_color, + e_color, + v_line_width, + ) + + plt.xlim((0, 1.0)) + plt.ylim((0, 1.0)) + plt.axis("off") + fig.tight_layout() + plt.show() + + +def _draw_single_dynamic_hypergraph( + hg: "eg.Hypergraph", + ax, + title_font_size=4, + group_name: str = "main", + e_style: str = "circle", + v_label: Optional[List[str]] = None, + v_size: Union[float, list] = 2.0, + v_color: Union[str, list] = "r", + v_line_width: Union[str, list] = 1.0, + e_color: Union[str, list] = "gray", + e_fill_color: Union[str, list] = "whitesmoke", + e_line_width: Union[str, list] = 1.0, + font_size: float = 1.0, + font_family: str = "sans-serif", + push_v_strength: float = 1.0, + push_e_strength: float = 1.0, + pull_e_strength: float = 1.0, + pull_center_strength: float = 1.0, +): + import matplotlib.pyplot as plt + + assert isinstance( + hg, eg.Hypergraph + ), "The input object must be a EasyGraph's hypergraph object." + assert e_style in ["circle"], "e_style must be 'circle'" + assert hg.num_e > 0, "g must be a non-empty structure" + + num_v, e_list = hg.num_v, deepcopy(hg.e_of_group(group_name)[0]) + # default configures + v_color, e_color, e_fill_color = default_hypergraph_style( + hg.num_v, hg.num_e, v_color, e_color, e_fill_color + ) + v_size, v_line_width, e_line_width, font_size = default_size( + num_v, e_list, v_size, v_line_width, e_line_width, font_size + ) + + ( + push_v_strength, + push_e_strength, + pull_e_strength, + pull_center_strength, + ) = default_hypergraph_strength( + num_v, + e_list, + push_v_strength, + push_e_strength, + pull_e_strength, + pull_center_strength, + ) + # layout + v_coor = force_layout( + num_v, + e_list, + push_v_strength, + push_e_strength, + pull_e_strength, + pull_center_strength, + ) + if e_style == "circle": + draw_circle_edge( + ax, + v_coor, + v_size, + e_list, + e_color, + e_fill_color, + e_line_width, + ) + else: + raise ValueError("e_style must be 'circle'") + + draw_vertex( + ax, + v_coor, + v_label, + font_size, + font_family, + v_size, + v_color, + v_color, + v_line_width, + ) + plt.title(group_name, fontsize=title_font_size) + plt.xlim((0, 1.0)) + plt.ylim((0, 1.0)) + plt.axis("off") + + +def draw_dynamic_hypergraph( + G, + group_name_list=None, + column_size=None, + save_path=None, + title_font_size=4, + e_style: str = "circle", + v_label: Optional[List[str]] = None, + v_size: Union[float, list] = 2.0, + v_color: Union[str, list] = "r", + v_line_width: Union[str, list] = 1.0, + e_color: Union[str, list] = "gray", + e_fill_color: Union[str, list] = "whitesmoke", + e_line_width: Union[str, list] = 1.0, + font_size: float = 1.0, + font_family: str = "sans-serif", + push_v_strength: float = 1.0, + push_e_strength: float = 1.0, + pull_e_strength: float = 1.0, + pull_center_strength: float = 1.0, +): + """ + + Parameters + ---------- + G eg.Hypergraph + group_name_list The groups to visualize + column_size The number of subplots placed in each row + save_path path to save visualization + title_font_size The font size of tilte of each subplot + + """ + import math + + import matplotlib.pyplot as plt + + # if group_name_list == None: + # group_name_list = G.group_names + COLUMN_SIZE = 3 if column_size == None else column_size + ROW_SIZE = math.ceil(len(group_name_list) / COLUMN_SIZE) + fig = plt.figure() + + sub = 1 + for gn in group_name_list: + if sub > len(group_name_list): + break + tmp_ax = fig.add_subplot(ROW_SIZE, COLUMN_SIZE, sub) + _draw_single_dynamic_hypergraph( + G, + ax=tmp_ax, + group_name=gn, + title_font_size=title_font_size, + e_style=e_style, + v_label=v_label, + v_size=v_size, + v_color=v_color, + v_line_width=v_line_width, + e_color=e_color, + e_fill_color=e_fill_color, + e_line_width=e_line_width, + font_size=font_size, + font_family=font_family, + push_v_strength=push_v_strength, + push_e_strength=push_e_strength, + pull_e_strength=pull_e_strength, + pull_center_strength=pull_center_strength, + ) + sub += 1 + fig.tight_layout() + if save_path is not None: + plt.savefig(save_path) + plt.show() + + +def draw_easygraph_nodes( + G, + pos, + nodelist=None, + node_size=300, + node_color="#1f78b4", + node_shape="o", + alpha=None, + cmap=None, + vmin=None, + vmax=None, + ax=None, + linewidths=None, + edgecolors=None, + label=None, + margins=None, +): + """Draw the nodes of the graph G. + + This draws only the nodes of the graph G. + + Parameters + ---------- + G : graph + A EasyGraph graph + + pos : dictionary + A dictionary with nodes as keys and positions as values. + Positions should be sequences of length 2. + + ax : Matplotlib Axes object, optional + Draw the graph in the specified Matplotlib axes. + + nodelist : list (default list(G)) + Draw only specified nodes + + node_size : scalar or array (default=300) + Size of nodes. If an array it must be the same length as nodelist. + + node_color : color or array of colors (default='#1f78b4') + Node color. Can be a single color or a sequence of colors with the same + length as nodelist. Color can be string or rgb (or rgba) tuple of + floats from 0-1. If numeric values are specified they will be + mapped to colors using the cmap and vmin,vmax parameters. See + matplotlib.scatter for more details. + + node_shape : string (default='o') + The shape of the node. Specification is as matplotlib.scatter + marker, one of 'so^>v>> G = eg.dodecahedral_graph() + >>> nodes = eg.draw_easygraph_nodes(G, pos=eg.spring_layout(G)) + + + + """ + from collections.abc import Iterable + + import matplotlib as mpl + import matplotlib.collections # call as mpl.collections + import matplotlib.pyplot as plt + import numpy as np + + if ax is None: + ax = plt.gca() + + if nodelist is None: + nodelist = list(G) + + if len(nodelist) == 0: # empty nodelist, no drawing + return mpl.collections.PathCollection(None) + + try: + xy = np.asarray([pos[v] for v in nodelist]) + except KeyError as err: + raise eg.EasygraphError(f"Node {err} has no position.") from err + + if isinstance(alpha, Iterable): + node_color = apply_alpha(node_color, alpha, nodelist, cmap, vmin, vmax) + alpha = None + + node_collection = ax.scatter( + xy[:, 0], + xy[:, 1], + s=node_size, + c=node_color, + marker=node_shape, + cmap=cmap, + vmin=vmin, + vmax=vmax, + alpha=alpha, + linewidths=linewidths, + edgecolors=edgecolors, + label=label, + ) + ax.tick_params( + axis="both", + which="both", + bottom=False, + left=False, + labelbottom=False, + labelleft=False, + ) + + if margins is not None: + if isinstance(margins, Iterable): + ax.margins(*margins) + else: + ax.margins(margins) + + node_collection.set_zorder(2) + return node_collection + + +def draw_easygraph_edges( + G, + pos, + edgelist=None, + width=1.0, + edge_color="k", + style="solid", + alpha=None, + arrowstyle=None, + arrowsize=10, + edge_cmap=None, + edge_vmin=None, + edge_vmax=None, + ax=None, + arrows=None, + label=None, + node_size=300, + nodelist=None, + node_shape="o", + connectionstyle="arc3", + min_source_margin=0, + min_target_margin=0, +): + r"""Draw the edges of the graph G. + + This draws only the edges of the graph G. + + Parameters + ---------- + G : graph + A easygraph graph + + pos : dictionary + A dictionary with nodes as keys and positions as values. + Positions should be sequences of length 2. + + edgelist : collection of edge tuples (default=G.edges()) + Draw only specified edges + + width : float or array of floats (default=1.0) + Line width of edges + + edge_color : color or array of colors (default='k') + Edge color. Can be a single color or a sequence of colors with the same + length as edgelist. Color can be string or rgb (or rgba) tuple of + floats from 0-1. If numeric values are specified they will be + mapped to colors using the edge_cmap and edge_vmin,edge_vmax parameters. + + style : string or array of strings (default='solid') + Edge line style e.g.: '-', '--', '-.', ':' + or words like 'solid' or 'dashed'. + Can be a single style or a sequence of styles with the same + length as the edge list. + If less styles than edges are given the styles will cycle. + If more styles than edges are given the styles will be used sequentially + and not be exhausted. + Also, `(offset, onoffseq)` tuples can be used as style instead of a strings. + (See `matplotlib.patches.FancyArrowPatch`: `linestyle`) + + alpha : float or array of floats (default=None) + The edge transparency. This can be a single alpha value, + in which case it will be applied to all specified edges. Otherwise, + if it is an array, the elements of alpha will be applied to the colors + in order (cycling through alpha multiple times if necessary). + + edge_cmap : Matplotlib colormap, optional + Colormap for mapping intensities of edges + + edge_vmin,edge_vmax : floats, optional + Minimum and maximum for edge colormap scaling + + ax : Matplotlib Axes object, optional + Draw the graph in the specified Matplotlib axes. + + arrows : bool or None, optional (default=None) + If `None`, directed graphs draw arrowheads with + `~matplotlib.patches.FancyArrowPatch`, while undirected graphs draw edges + via `~matplotlib.collections.LineCollection` for speed. + If `True`, draw arrowheads with FancyArrowPatches (bendable and stylish). + If `False`, draw edges using LineCollection (linear and fast). + + Note: Arrowheads will be the same color as edges. + + arrowstyle : str (default='-\|>' for directed graphs) + For directed graphs and `arrows==True` defaults to '-\|>', + For undirected graphs default to '-'. + + See `matplotlib.patches.ArrowStyle` for more options. + + arrowsize : int (default=10) + For directed graphs, choose the size of the arrow head's length and + width. See `matplotlib.patches.FancyArrowPatch` for attribute + `mutation_scale` for more info. + + connectionstyle : string (default="arc3") + Pass the connectionstyle parameter to create curved arc of rounding + radius rad. For example, connectionstyle='arc3,rad=0.2'. + See `matplotlib.patches.ConnectionStyle` and + `matplotlib.patches.FancyArrowPatch` for more info. + + node_size : scalar or array (default=300) + Size of nodes. Though the nodes are not drawn with this function, the + node size is used in determining edge positioning. + + nodelist : list, optional (default=G.nodes()) + This provides the node order for the `node_size` array (if it is an array). + + node_shape : string (default='o') + The marker used for nodes, used in determining edge positioning. + Specification is as a `matplotlib.markers` marker, e.g. one of 'so^>vv