├── .cookiecutter.json ├── .darglint ├── .flake8 ├── .gitattributes ├── .github ├── labels.yml ├── release-drafter.yml └── workflows │ ├── constraints.txt │ ├── labeler.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── README_pypi.md ├── codecov.yml ├── docs ├── codeofconduct.md ├── conf.py ├── contributing.md ├── index.md ├── license.md ├── reference.md ├── requirements.txt └── usage.md ├── graphcompass_logo.jpg ├── graphcompass_logo.pdf ├── notebooks ├── cellular_neighborhoods │ ├── 01_neighborhood_enrichment_mibitof.ipynb │ ├── 02_lm_models_mibitof_python.ipynb │ └── 02_lm_models_mibitof_r.ipynb ├── filtration_curves │ ├── filtration_curves_mibitof.ipynb │ └── filtration_curves_visium.ipynb ├── portrait │ ├── portrait_mibitof.ipynb │ ├── portrait_stereoseq.ipynb │ └── portrait_visium.ipynb ├── processing │ └── processing_mibitof.ipynb ├── tutorials │ └── MIBITOF_breast_cancer.ipynb └── wlkernel │ ├── wlkernel_mibitof.ipynb │ ├── wlkernel_stereoseq.ipynb │ └── wlkernel_visium.ipynb ├── noxfile.py ├── pyproject.toml ├── src └── graphcompass │ ├── __init__.py │ ├── __main__.py │ ├── datasets │ ├── __init__.py │ ├── _dataset.py │ └── _dataset.pyi │ ├── imports │ └── wwl_package │ │ ├── __init__.py │ │ ├── propagation_scheme.py │ │ └── wwl.py │ ├── pl │ ├── _WLkernel.py │ ├── __init__.py │ ├── _distance.py │ ├── _filtration_curves.py │ └── utils.py │ ├── py.typed │ └── tl │ ├── _WLkernel.py │ ├── __init__.py │ ├── _distance.py │ ├── _filtration_curves.py │ └── utils.py └── tests ├── __init__.py └── test_main.py /.cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "_output_dir": "/Users/mayar.ali/Documents/phd/projects/hackthon/git", 3 | "_repo_dir": "/Users/mayar.ali/.cookiecutters/cookiecutter-hypermodern-python", 4 | "_template": "gh:cjolowicz/cookiecutter-hypermodern-python", 5 | "author": "Mayar Ali and Merel Kuijs", 6 | "copyright_year": "2024", 7 | "development_status": "Development Status :: 1 - Planning", 8 | "email": "mayar.ali@helmholtz-munich.de, merelsentina.kuijs@helmholtz-munich.de", 9 | "friendly_name": "Graph-COMPASS", 10 | "github_user": "theislab", 11 | "license": "MIT", 12 | "package_name": "graphcompass", 13 | "project_name": "graphcompass", 14 | "version": "0.0.0" 15 | } 16 | -------------------------------------------------------------------------------- /.darglint: -------------------------------------------------------------------------------- 1 | [darglint] 2 | strictness = long 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | select = B,B9,C,D,DAR,E,F,N,RST,S,W 3 | ignore = E203,E501,RST201,RST203,RST301,W503 4 | max-line-length = 80 5 | max-complexity = 10 6 | docstring-convention = google 7 | per-file-ignores = tests/*:S101 8 | rst-roles = class,const,func,meth,mod,ref 9 | rst-directives = deprecated 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Labels names are important as they are used by Release Drafter to decide 3 | # regarding where to record them in changelog or if to skip them. 4 | # 5 | # The repository labels will be automatically configured using this file and 6 | # the GitHub Action https://github.com/marketplace/actions/github-labeler. 7 | - name: breaking 8 | description: Breaking Changes 9 | color: bfd4f2 10 | - name: bug 11 | description: Something isn't working 12 | color: d73a4a 13 | - name: build 14 | description: Build System and Dependencies 15 | color: bfdadc 16 | - name: ci 17 | description: Continuous Integration 18 | color: 4a97d6 19 | - name: dependencies 20 | description: Pull requests that update a dependency file 21 | color: 0366d6 22 | - name: documentation 23 | description: Improvements or additions to documentation 24 | color: 0075ca 25 | - name: duplicate 26 | description: This issue or pull request already exists 27 | color: cfd3d7 28 | - name: enhancement 29 | description: New feature or request 30 | color: a2eeef 31 | - name: github_actions 32 | description: Pull requests that update Github_actions code 33 | color: "000000" 34 | - name: good first issue 35 | description: Good for newcomers 36 | color: 7057ff 37 | - name: help wanted 38 | description: Extra attention is needed 39 | color: 008672 40 | - name: invalid 41 | description: This doesn't seem right 42 | color: e4e669 43 | - name: performance 44 | description: Performance 45 | color: "016175" 46 | - name: python 47 | description: Pull requests that update Python code 48 | color: 2b67c6 49 | - name: question 50 | description: Further information is requested 51 | color: d876e3 52 | - name: refactoring 53 | description: Refactoring 54 | color: ef67c4 55 | - name: removal 56 | description: Removals and Deprecations 57 | color: 9ae7ea 58 | - name: style 59 | description: Style 60 | color: c120e5 61 | - name: testing 62 | description: Testing 63 | color: b1fc6f 64 | - name: wontfix 65 | description: This will not be worked on 66 | color: ffffff 67 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: ":boom: Breaking Changes" 3 | label: "breaking" 4 | - title: ":rocket: Features" 5 | label: "enhancement" 6 | - title: ":fire: Removals and Deprecations" 7 | label: "removal" 8 | - title: ":beetle: Fixes" 9 | label: "bug" 10 | - title: ":racehorse: Performance" 11 | label: "performance" 12 | - title: ":rotating_light: Testing" 13 | label: "testing" 14 | - title: ":construction_worker: Continuous Integration" 15 | label: "ci" 16 | - title: ":books: Documentation" 17 | label: "documentation" 18 | - title: ":hammer: Refactoring" 19 | label: "refactoring" 20 | - title: ":lipstick: Style" 21 | label: "style" 22 | - title: ":package: Dependencies" 23 | labels: 24 | - "dependencies" 25 | - "build" 26 | template: | 27 | ## Changes 28 | 29 | $CHANGES 30 | -------------------------------------------------------------------------------- /.github/workflows/constraints.txt: -------------------------------------------------------------------------------- 1 | pip==22.1.2 2 | nox==2022.1.7 3 | nox-poetry==1.0.0 4 | poetry==1.1.13 5 | virtualenv==20.14.1 6 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Labeler 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | labeler: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repository 14 | uses: actions/checkout@v3 15 | 16 | - name: Run Labeler 17 | uses: crazy-max/ghaction-github-labeler@v4.0.0 18 | with: 19 | skip-delete: true 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | release: 11 | name: Release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out the repository 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 2 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: "3.10" 23 | 24 | - name: Upgrade pip 25 | run: | 26 | pip install --constraint=.github/workflows/constraints.txt pip 27 | pip --version 28 | 29 | - name: Install Poetry 30 | run: | 31 | pip install --constraint=.github/workflows/constraints.txt poetry 32 | poetry --version 33 | 34 | - name: Check if there is a parent commit 35 | id: check-parent-commit 36 | run: | 37 | echo "::set-output name=sha::$(git rev-parse --verify --quiet HEAD^)" 38 | 39 | - name: Detect and tag new version 40 | id: check-version 41 | if: steps.check-parent-commit.outputs.sha 42 | uses: salsify/action-detect-and-tag-new-version@v2.0.1 43 | with: 44 | version-command: | 45 | bash -o pipefail -c "poetry version | awk '{ print \$2 }'" 46 | 47 | - name: Bump version for developmental release 48 | if: "! steps.check-version.outputs.tag" 49 | run: | 50 | poetry version patch && 51 | version=$(poetry version | awk '{ print $2 }') && 52 | poetry version $version.dev.$(date +%s) 53 | 54 | - name: Build package 55 | run: | 56 | poetry build --ansi 57 | 58 | - name: Publish package on PyPI 59 | if: steps.check-version.outputs.tag 60 | uses: pypa/gh-action-pypi-publish@v1.5.0 61 | with: 62 | user: __token__ 63 | password: ${{ secrets.PYPI_TOKEN }} 64 | 65 | - name: Publish package on TestPyPI 66 | if: "! steps.check-version.outputs.tag" 67 | uses: pypa/gh-action-pypi-publish@v1.5.0 68 | with: 69 | user: __token__ 70 | password: ${{ secrets.TEST_PYPI_TOKEN }} 71 | repository_url: https://test.pypi.org/legacy/ 72 | 73 | - name: Publish the release notes 74 | uses: release-drafter/release-drafter@v5.20.0 75 | with: 76 | publish: ${{ steps.check-version.outputs.tag != '' }} 77 | tag: ${{ steps.check-version.outputs.tag }} 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | tests: 9 | name: ${{ matrix.session }} ${{ matrix.python }} / ${{ matrix.os }} 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - { python: "3.10", os: "ubuntu-latest", session: "pre-commit" } 16 | - { python: "3.10", os: "ubuntu-latest", session: "safety" } 17 | - { python: "3.10", os: "ubuntu-latest", session: "mypy" } 18 | - { python: "3.9", os: "ubuntu-latest", session: "mypy" } 19 | - { python: "3.8", os: "ubuntu-latest", session: "mypy" } 20 | - { python: "3.7", os: "ubuntu-latest", session: "mypy" } 21 | - { python: "3.10", os: "ubuntu-latest", session: "tests" } 22 | - { python: "3.9", os: "ubuntu-latest", session: "tests" } 23 | - { python: "3.8", os: "ubuntu-latest", session: "tests" } 24 | - { python: "3.7", os: "ubuntu-latest", session: "tests" } 25 | - { python: "3.10", os: "windows-latest", session: "tests" } 26 | - { python: "3.10", os: "macos-latest", session: "tests" } 27 | - { python: "3.10", os: "ubuntu-latest", session: "typeguard" } 28 | - { python: "3.10", os: "ubuntu-latest", session: "xdoctest" } 29 | - { python: "3.10", os: "ubuntu-latest", session: "docs-build" } 30 | 31 | env: 32 | NOXSESSION: ${{ matrix.session }} 33 | FORCE_COLOR: "1" 34 | PRE_COMMIT_COLOR: "always" 35 | 36 | steps: 37 | - name: Check out the repository 38 | uses: actions/checkout@v3 39 | 40 | - name: Set up Python ${{ matrix.python }} 41 | uses: actions/setup-python@v3 42 | with: 43 | python-version: ${{ matrix.python }} 44 | 45 | - name: Upgrade pip 46 | run: | 47 | pip install --constraint=.github/workflows/constraints.txt pip 48 | pip --version 49 | 50 | - name: Upgrade pip in virtual environments 51 | shell: python 52 | run: | 53 | import os 54 | import pip 55 | 56 | with open(os.environ["GITHUB_ENV"], mode="a") as io: 57 | print(f"VIRTUALENV_PIP={pip.__version__}", file=io) 58 | 59 | - name: Install Poetry 60 | run: | 61 | pipx install --pip-args=--constraint=.github/workflows/constraints.txt poetry 62 | poetry --version 63 | 64 | - name: Install Nox 65 | run: | 66 | pipx install --pip-args=--constraint=.github/workflows/constraints.txt nox 67 | pipx inject --pip-args=--constraint=.github/workflows/constraints.txt nox nox-poetry 68 | nox --version 69 | 70 | - name: Compute pre-commit cache key 71 | if: matrix.session == 'pre-commit' 72 | id: pre-commit-cache 73 | shell: python 74 | run: | 75 | import hashlib 76 | import sys 77 | 78 | python = "py{}.{}".format(*sys.version_info[:2]) 79 | payload = sys.version.encode() + sys.executable.encode() 80 | digest = hashlib.sha256(payload).hexdigest() 81 | result = "${{ runner.os }}-{}-{}-pre-commit".format(python, digest[:8]) 82 | 83 | print("::set-output name=result::{}".format(result)) 84 | 85 | - name: Restore pre-commit cache 86 | uses: actions/cache@v3 87 | if: matrix.session == 'pre-commit' 88 | with: 89 | path: ~/.cache/pre-commit 90 | key: ${{ steps.pre-commit-cache.outputs.result }}-${{ hashFiles('.pre-commit-config.yaml') }} 91 | restore-keys: | 92 | ${{ steps.pre-commit-cache.outputs.result }}- 93 | 94 | - name: Run Nox 95 | run: | 96 | nox --python=${{ matrix.python }} 97 | 98 | - name: Upload coverage data 99 | if: always() && matrix.session == 'tests' 100 | uses: "actions/upload-artifact@v3" 101 | with: 102 | name: coverage-data 103 | path: ".coverage.*" 104 | 105 | - name: Upload documentation 106 | if: matrix.session == 'docs-build' 107 | uses: actions/upload-artifact@v3 108 | with: 109 | name: docs 110 | path: docs/_build 111 | 112 | coverage: 113 | runs-on: ubuntu-latest 114 | needs: tests 115 | steps: 116 | - name: Check out the repository 117 | uses: actions/checkout@v3 118 | 119 | - name: Set up Python 120 | uses: actions/setup-python@v3 121 | with: 122 | python-version: "3.10" 123 | 124 | - name: Upgrade pip 125 | run: | 126 | pip install --constraint=.github/workflows/constraints.txt pip 127 | pip --version 128 | 129 | - name: Install Poetry 130 | run: | 131 | pipx install --pip-args=--constraint=.github/workflows/constraints.txt poetry 132 | poetry --version 133 | 134 | - name: Install Nox 135 | run: | 136 | pipx install --pip-args=--constraint=.github/workflows/constraints.txt nox 137 | pipx inject --pip-args=--constraint=.github/workflows/constraints.txt nox nox-poetry 138 | nox --version 139 | 140 | - name: Download coverage data 141 | uses: actions/download-artifact@v3 142 | with: 143 | name: coverage-data 144 | 145 | - name: Combine coverage data and display human readable report 146 | run: | 147 | nox --session=coverage 148 | 149 | - name: Create coverage report 150 | run: | 151 | nox --session=coverage -- xml 152 | 153 | - name: Upload coverage report 154 | uses: codecov/codecov-action@v3.1.0 155 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS files 2 | **/.DS_Store 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: black 5 | name: black 6 | entry: black 7 | language: system 8 | types: [python] 9 | require_serial: true 10 | - id: check-added-large-files 11 | name: Check for added large files 12 | entry: check-added-large-files 13 | language: system 14 | - id: check-toml 15 | name: Check Toml 16 | entry: check-toml 17 | language: system 18 | types: [toml] 19 | - id: check-yaml 20 | name: Check Yaml 21 | entry: check-yaml 22 | language: system 23 | types: [yaml] 24 | - id: darglint 25 | name: darglint 26 | entry: darglint 27 | language: system 28 | types: [python] 29 | stages: [manual] 30 | - id: end-of-file-fixer 31 | name: Fix End of Files 32 | entry: end-of-file-fixer 33 | language: system 34 | types: [text] 35 | stages: [commit, push, manual] 36 | - id: flake8 37 | name: flake8 38 | entry: flake8 39 | language: system 40 | types: [python] 41 | require_serial: true 42 | args: [--darglint-ignore-regex, .*] 43 | - id: isort 44 | name: isort 45 | entry: isort 46 | require_serial: true 47 | language: system 48 | types_or: [cython, pyi, python] 49 | args: ["--filter-files"] 50 | - id: pyupgrade 51 | name: pyupgrade 52 | description: Automatically upgrade syntax for newer versions. 53 | entry: pyupgrade 54 | language: system 55 | types: [python] 56 | args: [--py37-plus] 57 | - id: trailing-whitespace 58 | name: Trim Trailing Whitespace 59 | entry: trailing-whitespace-fixer 60 | language: system 61 | types: [text] 62 | stages: [commit, push, manual] 63 | - repo: https://github.com/pre-commit/mirrors-prettier 64 | rev: v2.6.0 65 | hooks: 66 | - id: prettier 67 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-20.04 4 | tools: 5 | python: "3.10" 6 | sphinx: 7 | configuration: docs/conf.py 8 | formats: all 9 | python: 10 | install: 11 | - requirements: docs/requirements.txt 12 | - path: . 13 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [mayar.ali@helmholtz-munich.de, merelsentina.kuijs@helmholtz-munich.de](mailto:mayar.ali@helmholtz-munich.de, merelsentina.kuijs@helmholtz-munich.de). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][faq]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [mozilla coc]: https://github.com/mozilla/diversity 131 | [faq]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor Guide 2 | 3 | Thank you for your interest in improving this project. 4 | This project is open-source under the [MIT license] and 5 | welcomes contributions in the form of bug reports, feature requests, and pull requests. 6 | 7 | Here is a list of important resources for contributors: 8 | 9 | - [Source Code] 10 | - [Documentation] 11 | - [Issue Tracker] 12 | - [Code of Conduct] 13 | 14 | [mit license]: https://opensource.org/licenses/MIT 15 | [source code]: https://github.com/theislab/graphcompass 16 | [documentation]: https://graphcompass.readthedocs.io/ 17 | [issue tracker]: https://github.com/theislab/graphcompass/issues 18 | 19 | ## How to report a bug 20 | 21 | Report bugs on the [Issue Tracker]. 22 | 23 | When filing an issue, make sure to answer these questions: 24 | 25 | - Which operating system and Python version are you using? 26 | - Which version of this project are you using? 27 | - What did you do? 28 | - What did you expect to see? 29 | - What did you see instead? 30 | 31 | The best way to get your bug fixed is to provide a test case, 32 | and/or steps to reproduce the issue. 33 | 34 | ## How to request a feature 35 | 36 | Request features on the [Issue Tracker]. 37 | 38 | ## How to set up your development environment 39 | 40 | You need Python 3.7+ and the following tools: 41 | 42 | - [Poetry] 43 | - [Nox] 44 | - [nox-poetry] 45 | 46 | Install the package with development requirements: 47 | 48 | ```console 49 | $ poetry install 50 | ``` 51 | 52 | You can now run an interactive Python session, 53 | or the command-line interface: 54 | 55 | ```console 56 | $ poetry run python 57 | $ poetry run graphcompass 58 | ``` 59 | 60 | [poetry]: https://python-poetry.org/ 61 | [nox]: https://nox.thea.codes/ 62 | [nox-poetry]: https://nox-poetry.readthedocs.io/ 63 | 64 | ## How to test the project 65 | 66 | Run the full test suite: 67 | 68 | ```console 69 | $ nox 70 | ``` 71 | 72 | List the available Nox sessions: 73 | 74 | ```console 75 | $ nox --list-sessions 76 | ``` 77 | 78 | You can also run a specific Nox session. 79 | For example, invoke the unit test suite like this: 80 | 81 | ```console 82 | $ nox --session=tests 83 | ``` 84 | 85 | Unit tests are located in the _tests_ directory, 86 | and are written using the [pytest] testing framework. 87 | 88 | [pytest]: https://pytest.readthedocs.io/ 89 | 90 | ## How to submit changes 91 | 92 | Open a [pull request] to submit changes to this project. 93 | 94 | Your pull request needs to meet the following guidelines for acceptance: 95 | 96 | - The Nox test suite must pass without errors and warnings. 97 | - Include unit tests. This project maintains 100% code coverage. 98 | - If your changes add functionality, update the documentation accordingly. 99 | 100 | Feel free to submit early, though—we can always iterate on this. 101 | 102 | To run linting and code formatting checks before committing your change, you can install pre-commit as a Git hook by running the following command: 103 | 104 | ```console 105 | $ nox --session=pre-commit -- install 106 | ``` 107 | 108 | It is recommended to open an issue before starting work on anything. 109 | This will allow a chance to talk it over with the owners and validate your approach. 110 | 111 | [pull request]: https://github.com/theislab/graphcompass/pulls 112 | 113 | 114 | 115 | [code of conduct]: CODE_OF_CONDUCT.md 116 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2024 Mayar Ali and Merel Kuijs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GraphCompass](graphcompass_logo.jpg) 2 | GraphCompass (**Graph** **Comp**arison Tools for Differential **A**nalyses in **S**patial **S**ystems) is a Python-based framework that brings together a robust suite of graph analysis and visualization methods, specifically tailored for the differential analysis of cell spatial organization using spatial omics data. 3 | 4 | It is developed on top on [`Squidpy`](https://github.com/scverse/squidpy/) and [`AnnData`](https://github.com/scverse/anndata). 5 | 6 | ## Features 7 | GraphCompass provides differential analysis methods to study spatial organization across conditions at three levels of abstraction: 8 | 1. Cell-type-specific subgraphs: 9 | 10 | i. Portrait method, 11 | 12 | ii. Diffusion method. 13 | 3. Cellular neighborhoods: 14 | 15 | i. GLMs for neighborhood enrichment analysis. 16 | 4. Entire graphs: 17 | 18 | i. Wasserstein WL kernel, 19 | 20 | ii. Filtration curves. 21 | 22 | Tutorials for the different methods can be found in the `notebooks` folder. 23 | 24 | 25 | ## Requirements 26 | You will find all the necessary dependencies in the `pyproject.toml` file. Dependencies are automatically installed when you install GraphCompass. 27 | 28 | ## Installation 29 | You can install _GraphCompass_ via [pip] from [PyPI](https://pypi.org/project/graphcompass/). 30 | ```console 31 | $ pip install graphcompass 32 | ``` 33 | 34 | or 35 | you can install _GraphCompass_ by cloning the repository and running: 36 | ```console 37 | $ pip install -e . 38 | ``` 39 | 40 | 41 | 42 | ## Citation 43 | 44 | Mayar Ali, Merel Kuijs, Soroor Hediyeh-zadeh, Tim Treis, Karin Hrovatin, Giovanni Palla, Anna C Schaar, Fabian J Theis, GraphCompass: spatial metrics for differential analyses of cell organization across conditions, *Bioinformatics*, Volume 40, Issue Supplement\_1, July 2024, Pages i548–i557, https://doi.org/10.1093/bioinformatics/btae242 45 | 46 | 47 | ## Contributing 48 | 49 | **[COMING SOON]** Contributions are very welcome. 50 | To learn more, see the [Contributor Guide]. 51 | 52 | ## License 53 | 54 | Distributed under the terms of the [MIT license][license], 55 | _GraphCompass_ is free and open source software. 56 | 57 | ## Issues 58 | 59 | If you encounter any problems, 60 | please [file an issue] along with a detailed description. 61 | 62 | ## Credits 63 | 64 | This project was generated from [@cjolowicz]'s [Hypermodern Python Cookiecutter] template. 65 | 66 | [@cjolowicz]: https://github.com/cjolowicz 67 | [pypi]: https://pypi.org/ 68 | [hypermodern python cookiecutter]: https://github.com/cjolowicz/cookiecutter-hypermodern-python 69 | [file an issue]: https://github.com/theislab/graphcompass/issues 70 | [pip]: https://pip.pypa.io/ 71 | 72 | 73 | 74 | [license]: https://github.com/theislab/graphcompass/blob/main/LICENSE 75 | [COMING SOON] [contributor guide]: https://github.com/theislab/graphcompass/blob/main/CONTRIBUTING.md 76 | -------------------------------------------------------------------------------- /README_pypi.md: -------------------------------------------------------------------------------- 1 | [![PyPI](https://img.shields.io/pypi/v/graphcompass.svg)](https://pypi.org/project/graphcompass/) 2 | 3 | # GraphCompass 4 | GraphCompass (**Graph** **Comp**arison Tools for Differential **A**nalyses in **S**patial **S**ystems) is a Python-based framework that brings together a robust suite of graph analysis and visualization methods, specifically tailored for the differential analysis of cell spatial organization using spatial omics data. It is developed on top on [`Squidpy`](https://github.com/scverse/squidpy/) and [`AnnData`](https://github.com/scverse/anndata). 5 | 6 | Visit our [`repository`](https://github.com/theislab/graphcompass/) for documentation and tutorials. 7 | 8 | ## GraphCompass key analysis features include: 9 | 10 | - Cell-type-specific subgraphs comparison. 11 | - Cellular neighborhoods comparison. 12 | - Entire graphs comparison. 13 | 14 | ## Installation 15 | Install GraphCompass via PyPI by running: 16 | 17 | pip install graphcompass 18 | 19 | ## Contributing to GraphCompass 20 | We are happy to collaborate. If you want to contribute to GraphCompass, head over to our GitHub repository and open an issue to discuss what you would like to change. 21 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | coverage: 3 | status: 4 | project: 5 | default: 6 | target: "100" 7 | patch: 8 | default: 9 | target: "100" 10 | -------------------------------------------------------------------------------- /docs/codeofconduct.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CODE_OF_CONDUCT.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Sphinx configuration.""" 2 | project = "Graph-COMPASS" 3 | author = "Mayar Ali and Merel Kuijs" 4 | copyright = "2024, Mayar Ali and Merel Kuijs" 5 | extensions = [ 6 | "sphinx.ext.autodoc", 7 | "sphinx.ext.napoleon", 8 | "sphinx_click", 9 | "myst_parser", 10 | ] 11 | autodoc_typehints = "description" 12 | html_theme = "furo" 13 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CONTRIBUTING.md 2 | --- 3 | end-before: 4 | --- 5 | ``` 6 | 7 | [code of conduct]: codeofconduct 8 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ```{include} ../README.md 2 | --- 3 | end-before: 4 | --- 5 | ``` 6 | 7 | [license]: license 8 | [contributor guide]: contributing 9 | [command-line reference]: usage 10 | 11 | ```{toctree} 12 | --- 13 | hidden: 14 | maxdepth: 1 15 | --- 16 | 17 | usage 18 | reference 19 | contributing 20 | Code of Conduct 21 | License 22 | Changelog 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | ```{literalinclude} ../LICENSE 4 | --- 5 | language: none 6 | --- 7 | ``` 8 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | # Reference 2 | 3 | ## graphcompass 4 | 5 | ```{eval-rst} 6 | .. automodule:: graphcompass 7 | :members: 8 | ``` 9 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | furo==2022.4.7 2 | sphinx==4.5.0 3 | sphinx-click==4.1.0 4 | myst_parser==0.17.2 5 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ```{eval-rst} 4 | .. click:: graphcompass.__main__:main 5 | :prog: graphcompass 6 | :nested: full 7 | ``` 8 | -------------------------------------------------------------------------------- /graphcompass_logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theislab/graphcompass/8bdd09bb0bbb3590ab2ff78ad17ee5056bae3fb6/graphcompass_logo.jpg -------------------------------------------------------------------------------- /graphcompass_logo.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theislab/graphcompass/8bdd09bb0bbb3590ab2ff78ad17ee5056bae3fb6/graphcompass_logo.pdf -------------------------------------------------------------------------------- /notebooks/wlkernel/wlkernel_stereoseq.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "a66e0612", 6 | "metadata": {}, 7 | "source": [ 8 | "### Stereo-seq dataset to study the restoration of axolotl brain function upon injury\n", 9 | "\n", 10 | "#### Conditions:\n", 11 | "1. Adult (N=1)\n", 12 | "2. 30 day-post injury (30DPI) (N=1)\n", 13 | "3. 30 day-post injury (60DPI) (N=1)\n", 14 | "\n", 15 | "\n", 16 | "The Stereo-seq axolotl data from Wei et al. is available in the Spatial Transcript Omics DataBase (STOmics DB):\n", 17 | "https://db.cngb.org/stomics/artista/." 18 | ] 19 | }, 20 | { 21 | "cell_type": "code", 22 | "execution_count": 1, 23 | "id": "7216cc95", 24 | "metadata": {}, 25 | "outputs": [], 26 | "source": [ 27 | "%load_ext autoreload\n", 28 | "%autoreload 2" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": 2, 34 | "id": "bacc5f5d", 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "import warnings\n", 39 | "warnings.filterwarnings(\"ignore\")" 40 | ] 41 | }, 42 | { 43 | "cell_type": "code", 44 | "execution_count": 3, 45 | "id": "d33a50ce", 46 | "metadata": {}, 47 | "outputs": [], 48 | "source": [ 49 | "import scanpy as sc\n", 50 | "import graphcompass as gc" 51 | ] 52 | }, 53 | { 54 | "cell_type": "markdown", 55 | "id": "cf9fc01c", 56 | "metadata": {}, 57 | "source": [ 58 | "## Load data" 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": 4, 64 | "id": "c72277f9", 65 | "metadata": {}, 66 | "outputs": [], 67 | "source": [ 68 | "DATA_PATH = \"/data/stereoseq_axolotl/\"" 69 | ] 70 | }, 71 | { 72 | "cell_type": "code", 73 | "execution_count": 5, 74 | "id": "9ec34cab", 75 | "metadata": {}, 76 | "outputs": [], 77 | "source": [ 78 | "adata = sc.read_h5ad(DATA_PATH + \"adata_processed.h5ad\")" 79 | ] 80 | }, 81 | { 82 | "cell_type": "code", 83 | "execution_count": 6, 84 | "id": "c1e964a4", 85 | "metadata": {}, 86 | "outputs": [ 87 | { 88 | "data": { 89 | "text/html": [ 90 | "
\n", 91 | "\n", 104 | "\n", 105 | " \n", 106 | " \n", 107 | " \n", 108 | " \n", 109 | " \n", 110 | " \n", 111 | " \n", 112 | " \n", 113 | " \n", 114 | " \n", 115 | " \n", 116 | " \n", 117 | " \n", 118 | " \n", 119 | " \n", 120 | " \n", 121 | " \n", 122 | " \n", 123 | " \n", 124 | " \n", 125 | " \n", 126 | " \n", 127 | " \n", 128 | " \n", 129 | " \n", 130 | " \n", 131 | " \n", 132 | " \n", 133 | " \n", 134 | " \n", 135 | " \n", 136 | " \n", 137 | " \n", 138 | " \n", 139 | " \n", 140 | " \n", 141 | " \n", 142 | " \n", 143 | " \n", 144 | " \n", 145 | " \n", 146 | " \n", 147 | " \n", 148 | " \n", 149 | " \n", 150 | " \n", 151 | " \n", 152 | " \n", 153 | " \n", 154 | " \n", 155 | " \n", 156 | " \n", 157 | " \n", 158 | " \n", 159 | " \n", 160 | " \n", 161 | " \n", 162 | " \n", 163 | " \n", 164 | " \n", 165 | " \n", 166 | " \n", 167 | " \n", 168 | " \n", 169 | " \n", 170 | " \n", 171 | " \n", 172 | " \n", 173 | " \n", 174 | " \n", 175 | " \n", 176 | " \n", 177 | " \n", 178 | " \n", 179 | " \n", 180 | " \n", 181 | " \n", 182 | " \n", 183 | " \n", 184 | " \n", 185 | " \n", 186 | " \n", 187 | "
CellIDBatchAnnotationcell_idspatial_leiden_e30_s8seurat_clustersinj_uninjD_VbatchStatus
CELL.1-0CELL.1Batch1_Adult_telencephalon_rep2_DP8400015234BL...VLMC1NaNNaNNaNNaN0Adult
CELL.2-0CELL.2Batch1_Adult_telencephalon_rep2_DP8400015234BL...VLMC2NaNNaNNaNNaN0Adult
CELL.3-0CELL.3Batch1_Adult_telencephalon_rep2_DP8400015234BL...VLMC3NaNNaNNaNNaN0Adult
CELL.4-0CELL.4Batch1_Adult_telencephalon_rep2_DP8400015234BL...VLMC4NaNNaNNaNNaN0Adult
CELL.5-0CELL.5Batch1_Adult_telencephalon_rep2_DP8400015234BL...VLMC5NaNNaNNaNNaN0Adult
\n", 188 | "
" 189 | ], 190 | "text/plain": [ 191 | " CellID Batch \\\n", 192 | "CELL.1-0 CELL.1 Batch1_Adult_telencephalon_rep2_DP8400015234BL... \n", 193 | "CELL.2-0 CELL.2 Batch1_Adult_telencephalon_rep2_DP8400015234BL... \n", 194 | "CELL.3-0 CELL.3 Batch1_Adult_telencephalon_rep2_DP8400015234BL... \n", 195 | "CELL.4-0 CELL.4 Batch1_Adult_telencephalon_rep2_DP8400015234BL... \n", 196 | "CELL.5-0 CELL.5 Batch1_Adult_telencephalon_rep2_DP8400015234BL... \n", 197 | "\n", 198 | " Annotation cell_id spatial_leiden_e30_s8 seurat_clusters inj_uninj \\\n", 199 | "CELL.1-0 VLMC 1 NaN NaN NaN \n", 200 | "CELL.2-0 VLMC 2 NaN NaN NaN \n", 201 | "CELL.3-0 VLMC 3 NaN NaN NaN \n", 202 | "CELL.4-0 VLMC 4 NaN NaN NaN \n", 203 | "CELL.5-0 VLMC 5 NaN NaN NaN \n", 204 | "\n", 205 | " D_V batch Status \n", 206 | "CELL.1-0 NaN 0 Adult \n", 207 | "CELL.2-0 NaN 0 Adult \n", 208 | "CELL.3-0 NaN 0 Adult \n", 209 | "CELL.4-0 NaN 0 Adult \n", 210 | "CELL.5-0 NaN 0 Adult " 211 | ] 212 | }, 213 | "execution_count": 6, 214 | "metadata": {}, 215 | "output_type": "execute_result" 216 | } 217 | ], 218 | "source": [ 219 | "adata.obs.head()" 220 | ] 221 | }, 222 | { 223 | "cell_type": "markdown", 224 | "id": "823a3a09", 225 | "metadata": {}, 226 | "source": [ 227 | "## Compute Weisfeiler-Lehman Graph Kernels to compare conditions" 228 | ] 229 | }, 230 | { 231 | "cell_type": "code", 232 | "execution_count": 7, 233 | "id": "e74285b6", 234 | "metadata": {}, 235 | "outputs": [], 236 | "source": [ 237 | "# define library_key and cluster_key for computing spatial graphs using `squidpy.gr.spatial_neighbors`\n", 238 | "\n", 239 | "library_key=\"Batch\"\n", 240 | "cluster_key=\"Annotation\"" 241 | ] 242 | }, 243 | { 244 | "cell_type": "code", 245 | "execution_count": 8, 246 | "id": "7d5937d9", 247 | "metadata": { 248 | "scrolled": true 249 | }, 250 | "outputs": [], 251 | "source": [ 252 | "# compute WWL kernels\n", 253 | "### results are stored in adata.uns[\"wl_kernel\"]\n", 254 | "\n", 255 | "gc.tl.wlkernel.compare_conditions(\n", 256 | " adata=adata,\n", 257 | " library_key=library_key,\n", 258 | " cluster_key=cluster_key,\n", 259 | " compute_spatial_graphs=True,\n", 260 | " kwargs_spatial_neighbors={\n", 261 | " 'coord_type': 'generic',\n", 262 | " 'delaunay': True, \n", 263 | " },\n", 264 | ")" 265 | ] 266 | }, 267 | { 268 | "cell_type": "code", 269 | "execution_count": 9, 270 | "id": "b7c533dc", 271 | "metadata": {}, 272 | "outputs": [ 273 | { 274 | "data": { 275 | "text/plain": [ 276 | "AnnData object with n_obs × n_vars = 28459 × 18611\n", 277 | " obs: 'CellID', 'Batch', 'Annotation', 'cell_id', 'spatial_leiden_e30_s8', 'seurat_clusters', 'inj_uninj', 'D_V', 'batch', 'Status'\n", 278 | " var: 'Gene'\n", 279 | " uns: 'Annotation_Batch_nhood_enrichment', 'pairwise_similarities', 'spatial_neighbors', 'wl_kernel'\n", 280 | " obsm: 'spatial'\n", 281 | " layers: 'counts'\n", 282 | " obsp: 'spatial_connectivities', 'spatial_distances'" 283 | ] 284 | }, 285 | "execution_count": 9, 286 | "metadata": {}, 287 | "output_type": "execute_result" 288 | } 289 | ], 290 | "source": [ 291 | "adata" 292 | ] 293 | }, 294 | { 295 | "cell_type": "markdown", 296 | "id": "3095bdae", 297 | "metadata": {}, 298 | "source": [ 299 | "## Plot results" 300 | ] 301 | }, 302 | { 303 | "cell_type": "code", 304 | "execution_count": 11, 305 | "id": "3fd7fadf", 306 | "metadata": {}, 307 | "outputs": [], 308 | "source": [ 309 | "# define necessary params\n", 310 | "condition_key=\"Status\" # key in adata.obs where conditions are stored\n", 311 | "control_group=\"Injury 60DPI\" # reference group\n", 312 | "metric_key=\"wasserstein_distance\" \n", 313 | "method=\"wl_kernel\"" 314 | ] 315 | }, 316 | { 317 | "cell_type": "code", 318 | "execution_count": 13, 319 | "id": "2fe77076", 320 | "metadata": { 321 | "scrolled": false 322 | }, 323 | "outputs": [ 324 | { 325 | "data": { 326 | "image/png": "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\n", 327 | "text/plain": [ 328 | "
" 329 | ] 330 | }, 331 | "metadata": { 332 | "needs_background": "light" 333 | }, 334 | "output_type": "display_data" 335 | } 336 | ], 337 | "source": [ 338 | "# Note: a smaller Wasserstein distance indicates a higher similarity between the two graphs, \n", 339 | "# while a larger distance indicates less similarity.\n", 340 | "\n", 341 | "# boxplot: in case of multiple samples per condition.\n", 342 | "# barplot: in case of one sample per condition.\n", 343 | "\n", 344 | "gc.pl.wlkernel.compare_conditions(\n", 345 | " adata=adata,\n", 346 | " library_key=library_key,\n", 347 | " condition_key=condition_key,\n", 348 | " control_group=control_group,\n", 349 | " metric_key=metric_key,\n", 350 | " method=method,\n", 351 | " figsize=(5,2),\n", 352 | " dpi=100,\n", 353 | " #save=\"figures/stereoseq_wwlkerenl.pdf\"\n", 354 | ")" 355 | ] 356 | } 357 | ], 358 | "metadata": { 359 | "kernelspec": { 360 | "display_name": "Python 3 (ipykernel)", 361 | "language": "python", 362 | "name": "python3" 363 | }, 364 | "language_info": { 365 | "codemirror_mode": { 366 | "name": "ipython", 367 | "version": 3 368 | }, 369 | "file_extension": ".py", 370 | "mimetype": "text/x-python", 371 | "name": "python", 372 | "nbconvert_exporter": "python", 373 | "pygments_lexer": "ipython3", 374 | "version": "3.10.12" 375 | } 376 | }, 377 | "nbformat": 4, 378 | "nbformat_minor": 5 379 | } 380 | -------------------------------------------------------------------------------- /notebooks/wlkernel/wlkernel_visium.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "72a0e338", 6 | "metadata": {}, 7 | "source": [ 8 | "### 10x Genomics Visium dataset to study myocardial tissue reorganization following ischemic injury\n", 9 | "\n", 10 | "#### Regions:\n", 11 | "1. Control (N=4)\n", 12 | "2. Remote Zone (RZ) (N=5)\n", 13 | "3. Ischaemic Zone (IZ) (N=8)\n", 14 | "\n", 15 | "\n", 16 | "The 10x Genomics Visium heart data from Kuppe et al. is available in the Zenodo data repository: https://zenodo.org/record/6578047." 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": 1, 22 | "id": "7216cc95", 23 | "metadata": {}, 24 | "outputs": [], 25 | "source": [ 26 | "%load_ext autoreload\n", 27 | "%autoreload 2" 28 | ] 29 | }, 30 | { 31 | "cell_type": "code", 32 | "execution_count": 2, 33 | "id": "bacc5f5d", 34 | "metadata": {}, 35 | "outputs": [], 36 | "source": [ 37 | "import warnings\n", 38 | "warnings.filterwarnings(\"ignore\")" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": 3, 44 | "id": "d33a50ce", 45 | "metadata": {}, 46 | "outputs": [], 47 | "source": [ 48 | "import scanpy as sc\n", 49 | "import graphcompass as gc" 50 | ] 51 | }, 52 | { 53 | "cell_type": "markdown", 54 | "id": "8ca25bbd", 55 | "metadata": {}, 56 | "source": [ 57 | "## Load data" 58 | ] 59 | }, 60 | { 61 | "cell_type": "code", 62 | "execution_count": 4, 63 | "id": "c72277f9", 64 | "metadata": {}, 65 | "outputs": [], 66 | "source": [ 67 | "DATA_PATH = \"/data/visium_heart/\"" 68 | ] 69 | }, 70 | { 71 | "cell_type": "code", 72 | "execution_count": 5, 73 | "id": "9ec34cab", 74 | "metadata": {}, 75 | "outputs": [], 76 | "source": [ 77 | "adata = sc.read_h5ad(DATA_PATH + \"adata_processed.h5ad\")" 78 | ] 79 | }, 80 | { 81 | "cell_type": "code", 82 | "execution_count": 6, 83 | "id": "5f45ac0a", 84 | "metadata": {}, 85 | "outputs": [ 86 | { 87 | "data": { 88 | "text/html": [ 89 | "
\n", 90 | "\n", 103 | "\n", 104 | " \n", 105 | " \n", 106 | " \n", 107 | " \n", 108 | " \n", 109 | " \n", 110 | " \n", 111 | " \n", 112 | " \n", 113 | " \n", 114 | " \n", 115 | " \n", 116 | " \n", 117 | " \n", 118 | " \n", 119 | " \n", 120 | " \n", 121 | " \n", 122 | " \n", 123 | " \n", 124 | " \n", 125 | " \n", 126 | " \n", 127 | " \n", 128 | " \n", 129 | " \n", 130 | " \n", 131 | " \n", 132 | " \n", 133 | " \n", 134 | " \n", 135 | " \n", 136 | " \n", 137 | " \n", 138 | " \n", 139 | " \n", 140 | " \n", 141 | " \n", 142 | " \n", 143 | " \n", 144 | " \n", 145 | " \n", 146 | " \n", 147 | " \n", 148 | " \n", 149 | " \n", 150 | " \n", 151 | " \n", 152 | " \n", 153 | " \n", 154 | " \n", 155 | " \n", 156 | " \n", 157 | " \n", 158 | " \n", 159 | " \n", 160 | " \n", 161 | " \n", 162 | " \n", 163 | " \n", 164 | " \n", 165 | " \n", 166 | " \n", 167 | " \n", 168 | " \n", 169 | " \n", 170 | " \n", 171 | " \n", 172 | " \n", 173 | " \n", 174 | " \n", 175 | " \n", 176 | " \n", 177 | " \n", 178 | " \n", 179 | " \n", 180 | " \n", 181 | " \n", 182 | " \n", 183 | " \n", 184 | " \n", 185 | " \n", 186 | " \n", 187 | " \n", 188 | " \n", 189 | " \n", 190 | " \n", 191 | " \n", 192 | " \n", 193 | " \n", 194 | " \n", 195 | " \n", 196 | " \n", 197 | " \n", 198 | " \n", 199 | " \n", 200 | " \n", 201 | " \n", 202 | " \n", 203 | " \n", 204 | " \n", 205 | " \n", 206 | " \n", 207 | " \n", 208 | " \n", 209 | " \n", 210 | " \n", 211 | " \n", 212 | " \n", 213 | " \n", 214 | " \n", 215 | " \n", 216 | " \n", 217 | " \n", 218 | " \n", 219 | " \n", 220 | " \n", 221 | " \n", 222 | " \n", 223 | " \n", 224 | " \n", 225 | " \n", 226 | " \n", 227 | " \n", 228 | " \n", 229 | " \n", 230 | " \n", 231 | " \n", 232 | " \n", 233 | " \n", 234 | " \n", 235 | " \n", 236 | " \n", 237 | " \n", 238 | " \n", 239 | " \n", 240 | " \n", 241 | " \n", 242 | " \n", 243 | " \n", 244 | " \n", 245 | " \n", 246 | " \n", 247 | " \n", 248 | " \n", 249 | " \n", 250 | " \n", 251 | " \n", 252 | "
n_countsn_genespercent.mtAdipocyteCardiomyocyteEndothelialFibroblastLymphoidMastMyeloid...cell_typeassaydiseaseorganismsextissueethnicitydevelopment_stagesamplecondition
AAACAAGTATCTCCCA-14429.0214729.5754650.0015980.4373240.0243560.3183030.0225370.0038580.059224...cardiac muscle myoblastVisium Spatial Gene ExpressionnormalHomo sapiensfemaleheart left ventricleEuropean44-year-old human stagecontrol_P1control
AAACAATCTACTAGCA-13037.0159131.2998980.0004820.7439490.0829480.0896870.0023310.0005620.039163...cardiac muscle myoblastVisium Spatial Gene ExpressionnormalHomo sapiensfemaleheart left ventricleEuropean44-year-old human stagecontrol_P1control
AAACACCAATAACTGC-12507.0146223.9566400.0019740.3752960.1679840.1516150.0041230.0330530.009395...cardiac muscle myoblastVisium Spatial Gene ExpressionnormalHomo sapiensfemaleheart left ventricleEuropean44-year-old human stagecontrol_P1control
AAACAGAGCGACTCCT-12502.0134133.0188680.0001100.6526960.0959140.1545880.0050170.0009770.055517...cardiac muscle myoblastVisium Spatial Gene ExpressionnormalHomo sapiensfemaleheart left ventricleEuropean44-year-old human stagecontrol_P1control
AAACAGCTTTCAGAAG-13054.0161733.6381320.0000840.5336600.1641570.0659990.0044930.0015320.072105...cardiac muscle myoblastVisium Spatial Gene ExpressionnormalHomo sapiensfemaleheart left ventricleEuropean44-year-old human stagecontrol_P1control
\n", 253 | "

5 rows × 34 columns

\n", 254 | "
" 255 | ], 256 | "text/plain": [ 257 | " n_counts n_genes percent.mt Adipocyte Cardiomyocyte \\\n", 258 | "AAACAAGTATCTCCCA-1 4429.0 2147 29.575465 0.001598 0.437324 \n", 259 | "AAACAATCTACTAGCA-1 3037.0 1591 31.299898 0.000482 0.743949 \n", 260 | "AAACACCAATAACTGC-1 2507.0 1462 23.956640 0.001974 0.375296 \n", 261 | "AAACAGAGCGACTCCT-1 2502.0 1341 33.018868 0.000110 0.652696 \n", 262 | "AAACAGCTTTCAGAAG-1 3054.0 1617 33.638132 0.000084 0.533660 \n", 263 | "\n", 264 | " Endothelial Fibroblast Lymphoid Mast Myeloid \\\n", 265 | "AAACAAGTATCTCCCA-1 0.024356 0.318303 0.022537 0.003858 0.059224 \n", 266 | "AAACAATCTACTAGCA-1 0.082948 0.089687 0.002331 0.000562 0.039163 \n", 267 | "AAACACCAATAACTGC-1 0.167984 0.151615 0.004123 0.033053 0.009395 \n", 268 | "AAACAGAGCGACTCCT-1 0.095914 0.154588 0.005017 0.000977 0.055517 \n", 269 | "AAACAGCTTTCAGAAG-1 0.164157 0.065999 0.004493 0.001532 0.072105 \n", 270 | "\n", 271 | " ... cell_type \\\n", 272 | "AAACAAGTATCTCCCA-1 ... cardiac muscle myoblast \n", 273 | "AAACAATCTACTAGCA-1 ... cardiac muscle myoblast \n", 274 | "AAACACCAATAACTGC-1 ... cardiac muscle myoblast \n", 275 | "AAACAGAGCGACTCCT-1 ... cardiac muscle myoblast \n", 276 | "AAACAGCTTTCAGAAG-1 ... cardiac muscle myoblast \n", 277 | "\n", 278 | " assay disease organism \\\n", 279 | "AAACAAGTATCTCCCA-1 Visium Spatial Gene Expression normal Homo sapiens \n", 280 | "AAACAATCTACTAGCA-1 Visium Spatial Gene Expression normal Homo sapiens \n", 281 | "AAACACCAATAACTGC-1 Visium Spatial Gene Expression normal Homo sapiens \n", 282 | "AAACAGAGCGACTCCT-1 Visium Spatial Gene Expression normal Homo sapiens \n", 283 | "AAACAGCTTTCAGAAG-1 Visium Spatial Gene Expression normal Homo sapiens \n", 284 | "\n", 285 | " sex tissue ethnicity \\\n", 286 | "AAACAAGTATCTCCCA-1 female heart left ventricle European \n", 287 | "AAACAATCTACTAGCA-1 female heart left ventricle European \n", 288 | "AAACACCAATAACTGC-1 female heart left ventricle European \n", 289 | "AAACAGAGCGACTCCT-1 female heart left ventricle European \n", 290 | "AAACAGCTTTCAGAAG-1 female heart left ventricle European \n", 291 | "\n", 292 | " development_stage sample condition \n", 293 | "AAACAAGTATCTCCCA-1 44-year-old human stage control_P1 control \n", 294 | "AAACAATCTACTAGCA-1 44-year-old human stage control_P1 control \n", 295 | "AAACACCAATAACTGC-1 44-year-old human stage control_P1 control \n", 296 | "AAACAGAGCGACTCCT-1 44-year-old human stage control_P1 control \n", 297 | "AAACAGCTTTCAGAAG-1 44-year-old human stage control_P1 control \n", 298 | "\n", 299 | "[5 rows x 34 columns]" 300 | ] 301 | }, 302 | "execution_count": 6, 303 | "metadata": {}, 304 | "output_type": "execute_result" 305 | } 306 | ], 307 | "source": [ 308 | "adata.obs.head()" 309 | ] 310 | }, 311 | { 312 | "cell_type": "markdown", 313 | "id": "2af85dff", 314 | "metadata": {}, 315 | "source": [ 316 | "## Compute Weisfeiler-Lehman Graph Kernels to compare conditions" 317 | ] 318 | }, 319 | { 320 | "cell_type": "code", 321 | "execution_count": 7, 322 | "id": "e74285b6", 323 | "metadata": {}, 324 | "outputs": [], 325 | "source": [ 326 | "# define library_key and cluster_key for computing spatial graphs using `squidpy.gr.spatial_neighbors`\n", 327 | "\n", 328 | "library_key=\"sample\"\n", 329 | "cluster_key=\"cell_type_original\"" 330 | ] 331 | }, 332 | { 333 | "cell_type": "code", 334 | "execution_count": 8, 335 | "id": "7d5937d9", 336 | "metadata": { 337 | "scrolled": true 338 | }, 339 | "outputs": [], 340 | "source": [ 341 | "# compute WWL kernels\n", 342 | "### results are stored in adata.uns[\"wl_kernel\"]\n", 343 | "\n", 344 | "gc.tl.wlkernel.compare_conditions(\n", 345 | " adata=adata,\n", 346 | " library_key=library_key,\n", 347 | " cluster_key=cluster_key,\n", 348 | " compute_spatial_graphs=True,\n", 349 | " kwargs_spatial_neighbors={\n", 350 | " 'coord_type': 'grid',\n", 351 | " 'n_neighs': 6,\n", 352 | " },\n", 353 | ")" 354 | ] 355 | }, 356 | { 357 | "cell_type": "code", 358 | "execution_count": 10, 359 | "id": "947cc83a", 360 | "metadata": {}, 361 | "outputs": [ 362 | { 363 | "data": { 364 | "text/plain": [ 365 | "AnnData object with n_obs × n_vars = 54258 × 11669\n", 366 | " obs: 'n_counts', 'n_genes', 'percent.mt', 'Adipocyte', 'Cardiomyocyte', 'Endothelial', 'Fibroblast', 'Lymphoid', 'Mast', 'Myeloid', 'Neuronal', 'Pericyte', 'Cycling.cells', 'vSMCs', 'cell_type_original', 'assay_ontology_term_id', 'cell_type_ontology_term_id', 'development_stage_ontology_term_id', 'disease_ontology_term_id', 'ethnicity_ontology_term_id', 'is_primary_data', 'organism_ontology_term_id', 'sex_ontology_term_id', 'tissue_ontology_term_id', 'cell_type', 'assay', 'disease', 'organism', 'sex', 'tissue', 'ethnicity', 'development_stage', 'sample', 'condition'\n", 367 | " var: 'feature_biotype', 'feature_is_filtered', 'feature_name', 'feature_reference'\n", 368 | " uns: 'cell_type_original_sample_nhood_enrichment', 'pairwise_similarities', 'spatial_neighbors', 'wl_kernel'\n", 369 | " obsm: 'X_pca', 'X_spatial', 'X_umap', 'spatial'\n", 370 | " obsp: 'spatial_connectivities', 'spatial_distances'" 371 | ] 372 | }, 373 | "execution_count": 10, 374 | "metadata": {}, 375 | "output_type": "execute_result" 376 | } 377 | ], 378 | "source": [ 379 | "adata" 380 | ] 381 | }, 382 | { 383 | "cell_type": "markdown", 384 | "id": "710a3342", 385 | "metadata": {}, 386 | "source": [ 387 | "## Plot results" 388 | ] 389 | }, 390 | { 391 | "cell_type": "code", 392 | "execution_count": 16, 393 | "id": "eec67841", 394 | "metadata": {}, 395 | "outputs": [], 396 | "source": [ 397 | "# define necessary params\n", 398 | "condition_key=\"condition\" # key in adata.obs where conditions are stored\n", 399 | "control_group=\"RZ\" # reference group\n", 400 | "metric_key=\"wasserstein_distance\" \n", 401 | "method=\"wl_kernel\"" 402 | ] 403 | }, 404 | { 405 | "cell_type": "code", 406 | "execution_count": 17, 407 | "id": "e12970de", 408 | "metadata": {}, 409 | "outputs": [ 410 | { 411 | "name": "stdout", 412 | "output_type": "stream", 413 | "text": [ 414 | "p-value annotation legend:\n", 415 | "ns: 5.00e-02 < p <= 1.00e+00\n", 416 | "*: 1.00e-02 < p <= 5.00e-02\n", 417 | "**: 1.00e-03 < p <= 1.00e-02\n", 418 | "***: 1.00e-04 < p <= 1.00e-03\n", 419 | "****: p <= 1.00e-04\n", 420 | "\n", 421 | "RZ vs control v.s. RZ vs IZ: t-test independent samples, P_val=2.148e-01 stat=-1.254e+00\n" 422 | ] 423 | }, 424 | { 425 | "data": { 426 | "image/png": "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\n", 427 | "text/plain": [ 428 | "
" 429 | ] 430 | }, 431 | "metadata": { 432 | "needs_background": "light" 433 | }, 434 | "output_type": "display_data" 435 | } 436 | ], 437 | "source": [ 438 | "# Note: a smaller Wasserstein distance indicates a higher similarity between the two graphs, \n", 439 | "# while a larger distance indicates less similarity.\n", 440 | "\n", 441 | "\n", 442 | "gc.pl.wlkernel.compare_conditions(\n", 443 | " adata=adata,\n", 444 | " library_key=library_key,\n", 445 | " condition_key=condition_key,\n", 446 | " control_group=control_group,\n", 447 | " metric_key=metric_key,\n", 448 | " method=method,\n", 449 | " figsize=(3,5),\n", 450 | " dpi=100,\n", 451 | " #save=\"figures/visium_wwlkerenl.pdf\"\n", 452 | ")" 453 | ] 454 | } 455 | ], 456 | "metadata": { 457 | "kernelspec": { 458 | "display_name": "Python 3 (ipykernel)", 459 | "language": "python", 460 | "name": "python3" 461 | }, 462 | "language_info": { 463 | "codemirror_mode": { 464 | "name": "ipython", 465 | "version": 3 466 | }, 467 | "file_extension": ".py", 468 | "mimetype": "text/x-python", 469 | "name": "python", 470 | "nbconvert_exporter": "python", 471 | "pygments_lexer": "ipython3", 472 | "version": "3.10.12" 473 | } 474 | }, 475 | "nbformat": 4, 476 | "nbformat_minor": 5 477 | } 478 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """Nox sessions.""" 2 | import os 3 | import shlex 4 | import shutil 5 | import sys 6 | from pathlib import Path 7 | from textwrap import dedent 8 | 9 | import nox 10 | 11 | 12 | try: 13 | from nox_poetry import Session 14 | from nox_poetry import session 15 | except ImportError: 16 | message = f"""\ 17 | Nox failed to import the 'nox-poetry' package. 18 | 19 | Please install it using the following command: 20 | 21 | {sys.executable} -m pip install nox-poetry""" 22 | raise SystemExit(dedent(message)) from None 23 | 24 | 25 | package = "graphcompass" 26 | python_versions = ["3.10", "3.9", "3.8", "3.7"] 27 | nox.needs_version = ">= 2021.6.6" 28 | nox.options.sessions = ( 29 | "pre-commit", 30 | "safety", 31 | "mypy", 32 | "tests", 33 | "typeguard", 34 | "xdoctest", 35 | "docs-build", 36 | ) 37 | 38 | 39 | def activate_virtualenv_in_precommit_hooks(session: Session) -> None: 40 | """Activate virtualenv in hooks installed by pre-commit. 41 | 42 | This function patches git hooks installed by pre-commit to activate the 43 | session's virtual environment. This allows pre-commit to locate hooks in 44 | that environment when invoked from git. 45 | 46 | Args: 47 | session: The Session object. 48 | """ 49 | assert session.bin is not None # noqa: S101 50 | 51 | # Only patch hooks containing a reference to this session's bindir. Support 52 | # quoting rules for Python and bash, but strip the outermost quotes so we 53 | # can detect paths within the bindir, like /python. 54 | bindirs = [ 55 | bindir[1:-1] if bindir[0] in "'\"" else bindir 56 | for bindir in (repr(session.bin), shlex.quote(session.bin)) 57 | ] 58 | 59 | virtualenv = session.env.get("VIRTUAL_ENV") 60 | if virtualenv is None: 61 | return 62 | 63 | headers = { 64 | # pre-commit < 2.16.0 65 | "python": f"""\ 66 | import os 67 | os.environ["VIRTUAL_ENV"] = {virtualenv!r} 68 | os.environ["PATH"] = os.pathsep.join(( 69 | {session.bin!r}, 70 | os.environ.get("PATH", ""), 71 | )) 72 | """, 73 | # pre-commit >= 2.16.0 74 | "bash": f"""\ 75 | VIRTUAL_ENV={shlex.quote(virtualenv)} 76 | PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" 77 | """, 78 | # pre-commit >= 2.17.0 on Windows forces sh shebang 79 | "/bin/sh": f"""\ 80 | VIRTUAL_ENV={shlex.quote(virtualenv)} 81 | PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" 82 | """, 83 | } 84 | 85 | hookdir = Path(".git") / "hooks" 86 | if not hookdir.is_dir(): 87 | return 88 | 89 | for hook in hookdir.iterdir(): 90 | if hook.name.endswith(".sample") or not hook.is_file(): 91 | continue 92 | 93 | if not hook.read_bytes().startswith(b"#!"): 94 | continue 95 | 96 | text = hook.read_text() 97 | 98 | if not any( 99 | Path("A") == Path("a") and bindir.lower() in text.lower() or bindir in text 100 | for bindir in bindirs 101 | ): 102 | continue 103 | 104 | lines = text.splitlines() 105 | 106 | for executable, header in headers.items(): 107 | if executable in lines[0].lower(): 108 | lines.insert(1, dedent(header)) 109 | hook.write_text("\n".join(lines)) 110 | break 111 | 112 | 113 | @session(name="pre-commit", python=python_versions[0]) 114 | def precommit(session: Session) -> None: 115 | """Lint using pre-commit.""" 116 | args = session.posargs or [ 117 | "run", 118 | "--all-files", 119 | "--hook-stage=manual", 120 | "--show-diff-on-failure", 121 | ] 122 | session.install( 123 | "black", 124 | "darglint", 125 | "flake8", 126 | "flake8-bandit", 127 | "flake8-bugbear", 128 | "flake8-docstrings", 129 | "flake8-rst-docstrings", 130 | "isort", 131 | "pep8-naming", 132 | "pre-commit", 133 | "pre-commit-hooks", 134 | "pyupgrade", 135 | ) 136 | session.run("pre-commit", *args) 137 | if args and args[0] == "install": 138 | activate_virtualenv_in_precommit_hooks(session) 139 | 140 | 141 | @session(python=python_versions[0]) 142 | def safety(session: Session) -> None: 143 | """Scan dependencies for insecure packages.""" 144 | requirements = session.poetry.export_requirements() 145 | session.install("safety") 146 | session.run("safety", "check", "--full-report", f"--file={requirements}") 147 | 148 | 149 | @session(python=python_versions) 150 | def mypy(session: Session) -> None: 151 | """Type-check using mypy.""" 152 | args = session.posargs or ["src", "tests", "docs/conf.py"] 153 | session.install(".") 154 | session.install("mypy", "pytest") 155 | session.run("mypy", *args) 156 | if not session.posargs: 157 | session.run("mypy", f"--python-executable={sys.executable}", "noxfile.py") 158 | 159 | 160 | @session(python=python_versions) 161 | def tests(session: Session) -> None: 162 | """Run the test suite.""" 163 | session.install(".") 164 | session.install("coverage[toml]", "pytest", "pygments") 165 | try: 166 | session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs) 167 | finally: 168 | if session.interactive: 169 | session.notify("coverage", posargs=[]) 170 | 171 | 172 | @session(python=python_versions[0]) 173 | def coverage(session: Session) -> None: 174 | """Produce the coverage report.""" 175 | args = session.posargs or ["report"] 176 | 177 | session.install("coverage[toml]") 178 | 179 | if not session.posargs and any(Path().glob(".coverage.*")): 180 | session.run("coverage", "combine") 181 | 182 | session.run("coverage", *args) 183 | 184 | 185 | @session(python=python_versions[0]) 186 | def typeguard(session: Session) -> None: 187 | """Runtime type checking using Typeguard.""" 188 | session.install(".") 189 | session.install("pytest", "typeguard", "pygments") 190 | session.run("pytest", f"--typeguard-packages={package}", *session.posargs) 191 | 192 | 193 | @session(python=python_versions) 194 | def xdoctest(session: Session) -> None: 195 | """Run examples with xdoctest.""" 196 | if session.posargs: 197 | args = [package, *session.posargs] 198 | else: 199 | args = [f"--modname={package}", "--command=all"] 200 | if "FORCE_COLOR" in os.environ: 201 | args.append("--colored=1") 202 | 203 | session.install(".") 204 | session.install("xdoctest[colors]") 205 | session.run("python", "-m", "xdoctest", *args) 206 | 207 | 208 | @session(name="docs-build", python=python_versions[0]) 209 | def docs_build(session: Session) -> None: 210 | """Build the documentation.""" 211 | args = session.posargs or ["docs", "docs/_build"] 212 | if not session.posargs and "FORCE_COLOR" in os.environ: 213 | args.insert(0, "--color") 214 | 215 | session.install(".") 216 | session.install("sphinx", "sphinx-click", "furo", "myst-parser") 217 | 218 | build_dir = Path("docs", "_build") 219 | if build_dir.exists(): 220 | shutil.rmtree(build_dir) 221 | 222 | session.run("sphinx-build", *args) 223 | 224 | 225 | @session(python=python_versions[0]) 226 | def docs(session: Session) -> None: 227 | """Build and serve the documentation with live reloading on file changes.""" 228 | args = session.posargs or ["--open-browser", "docs", "docs/_build"] 229 | session.install(".") 230 | session.install("sphinx", "sphinx-autobuild", "sphinx-click", "furo", "myst-parser") 231 | 232 | build_dir = Path("docs", "_build") 233 | if build_dir.exists(): 234 | shutil.rmtree(build_dir) 235 | 236 | session.run("sphinx-autobuild", *args) 237 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # pyproject.toml adapted from https://github.com/scverse/squidpy/blob/main/pyproject.toml 2 | 3 | [build-system] 4 | build-backend = "hatchling.build" 5 | requires = ["hatchling", "hatch-vcs"] 6 | 7 | [project] 8 | name = "graphcompass" 9 | #dynamic = ["version"] 10 | version = "0.2.5" 11 | description = "Spatial metrics for differential analyses of cell organization across conditions" 12 | readme = "README.md" #change to README_pypi.md for PyPI 13 | requires-python = ">=3.9, <3.12" 14 | license = {file = "LICENSE"} 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Science/Research", 18 | "Natural Language :: English", 19 | "License :: OSI Approved :: BSD License", 20 | "Operating System :: POSIX :: Linux", 21 | "Operating System :: MacOS :: MacOS X", 22 | "Typing :: Typed", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Environment :: Console", 27 | "Framework :: Jupyter", 28 | "Intended Audience :: Science/Research", 29 | "Topic :: Scientific/Engineering :: Bio-Informatics", 30 | "Topic :: Scientific/Engineering :: Visualization", 31 | ] 32 | 33 | keywords = [ 34 | "spatial omics", 35 | "bio-informatics", 36 | "tissue architecture", 37 | "spatial data analysis", 38 | "cell spatial organization", 39 | "graph analytics", 40 | ] 41 | authors = [ 42 | {name = "Mayar Ali"}, 43 | {name = "Merel Kuijs"}, 44 | ] 45 | maintainers = [ 46 | {name = "Mayar Ali", email = "mayar.ali@helmholtz-munich.de"}, 47 | {name = "Merel Kuijs", email = "merelsentina.kuijs@helmholtz-munich.de"} 48 | ] 49 | 50 | dependencies = [ 51 | "aiohttp>=3.8.1", 52 | "anndata>=0.9", 53 | "cycler>=0.11.0", 54 | "Cython>=3.0.2", 55 | "dask-image>=0.5.0", 56 | "dask[array]>=2021.02.0", 57 | "docrep>=0.3.1", 58 | "fsspec>=2021.11.0", 59 | "igraph>=0.11.3", 60 | "leidenalg>=0.8.2", 61 | "matplotlib>=3.3", 62 | "matplotlib-scalebar>=0.8.0", 63 | "networkx>=2.8.6", 64 | "NetLSD>=1.0.2", 65 | "numba>=0.56.4", 66 | "numpy>=1.23.0,<2.0", 67 | "omnipath>=1.0.7", 68 | "pandas>=2.1.0", 69 | "Pillow>=8.0.0", 70 | "POT", 71 | "scanpy>=1.9.3", 72 | "scikit-image>=0.19,<=0.20", 73 | "scikit-learn>=0.24.0", 74 | "squidpy>=1.2.2", 75 | "spatialdata", 76 | "statannot>=0.2.3", 77 | "statsmodels>=0.12.0", 78 | "tifffile!=2022.4.22", 79 | "tqdm>=4.50.2", 80 | "validators>=0.18.2", 81 | "xarray>=0.16.1", 82 | "zarr>=2.6.1", 83 | ] 84 | 85 | dev = [ 86 | "pre-commit>=3.0.0", 87 | "tox>=4.0.0", 88 | ] 89 | test = [ 90 | "pytest>=7", 91 | "pytest-xdist>=3", 92 | "pytest-mock>=3.5.0", 93 | "pytest-cov>=4", 94 | "coverage[toml]>=7", 95 | ] 96 | docs = [ 97 | "ipython", 98 | "ipywidgets>=8.0.0", 99 | "sphinx>=5.3", 100 | "sphinx-autodoc-annotation", 101 | "sphinx-autodoc-typehints>=1.10.3", 102 | "sphinx_rtd_theme", 103 | "sphinxcontrib-bibtex>=2.3.0", 104 | "sphinxcontrib-spelling>=7.6.2", 105 | "nbsphinx>=0.8.1", 106 | "myst-nb>=0.17.1", 107 | "sphinx_copybutton>=0.5.0", 108 | ] 109 | 110 | [project.urls] 111 | Homepage = "https://github.com/theislab/graphcompass" 112 | "Bug Tracker" = "https://github.com/theislab/graphcompass/issues" 113 | "Source Code" = "https://github.com/theislab/graphcompass" 114 | 115 | [tool.setuptools] 116 | package-dir = {"" = "src"} 117 | include-package-data = true 118 | 119 | [tool.setuptools_scm] 120 | 121 | [tool.black] 122 | line-length = 120 123 | target-version = ['py39'] 124 | include = '\.pyi?$' 125 | exclude = ''' 126 | ( 127 | /( 128 | \.eggs 129 | | \.git 130 | | \.hg 131 | | \.mypy_cache 132 | | \.tox 133 | | \.venv 134 | | _build 135 | | buck-out 136 | | build 137 | | dist 138 | )/ 139 | 140 | ) 141 | ''' 142 | 143 | [tool.isort] 144 | profile = "black" 145 | py_version = "38" 146 | skip = "docs/source/conf.py,.tox,build" 147 | line_length = 88 148 | multi_line_output = 3 149 | include_trailing_comma = true 150 | use_parentheses = true 151 | known_stdlib = "joblib" 152 | known_bio = "anndata,scanpy,squidpy" 153 | known_num = "numpy,numba,scipy,sklearn,statsmodels,pandas,xarray,dask" 154 | known_plot = "matplotlib,seaborn,napari" 155 | known_gui = "PyQt5,superqt" 156 | known_img = "skimage,tifffile,dask_image" 157 | known_graph = "networkx" 158 | sections = "FUTURE,STDLIB,THIRDPARTY,BIO,NUM,GUI,PLOT,IMG,GRAPH,FIRSTPARTY,LOCALFOLDER" 159 | no_lines_before="LOCALFOLDER" 160 | balanced_wrapping = true 161 | force_grid_wrap = 0 162 | length_sort = "1" 163 | indent = " " 164 | from_first = true 165 | order_by_type = true 166 | atomic = true 167 | combine_star = true 168 | combine_as_imports = true 169 | honor_noqa = true 170 | remove_redundant_aliases = true 171 | only_modified = true 172 | group_by_package = true 173 | force_alphabetical_sort_within_sections = true 174 | lexicographical = true 175 | 176 | [tool.hatch.version] 177 | source = "vcs" 178 | 179 | [tool.hatch.metadata] 180 | allow-direct-references = true 181 | 182 | [tool.ruff] 183 | exclude = [ 184 | ".git", 185 | ".tox", 186 | "__pycache__", 187 | "build", 188 | "docs/_build", 189 | "dist", 190 | "setup.py" 191 | ] 192 | ignore = [ 193 | # line too long -> we accept long comment lines; black gets rid of long code lines 194 | "E501", 195 | # Do not assign a lambda expression, use a def -> lambda expression assignments are convenient 196 | "E731", 197 | # allow I, O, l as variable names -> I is the identity matrix, i, j, k, l is reasonable indexing notation 198 | "E741", 199 | # Missing docstring in public package 200 | "D104", 201 | # ... imported but unused 202 | "F401", 203 | # Missing docstring in public module 204 | "D100", 205 | # Missing docstring in __init__ 206 | "D107", 207 | # Do not perform function calls in argument defaults. 208 | "B008", 209 | # Missing docstring in magic method 210 | "D105", 211 | # Missing blank line before section 212 | "D411", 213 | # D100 Missing docstring in public module 214 | "D100", 215 | # D107 Missing docstring in __init__, 216 | "D107", 217 | # B008 Do not perform function calls in argument defaults. 218 | "B008", 219 | # B024 Do not use `__class__` for string comparisons. 220 | "B024", 221 | ## Flake8 rules not supported by ruff: 222 | # RST201 Block quote ends without a blank line; unexpected unindent. 223 | # "RST201", 224 | # RST301 Unexpected indentation. 225 | # "RST301", 226 | # RST306 Unknown target name. 227 | # "RST306", 228 | # RST203 Definition list ends without a blank line; unexpected unindent. 229 | # "RST203", 230 | # line break before a binary operator -> black does not adhere to PEP8 231 | # "W503", 232 | # line break occured after a binary operator -> black does not adhere to PEP8 233 | # "W504", 234 | # whitespace before : -> black does not adhere to PEP8 235 | # "E203", 236 | # whitespace before : -> black does not adhere to PEP8 237 | # "E203", 238 | # missing whitespace after ,', ';', or ':' -> black does not adhere to PEP8 239 | # "E231", 240 | # continuation line over-indented for hanging indent -> black does not adhere to PEP8 241 | # "E126", 242 | # inline comment should start with '#' -> Scanpy allows them for specific explanations 243 | # "E266", 244 | # format string does contain unindexed parameters 245 | # "P101", 246 | # indentation is not a multiple of 4 247 | # "E111", 248 | # "E114", 249 | ] 250 | line-length = 120 251 | select = [ 252 | "I", # isort 253 | "E", # pycodestyle 254 | "F", # pyflakes 255 | "W", # pycodestyle 256 | # below are not autofixed 257 | "UP", # pyupgrade 258 | "C4", # flake8-comprehensions 259 | "B", # flake8-bugbear 260 | "BLE", # flake8-blind-except 261 | ] 262 | unfixable = ["B", "UP", "C4", "BLE"] 263 | target-version = "py38" 264 | [tool.ruff.per-file-ignores] 265 | "*/__init__.py" = ["D104", "F401"] 266 | "tests/*"= ["D"] 267 | "docs/*"= ["D","B"] 268 | # "graphcompass/*.py"= ["RST303"] 269 | 270 | [tool.ruff.flake8-tidy-imports] 271 | # Disallow all relative imports. 272 | ban-relative-imports = "all" 273 | -------------------------------------------------------------------------------- /src/graphcompass/__init__.py: -------------------------------------------------------------------------------- 1 | """Graph-COMPASS.""" 2 | from graphcompass import pl 3 | from graphcompass import tl 4 | from graphcompass import datasets 5 | from graphcompass import imports 6 | from graphcompass.imports import wwl_package 7 | -------------------------------------------------------------------------------- /src/graphcompass/__main__.py: -------------------------------------------------------------------------------- 1 | """Command-line interface.""" 2 | import click 3 | 4 | 5 | @click.command() 6 | @click.version_option() 7 | def main() -> None: 8 | """Graph-COMPASS.""" 9 | 10 | 11 | if __name__ == "__main__": 12 | main(prog_name="graphcompass") # pragma: no cover 13 | -------------------------------------------------------------------------------- /src/graphcompass/datasets/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from graphcompass.datasets._dataset import * # noqa: F403 -------------------------------------------------------------------------------- /src/graphcompass/datasets/_dataset.py: -------------------------------------------------------------------------------- 1 | # adapted from squidpy.datasets._dataset.py 2 | from __future__ import annotations 3 | from copy import copy 4 | 5 | from squidpy.datasets._utils import AMetadata 6 | 7 | _mibitof_breast_cancer = AMetadata( 8 | name="mibitof_breast_cancer", 9 | doc_header="MIBI-TOF breast cancer dataset fro `Risom et al. `", 10 | url="https://figshare.com/ndownloader/files/44696053", 11 | shape=(69672, 59), 12 | ) 13 | 14 | _visium_heart = AMetadata( 15 | name="visium_heart", 16 | doc_header="Visium Myocardial tissue (heart) data from `Kuppe et al. `", 17 | url="https://figshare.com/ndownloader/files/44715052", 18 | shape=(88456, 11669), 19 | ) 20 | 21 | _stereoseq_axolotl_development = AMetadata( 22 | name="stereoseq_axolotl_development", 23 | doc_header="Stereo-seq Axolotl Brain Development dataset from `Wei et al. `", 24 | url="https://figshare.com/ndownloader/files/44714629", 25 | shape=(36198, 12704), 26 | ) 27 | 28 | _stereoseq_axolotl_regeneration = AMetadata( 29 | name="stereoseq_axolotl_regeneration", 30 | doc_header="Stereo-seq Axolotl Brain Regeneration dataset from `Wei et al. `", 31 | url="https://figshare.com/ndownloader/files/44715166", 32 | shape=(182142, 16176), 33 | ) 34 | 35 | _stereoseq_axolotl_subset = AMetadata( 36 | name="stereoseq_axolotl_subset", 37 | doc_header="Stereo-seq Axolotl Brain subset (30DPI, 60DPI, Adult) dataset from `Wei et al. `", 38 | url="https://figshare.com/ndownloader/files/44714335", 39 | shape=(28459, 18611), 40 | ) 41 | 42 | for name, var in copy(locals()).items(): 43 | if isinstance(var, AMetadata): 44 | var._create_function(name, globals()) 45 | 46 | __all__ = [ # noqa: F822 47 | "mibitof_breast_cancer", 48 | "visium_heart", 49 | "stereoseq_axolotl_development", 50 | "stereoseq_axolotl_regeneration", 51 | "stereoseq_axolotl_subset", 52 | ] 53 | -------------------------------------------------------------------------------- /src/graphcompass/datasets/_dataset.pyi: -------------------------------------------------------------------------------- 1 | # adapted from squidpy.datasets._dataset.pyi 2 | from typing import Any, Protocol, Union 3 | 4 | from anndata import AnnData 5 | 6 | from squidpy.datasets._utils import PathLike 7 | 8 | 9 | class Dataset(Protocol): 10 | def __call__(self, path: PathLike | None = ..., **kwargs: Any) -> AnnData: ... 11 | 12 | 13 | mibitof_breast_cancer: Dataset 14 | visium_heart: Dataset 15 | stereoseq_axolotl_development: Dataset 16 | stereoseq_axolotl_regeneration: Dataset 17 | stereoseq_axolotl_subset: Dataset 18 | -------------------------------------------------------------------------------- /src/graphcompass/imports/wwl_package/__init__.py: -------------------------------------------------------------------------------- 1 | """The spatial DE methods module.""" 2 | from .wwl import pairwise_wasserstein_distance, wwl -------------------------------------------------------------------------------- /src/graphcompass/imports/wwl_package/propagation_scheme.py: -------------------------------------------------------------------------------- 1 | ######## This file is copied from https://github.com/BorgwardtLab/WWL/blob/master/src/wwl/propagation_scheme.py ######## 2 | 3 | # ----------------------------------------------------------------------------- 4 | # This file contains the propagation schemes for categorically labeled and 5 | # continuously attributed graphs. 6 | # 7 | # November 2019, M. Togninalli 8 | # ----------------------------------------------------------------------------- 9 | import numpy as np 10 | 11 | from sklearn.preprocessing import scale 12 | from sklearn.base import TransformerMixin 13 | 14 | import argparse 15 | import igraph as ig 16 | import os 17 | 18 | import copy 19 | from collections import defaultdict 20 | from typing import List 21 | from tqdm import tqdm 22 | from scipy.sparse import csr_matrix, diags 23 | 24 | 25 | #################### 26 | # Weisfeiler-Lehman 27 | #################### 28 | class WeisfeilerLehman(TransformerMixin): 29 | """ 30 | Class that implements the Weisfeiler-Lehman transform 31 | Credits: Christian Bock and Bastian Rieck 32 | """ 33 | def __init__(self): 34 | self._relabel_steps = defaultdict(dict) 35 | self._label_dict = {} 36 | self._last_new_label = -1 37 | self._preprocess_relabel_dict = {} 38 | self._results = defaultdict(dict) 39 | self._label_dicts = {} 40 | 41 | def _reset_label_generation(self): 42 | self._last_new_label = -1 43 | 44 | def _get_next_label(self): 45 | self._last_new_label += 1 46 | return self._last_new_label 47 | 48 | def _relabel_graphs(self, X: List[ig.Graph]): 49 | num_unique_labels = 0 50 | preprocessed_graphs = [] 51 | for i, g in enumerate(X): 52 | x = g.copy() 53 | 54 | if not 'label' in x.vs.attribute_names(): 55 | x.vs['label'] = list(map(str, [l for l in x.vs.degree()])) 56 | labels = x.vs['label'] 57 | 58 | 59 | new_labels = [] 60 | for label in labels: 61 | if label in self._preprocess_relabel_dict.keys(): 62 | new_labels.append(self._preprocess_relabel_dict[label]) 63 | else: 64 | self._preprocess_relabel_dict[label] = self._get_next_label() 65 | new_labels.append(self._preprocess_relabel_dict[label]) 66 | x.vs['label'] = new_labels 67 | self._results[0][i] = (labels, new_labels) 68 | self._label_sequences[i][:, 0] = new_labels 69 | preprocessed_graphs.append(x) 70 | self._reset_label_generation() 71 | return preprocessed_graphs 72 | 73 | def fit_transform(self, X: List[ig.Graph], num_iterations: int=3, return_sequences=True): 74 | self._label_sequences = [ 75 | np.full((len(g.vs), num_iterations + 1), np.nan) for g in X 76 | ] 77 | X = self._relabel_graphs(X) 78 | for it in np.arange(1, num_iterations+1, 1): 79 | self._reset_label_generation() 80 | self._label_dict = {} 81 | for i, g in enumerate(X): 82 | # Get labels of current interation 83 | current_labels = g.vs['label'] 84 | 85 | # Get for each vertex the labels of its neighbors 86 | neighbor_labels = self._get_neighbor_labels(g, sort=True) 87 | 88 | # Prepend the vertex label to the list of labels of its neighbors 89 | merged_labels = [[b]+a for a,b in zip(neighbor_labels, current_labels)] 90 | 91 | # Generate a label dictionary based on the merged labels 92 | self._append_label_dict(merged_labels) 93 | 94 | # Relabel the graph 95 | new_labels = self._relabel_graph(g, merged_labels) 96 | self._relabel_steps[i][it] = { idx: {old_label: new_labels[idx]} for idx, old_label in enumerate(current_labels) } 97 | g.vs['label'] = new_labels 98 | 99 | self._results[it][i] = (merged_labels, new_labels) 100 | self._label_sequences[i][:, it] = new_labels 101 | self._label_dicts[it] = copy.deepcopy(self._label_dict) 102 | if return_sequences: 103 | return self._label_sequences 104 | else: 105 | return self._results 106 | 107 | def _relabel_graph(self, X: ig.Graph, merged_labels: list): 108 | new_labels = [] 109 | for merged in merged_labels: 110 | new_labels.append(self._label_dict['-'.join(map(str,merged))]) 111 | return new_labels 112 | 113 | def _append_label_dict(self, merged_labels: List[list]): 114 | for merged_label in merged_labels: 115 | dict_key = '-'.join(map(str,merged_label)) 116 | if dict_key not in self._label_dict.keys(): 117 | self._label_dict[ dict_key ] = self._get_next_label() 118 | 119 | def _get_neighbor_labels(self, X: ig.Graph, sort: bool=True): 120 | neighbor_indices = [[n_v.index for n_v in X.vs[X.neighbors(v.index)]] for v in X.vs] 121 | neighbor_labels = [] 122 | for n_indices in neighbor_indices: 123 | if sort: 124 | neighbor_labels.append( sorted(X.vs[n_indices]['label']) ) 125 | else: 126 | neighbor_labels.append( X.vs[n_indices]['label'] ) 127 | return neighbor_labels 128 | 129 | #################### 130 | # Continuous Weisfeiler-Lehman 131 | #################### 132 | 133 | class ContinuousWeisfeilerLehman(TransformerMixin): 134 | """ 135 | Class that implements the continuous Weisfeiler-Lehman propagation scheme 136 | """ 137 | def __init__(self): 138 | self._results = defaultdict(dict) 139 | self._label_sequences = [] 140 | 141 | def _preprocess_graphs(self, X: List[ig.Graph]): 142 | """ 143 | Load graphs from gml files. 144 | """ 145 | # initialize 146 | node_features = [] 147 | adj_mat = [] 148 | n_nodes = [] 149 | 150 | # Iterate across graphs and load initial node features 151 | for graph in X: 152 | if not 'label' in graph.vs.attribute_names(): 153 | graph.vs['label'] = list(map(str, [l for l in graph.vs.degree()])) 154 | # Get features and adjacency matrix 155 | node_features_cur = np.asarray(graph.vs['label']).astype(float).reshape(-1, 1) 156 | adj_mat_cur = csr_matrix(graph.get_adjacency_sparse()) 157 | # Load features 158 | node_features.append(node_features_cur) 159 | adj_mat.append(adj_mat_cur) 160 | n_nodes.append(adj_mat_cur.shape[0]) 161 | 162 | # By default, keep degree or label as features, if other features shall 163 | # to be used (e.g. the one from the TU Dortmund website), 164 | # provide them to the fit_transform function. 165 | 166 | n_nodes = np.asarray(n_nodes) 167 | node_features = np.asarray(node_features, dtype=object) 168 | 169 | return node_features, adj_mat, n_nodes 170 | 171 | def _create_adj_avg(self, adj_cur: csr_matrix) -> csr_matrix: 172 | ''' 173 | Create adjacency matrix using sparse operations. 174 | ''' 175 | deg = np.array(adj_cur.sum(axis=1)).flatten() 176 | 177 | # Adjust degrees where needed 178 | deg[deg!=1] -= 1 179 | 180 | deg = 1 / deg 181 | deg_mat = diags(deg) 182 | 183 | # Normalize adjacency matrix 184 | adj_cur = deg_mat @ adj_cur # Sparse multiplication 185 | 186 | return adj_cur 187 | 188 | def fit_transform(self, X: List[ig.Graph], node_features = None, num_iterations: int=3): 189 | """ 190 | Transform a list of graphs into their node representations. 191 | Node features should be provided as a numpy array. 192 | """ 193 | print("Embedding nodes; pre-processing...") 194 | node_features_labels, adj_mat, n_nodes = self._preprocess_graphs(X) 195 | if node_features is None: 196 | node_features = node_features_labels 197 | 198 | print("Embedding nodes; feature scaling...") 199 | node_features_data = scale(np.concatenate(node_features, axis=0), axis = 0) 200 | splits_idx = np.cumsum(n_nodes).astype(int) 201 | node_features_split = np.vsplit(node_features_data,splits_idx) 202 | node_features = node_features_split[:-1] 203 | 204 | # Generate the label sequences for h iterations 205 | print("Embedding nodes; generating label sequences...") 206 | n_graphs = len(node_features) 207 | self._label_sequences = [] 208 | for i in tqdm(range(n_graphs)): 209 | graph_feat = [] 210 | 211 | for it in range(num_iterations+1): 212 | if it == 0: 213 | graph_feat.append(node_features[i]) 214 | else: 215 | adj_cur = adj_mat[i] + csr_matrix(np.identity(adj_mat[i].shape[0])) 216 | adj_cur = self._create_adj_avg(adj_cur) 217 | 218 | adj_cur.setdiag(0) 219 | graph_feat_cur = 0.5 * (adj_cur @ graph_feat[it-1] + graph_feat[it-1]) 220 | graph_feat.append(graph_feat_cur) 221 | 222 | self._label_sequences.append(np.concatenate(graph_feat, axis=1)) 223 | return self._label_sequences -------------------------------------------------------------------------------- /src/graphcompass/imports/wwl_package/wwl.py: -------------------------------------------------------------------------------- 1 | ######## This file is copied from https://github.com/BorgwardtLab/WWL/blob/master/src/wwl/wwl.py ######## 2 | 3 | 4 | # ----------------------------------------------------------------------------- 5 | # This file contains the API for the WWL kernel computations 6 | # 7 | # December 2019, M. Togninalli 8 | # ----------------------------------------------------------------------------- 9 | import sys 10 | import logging 11 | from tqdm import tqdm 12 | 13 | import ot 14 | import numpy as np 15 | from sklearn.metrics.pairwise import laplacian_kernel 16 | 17 | from .propagation_scheme import WeisfeilerLehman, ContinuousWeisfeilerLehman 18 | 19 | logging.basicConfig(level=logging.INFO) 20 | 21 | def logging_config(level='DEBUG'): 22 | level = logging.getLevelName(level.upper()) 23 | logging.basicConfig(level=level) 24 | pass 25 | 26 | def _compute_wasserstein_distance(label_sequences, sinkhorn=False, 27 | categorical=False, sinkhorn_lambda=1e-2): 28 | ''' 29 | Generate the Wasserstein distance matrix for the graphs embedded 30 | in label_sequences 31 | ''' 32 | # Get the iteration number from the embedding file 33 | n = len(label_sequences) 34 | 35 | M = np.zeros((n,n)) 36 | # Iterate over pairs of graphs 37 | for graph_index_1, graph_1 in enumerate(label_sequences): 38 | # Only keep the embeddings for the first h iterations 39 | labels_1 = label_sequences[graph_index_1] 40 | for graph_index_2, graph_2 in tqdm(enumerate(label_sequences[graph_index_1:])): 41 | labels_2 = label_sequences[graph_index_2 + graph_index_1] 42 | # Get cost matrix 43 | ground_distance = 'hamming' if categorical else 'euclidean' 44 | costs = ot.dist(labels_1, labels_2, metric=ground_distance) 45 | 46 | if sinkhorn: 47 | mat = ot.sinkhorn( 48 | np.ones(len(labels_1))/len(labels_1), 49 | np.ones(len(labels_2))/len(labels_2), 50 | costs, 51 | sinkhorn_lambda, 52 | numItermax=50 53 | ) 54 | M[graph_index_1, graph_index_2 + graph_index_1] = np.sum(np.multiply(mat, costs)) 55 | else: 56 | M[graph_index_1, graph_index_2 + graph_index_1] = \ 57 | ot.emd2([], [], costs) 58 | 59 | M = (M + M.T) 60 | return M 61 | 62 | def pairwise_wasserstein_distance(X, node_features = None, num_iterations=3, sinkhorn=False, enforce_continuous=False): 63 | """ 64 | Pairwise computation of the Wasserstein distance between embeddings of the 65 | graphs in X. 66 | args: 67 | X (List[ig.graphs]): List of graphs 68 | node_features (array): Array containing the node features for continuously attributed graphs 69 | num_iterations (int): Number of iterations for the propagation scheme 70 | sinkhorn (bool): Indicates whether sinkhorn approximation should be used 71 | """ 72 | # First check if the graphs are continuous vs categorical 73 | categorical = True 74 | if enforce_continuous: 75 | logging.info('Enforce continous flag is on, using CONTINUOUS propagation scheme.') 76 | categorical = False 77 | elif node_features is not None: 78 | logging.info('Continuous node features provided, using CONTINUOUS propagation scheme.') 79 | categorical = False 80 | else: 81 | for g in X: 82 | if not 'label' in g.vs.attribute_names(): 83 | logging.info('No label attributed to graphs, use degree instead and use CONTINUOUS propagation scheme.') 84 | categorical = False 85 | break 86 | if categorical: 87 | logging.info('Categorically-labelled graphs, using CATEGORICAL propagation scheme.') 88 | 89 | # Embed the nodes 90 | if categorical: 91 | es = WeisfeilerLehman() 92 | node_representations = es.fit_transform(X, num_iterations=num_iterations) 93 | else: 94 | es = ContinuousWeisfeilerLehman() 95 | node_representations = es.fit_transform(X, node_features=node_features, num_iterations=num_iterations) 96 | 97 | # Compute the Wasserstein distance 98 | print("Computing Wasserstein distance between conditions...") 99 | pairwise_distances = _compute_wasserstein_distance(node_representations, sinkhorn=sinkhorn, 100 | categorical=categorical, sinkhorn_lambda=1e-2) 101 | return pairwise_distances 102 | 103 | def wwl(X, node_features=None, num_iterations=3, sinkhorn=False, gamma=None): 104 | """ 105 | Pairwise computation of the Wasserstein Weisfeiler-Lehman kernel for graphs in X. 106 | """ 107 | D_W = pairwise_wasserstein_distance(X, node_features = node_features, 108 | num_iterations=num_iterations, sinkhorn=sinkhorn) 109 | wwl = laplacian_kernel(D_W, gamma=gamma) 110 | return wwl 111 | 112 | 113 | ####################### 114 | # Class implementation 115 | ####################### -------------------------------------------------------------------------------- /src/graphcompass/pl/_WLkernel.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import seaborn as sns 3 | import matplotlib.pyplot as plt 4 | 5 | from pathlib import Path 6 | from anndata import AnnData 7 | from matplotlib.axes import Axes 8 | from matplotlib.figure import Figure 9 | from graphcompass.tl._distance import compare_groups 10 | from typing import Any, Sequence, Tuple, Union 11 | 12 | 13 | def compare_conditions( 14 | adata: AnnData, 15 | library_key="sample_id", 16 | condition_key="status", 17 | control_group="normal", 18 | metric_key="wasserstein_distance", # kernel_matrix or wasserstein_distance 19 | method="wl_kernel", 20 | fig: Union[Figure, None] = None, 21 | ax: Union[Axes, Sequence[Axes], None] = None, 22 | return_ax: bool = False, 23 | figsize: Union[Tuple[float, float], None] = (20,10), 24 | dpi: Union[int, None] = 300, 25 | color: Union[str, list] = "grey", 26 | palette: str = "Set2", 27 | add_sign: bool = False, 28 | save: Union[str, Path, None] = None, 29 | **kwargs: Any, 30 | ) -> Union[Axes, Sequence[Axes], None]: 31 | """ 32 | Plot group comparison for full samples. 33 | 34 | Parameters 35 | ---------- 36 | adata 37 | Annotated data matrix. 38 | library_key 39 | Key in `adata.obs` where the library information is stored. 40 | condition_key 41 | Key in `adata.obs` where the condition information is stored. 42 | control_group 43 | Name of the control group. 44 | metric_key 45 | Key in `adata.uns` where the metric of interest is stored. 46 | method 47 | Method used to calculate the comparison, also a parent key for metric_key in `adata.uns` 48 | fig 49 | Figure object to be used for plotting. 50 | ax 51 | Axes object to be used for plotting. 52 | return_ax 53 | If True, then return the axes object. 54 | figsize 55 | Figure size. 56 | dpi 57 | Figure resolution. 58 | color 59 | Color(s) for the bars in the plot (monocolor). 60 | palette 61 | Palette for the bar plot (multicolor). 62 | add_sign 63 | Significance between pairs of contrasts. 64 | save 65 | Filename under which to save the plot. 66 | **kwargs 67 | Keyword arguments to be passed to plotting functions. 68 | """ 69 | pairwise_similarities = adata.uns[method][metric_key] 70 | 71 | dict_sample_to_status = {} 72 | for sample in adata.obs[library_key].unique(): 73 | dict_sample_to_status[sample] = adata[adata.obs[library_key] == sample].obs[condition_key].values.unique()[0] 74 | 75 | df_for_plot = None 76 | 77 | sample_ids = dict_sample_to_status.keys() 78 | status = dict_sample_to_status.values() 79 | 80 | sample_to_status = pd.DataFrame({"sample_id": sample_ids, "contrast": status}) 81 | disease_status = list(set(sample_to_status.contrast)) 82 | disease_status.remove(control_group) 83 | contrasts = [(control_group, c) for c in disease_status] 84 | 85 | df_for_plot = compare_groups( 86 | pairwise_similarities=pairwise_similarities, 87 | sample_to_contrasts=sample_to_status, 88 | contrasts=contrasts, 89 | output_format="tidy" 90 | ) 91 | 92 | if metric_key == "kernel_matrix": 93 | xlabel = "Kernel matrix values" 94 | elif metric_key == "wasserstein_distance": 95 | xlabel = "Wasserstein distance" 96 | else: 97 | ValueError( 98 | "Parameter 'metric_key' must be of type either kernel_matrix or wasserstein_distance." 99 | ) 100 | 101 | # plot 102 | plt.rcParams["font.size"] = 12 103 | contrasts = df_for_plot["contrast"].unique() 104 | num_contrasts = len(contrasts) 105 | if color: 106 | edgecolor = color 107 | else: 108 | edgecolor = sns.color_palette(palette)[:num_contrasts] 109 | 110 | plt.figure(figsize=figsize, dpi=dpi) 111 | 112 | one_sample_per_contrast = num_contrasts == len(df_for_plot) 113 | if one_sample_per_contrast: 114 | xlabel = xlabel 115 | ylabel = "" 116 | sns.barplot( 117 | data=df_for_plot, 118 | y="contrast", 119 | x="vals", 120 | facecolor='none', edgecolor=edgecolor, 121 | linewidth=3, 122 | color=color, 123 | palette=palette, 124 | ) 125 | else: 126 | ylabel = xlabel 127 | xlabel = "" 128 | ax = sns.boxplot( 129 | data=df_for_plot, 130 | x="contrast", 131 | y="vals", 132 | color='white', width=.5, 133 | ) 134 | if add_sign: 135 | pairs = [] 136 | # defining contrast pairs 137 | for i in range(len(contrasts)): 138 | for j in range(i+1, len(contrasts)): 139 | # Create a tuple and append it to the list 140 | pairs.append((contrasts[i], contrasts[j])) 141 | 142 | from statannot import add_stat_annotation 143 | add_stat_annotation(data=df_for_plot, x="contrast", y="vals", 144 | ax=ax, 145 | box_pairs=pairs, 146 | test='t-test_ind', text_format='star', loc='outside', verbose=2, comparisons_correction=None) 147 | plt.xticks(rotation=90) 148 | 149 | plt.xlabel(xlabel) 150 | plt.ylabel(ylabel) 151 | plt.grid(False) 152 | sns.despine() 153 | plt.tight_layout() 154 | 155 | if save: 156 | plt.savefig(save, dpi=dpi) 157 | 158 | if return_ax: 159 | return ax 160 | else: 161 | plt.show() 162 | -------------------------------------------------------------------------------- /src/graphcompass/pl/__init__.py: -------------------------------------------------------------------------------- 1 | """The plotting module.""" 2 | from . import _distance as distance 3 | from . import _WLkernel as wlkernel 4 | from . import _filtration_curves as filtration_curves 5 | from . import utils -------------------------------------------------------------------------------- /src/graphcompass/pl/_distance.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import numpy as np 3 | import pandas as pd 4 | import seaborn as sns 5 | import matplotlib.pyplot as plt 6 | 7 | from pathlib import Path 8 | 9 | from anndata import AnnData 10 | from matplotlib.axes import Axes 11 | from matplotlib.figure import Figure 12 | 13 | from tqdm import tqdm 14 | from graphcompass.tl._distance import compare_groups 15 | 16 | from typing import Any, Sequence, Tuple, Union 17 | 18 | 19 | def convert_dataframe(df_ct, samples): 20 | num_samples = len(samples) 21 | 22 | df = pd.DataFrame( 23 | data=np.full((num_samples, num_samples), fill_value=np.nan), 24 | columns=samples, 25 | index=samples 26 | ) 27 | 28 | for sample_a, sample_b in itertools.product(samples, samples): 29 | 30 | if sample_a == sample_b: 31 | df[sample_a][sample_b] = 0.0 32 | df[sample_b][sample_a] = 0.0 33 | 34 | if sample_a < sample_b: 35 | df_ct_a = df_ct[df_ct["sample_a"] == sample_a] 36 | df_ct_ab = df_ct_a[df_ct_a["sample_b"] == sample_b] 37 | if len(df_ct_ab) > 0: 38 | value = df_ct_ab["similarity_score"].values[0] 39 | df[sample_a][sample_b] = value 40 | df[sample_b][sample_a] = value 41 | else: 42 | df_ct_a = df_ct[df_ct["sample_b"] == sample_a] 43 | df_ct_ab = df_ct_a[df_ct_a["sample_a"] == sample_b] 44 | if len(df_ct_ab) > 0: 45 | value = df_ct_ab["similarity_score"].values[0] 46 | df[sample_a][sample_b] = value 47 | df[sample_b][sample_a] = value 48 | 49 | return df 50 | 51 | 52 | def compare_conditions( 53 | adata: AnnData, 54 | library_key: str = "sample_id", 55 | condition_key: str = "status", 56 | control_group: str = " normal", 57 | metric_key: str = "pairwise_similarities", 58 | add_ncells_and_density_plots: bool = False, 59 | plot_groups_separately: bool = False, # if False then all in one plot 60 | fig: Union[Figure, None] = None, 61 | ax: Union[Axes, Sequence[Axes], None] = None, 62 | return_ax: bool = False, 63 | figsize: Union[Tuple[float, float], None] = (20, 10), 64 | palette="Greys", 65 | dpi: Union[int, None] = 300, 66 | save: Union[str, Path, None] = None, 67 | vertical: bool = True, 68 | **kwargs: Any, 69 | ) -> Union[Axes, Sequence[Axes], None]: 70 | """ 71 | Plot group comparison for each cell type. 72 | 73 | Parameters 74 | ---------- 75 | adata 76 | Annotated data matrix. 77 | library_key 78 | Key in `adata.obs` where the library information is stored. 79 | condition_key 80 | Key in `adata.obs` where the condition information is stored. 81 | control_group 82 | Name of the control group. 83 | metric_key 84 | Key in `adata.uns` where the metric of interest is stored. 85 | add_ncells_and_density_plots 86 | plot_groups_separately 87 | If True, then each group is plotted separately. If False, then all 88 | groups are plotted in one plot. 89 | fig 90 | Figure object to be used for plotting. 91 | ax 92 | Axes object to be used for plotting. 93 | return_ax 94 | If True, then return the axes object. 95 | figsize 96 | Figure size. 97 | palette 98 | Color palette. 99 | dpi 100 | Figure resolution. 101 | save 102 | Filename under which to save the plot. 103 | vertical 104 | **kwargs 105 | Keyword arguments to be passed to plotting functions. 106 | """ 107 | pairwise_similarities = adata.uns[metric_key] 108 | 109 | dict_sample_to_status = {} 110 | for sample in adata.obs[library_key].unique(): 111 | dict_sample_to_status[sample] = ( 112 | adata[adata.obs[library_key] == sample].obs[condition_key].values.unique()[0] 113 | ) 114 | 115 | df_for_plot = None 116 | for i, ct in tqdm(enumerate(pairwise_similarities.cell_type.unique())): 117 | samples = np.unique( 118 | np.append( 119 | list(pairwise_similarities.sample_a.values), 120 | list(pairwise_similarities.sample_b.values), 121 | ) 122 | ) 123 | df_ct = pairwise_similarities[pairwise_similarities.cell_type == ct] 124 | dataframe = convert_dataframe(df_ct, samples) 125 | sample_ids = dict_sample_to_status.keys() 126 | status = dict_sample_to_status.values() 127 | 128 | sample_to_status = pd.DataFrame( 129 | {"sample_id": sample_ids, "contrast": status} 130 | ) 131 | 132 | disease_status = list(set(sample_to_status.contrast)) 133 | disease_status.remove(control_group) 134 | contrasts = [(control_group, c) for c in disease_status] 135 | 136 | if i > 0: 137 | df = compare_groups( 138 | pairwise_similarities=dataframe, 139 | sample_to_contrasts=sample_to_status, 140 | contrasts=contrasts, 141 | output_format="tidy" 142 | ) 143 | df["cell_type"] = np.full(df.shape[0], ct) 144 | df_for_plot = pd.concat([df_for_plot, df]) 145 | else: 146 | df_for_plot = compare_groups( 147 | pairwise_similarities=dataframe, 148 | sample_to_contrasts=sample_to_status, 149 | contrasts=contrasts, 150 | output_format="tidy" 151 | ) 152 | df_for_plot["cell_type"] = np.full(df_for_plot.shape[0], ct) 153 | 154 | # set figure parameters 155 | # if fig is None and ax is None: 156 | # fig, ax = plt.subplots(figsize=figsize, dpi=dpi) 157 | # elif fig is None and ax is not None: 158 | # fig = ax[0].figure 159 | # elif fig is not None and ax is None: 160 | # ax = fig.gca() 161 | 162 | # plot 163 | plt.rcParams["font.size"] = 12 164 | 165 | # Function to assign color based on value 166 | def get_bar_color(value, max_value, min_value, color): 167 | """ Return color shade based on value. """ 168 | normalized = (value - min_value) / (max_value - min_value) 169 | return plt.cm.get_cmap(color)(normalized) 170 | 171 | width, height = figsize 172 | # return df_for_plot 173 | if plot_groups_separately: 174 | # plot each contrast in a plot 175 | 176 | num_contrasts = len(df_for_plot["contrast"].unique()) 177 | num_rows = int(np.ceil(num_contrasts/2)) 178 | num_cols = 2 179 | 180 | if vertical: 181 | x = "cell_type" 182 | y = "vals" 183 | else: 184 | x = "vals" 185 | y = "cell_type" 186 | 187 | if add_ncells_and_density_plots: 188 | # get ncells and density from adata.uns[metric_key] 189 | df = pd.DataFrame() 190 | properties = pd.DataFrame( 191 | columns=["condition", "sample", "cell_type", "ncells", "density"] 192 | ) 193 | 194 | for i, row in adata.uns[metric_key].iterrows(): 195 | ct = row["cell_type"] 196 | sample_a = row["sample_a"] 197 | condition_a = adata[adata.obs[library_key] == sample_a].obs[condition_key].values[0] 198 | sample_b = row["sample_b"] 199 | condition_b = adata[adata.obs[library_key] == sample_b].obs[condition_key].values[0] 200 | p = properties[properties["cell_type"] == ct] 201 | if len(p) == 0 or len(p[p["sample"] == sample_a]) == 0: 202 | df["condition"] = [condition_a] 203 | df["sample"] = [sample_a] 204 | df["cell_type"] = [ct] 205 | df["ncells"] = [row["ncells_a"]] 206 | df["density"] = [row["density_a"]] 207 | properties = pd.concat([properties, df]) 208 | if len(p) == 0 or len(p[p["sample"] == sample_b]) == 0: 209 | df["condition"] = [condition_b] 210 | df["sample"] = [sample_b] 211 | df["cell_type"] = [ct] 212 | df["ncells"] = [row["ncells_b"]] 213 | df["density"] = [row["density_b"]] 214 | properties = pd.concat([properties, df]) 215 | 216 | fig, axes = plt.subplots( 217 | num_contrasts, 218 | 3, 219 | figsize=(width, height*num_contrasts), 220 | dpi=dpi 221 | ) # Adjust the figsize as needed 222 | y = "cell_type" 223 | x = "vals" 224 | # Adjust this value as needed for padding 225 | fig.subplots_adjust(hspace=0.5) 226 | 227 | for i, contrast in enumerate(df_for_plot["contrast"].unique()): 228 | df_contrast = df_for_plot[df_for_plot["contrast"] == contrast] 229 | # return df_contrast 230 | ax_title = fig.add_subplot(num_contrasts, 1, i + 1) 231 | ax_title.set_title(contrast, fontsize=16, pad=50) 232 | ax_title.axis('off') 233 | 234 | contrast_properties = properties[properties["condition"].isin(contrast.split("_vs_"))] 235 | mean_vals = df_contrast.groupby('cell_type')['vals'].mean() 236 | palette = {ct: get_bar_color(mean_vals[ct], mean_vals.max(), mean_vals.min(), "Greens") for ct in mean_vals.index} 237 | 238 | sns.barplot( 239 | data=df_contrast, 240 | x=x, 241 | y=y, 242 | # errorbar=("pi", 50), capsize=.4, errcolor=".5", 243 | linewidth=1, edgecolor=".5", 244 | palette=palette, 245 | ax=axes[i, 0], 246 | ) 247 | axes[i, 0].set_xlim(0, 1) 248 | axes[i, 0].set_xticks([0, 1]) 249 | axes[i, 1].set_xlabel('Similarity score') 250 | axes[i, 0].set_xticklabels(["Identical graphs", "Maximally different"], rotation=90) 251 | axes[i, 0].set_title('Pairwise similarity') 252 | sns.despine() 253 | 254 | # Plot 2 255 | sns.barplot( 256 | data=contrast_properties, 257 | y=y, 258 | x="ncells", 259 | hue="condition", 260 | palette="tab20", 261 | ax=axes[i, 1] 262 | ) 263 | axes[i, 1].set_ylabel('') 264 | axes[i, 1].legend().remove() 265 | axes[i, 1].set_title('Number of cells') 266 | sns.despine() 267 | 268 | # Plot 3 269 | sns_plot = sns.barplot( 270 | data=contrast_properties, 271 | y=y, 272 | x="density", 273 | hue="condition", 274 | palette="tab20", 275 | ax=axes[i, 2] 276 | ) 277 | # axes[i, 2].set_xticklabels(axes[i, 2].get_xticklabels(), rotation=90) 278 | axes[i, 2].set_ylabel('') 279 | axes[i, 2].set_title('Graph density') 280 | sns_plot.legend(loc='upper center', bbox_to_anchor=(0.0, -0.07), ncol=2) 281 | 282 | sns.despine() 283 | plt.tight_layout() # rect=[0, 0.03, 1, 0.95]) 284 | 285 | else: 286 | fig, ax = plt.subplots(num_rows, num_cols, figsize=figsize, dpi=dpi) 287 | ax = ax.flatten() 288 | # plot 289 | for i, contrast in enumerate(df_for_plot["contrast"].unique()): 290 | df_contrast = df_for_plot[df_for_plot["contrast"] == contrast] 291 | mean_vals = df_contrast.groupby('cell_type')['vals'].mean() 292 | palette = {ct: get_bar_color(mean_vals[ct], mean_vals.max(), mean_vals.min(), "Greens") for ct in mean_vals.index} 293 | 294 | sns.barplot( 295 | data=df_contrast, 296 | x=x, 297 | y=y, 298 | errorbar=("pi", 50), capsize=.4, errcolor=".5", 299 | linewidth=1, edgecolor=".5", # facecolor=(0, 0, 0, 0), 300 | palette=palette, 301 | ax=ax[i] 302 | ) 303 | axes[i, 0].set_ylim(0, 1) 304 | ax[i].set_yticks([0, 1]) 305 | ax[i].set_yticklabels(["Identical graphs", "Maximally different"]) 306 | ax[i].set_ylabel("Similarity score") 307 | ax[i].set_xticklabels(ax[i].get_xticklabels(), rotation=90) 308 | sns.despine() 309 | 310 | ax[i].set_title(' '.join(contrast.split("_"))) 311 | 312 | plt.tight_layout() 313 | 314 | if return_ax: 315 | return ax 316 | else: 317 | plt.show() 318 | 319 | if save: 320 | plt.savefig(save, dpi=dpi) 321 | else: 322 | # plot all contrasts in one plot 323 | result = df_for_plot.groupby(['contrast', 'cell_type'])['vals'].agg(['median', 'var']).reset_index() 324 | 325 | # Rename columns 326 | result.columns = ['contrast', 'cell_type', 'median', 'variance'] 327 | result["median"] = result["median"].fillna(1) 328 | result["variance"] = result["variance"].fillna(0) 329 | 330 | # Function to calculate binned_variance 331 | def calculate_binned_variance(row): 332 | tmp = (1 - row['variance']) * 100 333 | if tmp > 95: 334 | return 20 ** 2 335 | elif tmp > 90: 336 | return 15 ** 2 337 | elif tmp > 80: 338 | return 10 ** 2 339 | else: 340 | return 5 ** 2 341 | 342 | # Add the 'binned_variance' column 343 | result['binned_variance'] = result.apply(calculate_binned_variance, axis=1) 344 | result["contrast"] = [' '.join(contrast.split("_")) for contrast in result["contrast"].values] 345 | 346 | if add_ncells_and_density_plots: 347 | df = pd.DataFrame() 348 | properties = pd.DataFrame(columns=["condition", "sample", "cell_type", "ncells", "density"]) 349 | 350 | for i, row in adata.uns[metric_key].iterrows(): 351 | ct = row["cell_type"] 352 | sample_a = row["sample_a"] 353 | condition_a = adata[adata.obs[library_key] == sample_a].obs[condition_key].values[0] 354 | sample_b = row["sample_b"] 355 | condition_b = adata[adata.obs[library_key] == sample_b].obs[condition_key].values[0] 356 | p = properties[properties["cell_type"] == ct] 357 | if len(p) == 0 or len(p[p["sample"] == sample_a]) == 0: 358 | df["condition"] = [condition_a] 359 | df["sample"] = [sample_a] 360 | df["cell_type"] = [ct] 361 | df["ncells"] = [row["ncells_a"]] 362 | df["density"] = [row["density_a"]] 363 | properties = pd.concat([properties, df]) 364 | if len(p) == 0 or len(p[p["sample"] == sample_b]) == 0: 365 | df["condition"] = [condition_b] 366 | df["sample"] = [sample_b] 367 | df["cell_type"] = [ct] 368 | df["ncells"] = [row["ncells_b"]] 369 | df["density"] = [row["density_b"]] 370 | properties = pd.concat([properties, df]) 371 | 372 | result_ncells = properties.groupby(['condition', 'cell_type'])['ncells'].agg(['median', 'var']).reset_index() 373 | result_ncells.columns = ['condition', 'cell_type', 'median', 'variance'] 374 | result_density = properties.groupby(['condition', 'cell_type'])['density'].agg(['median', 'var']).reset_index() 375 | result_density.columns = ['condition', 'cell_type', 'median', 'variance'] 376 | 377 | result_ncells["median"] = result_ncells["median"].fillna(1) 378 | result_ncells["variance"] = result_ncells["variance"].fillna(0) 379 | result_density["median"] = result_density["median"].fillna(1) 380 | result_density["variance"] = result_density["variance"].fillna(0) 381 | 382 | result_ncells['binned_variance'] = result_ncells.apply(calculate_binned_variance, axis=1) 383 | result_density['binned_variance'] = result_density.apply(calculate_binned_variance, axis=1) 384 | 385 | # Plotting 386 | from mpl_toolkits.axes_grid1 import make_axes_locatable 387 | 388 | if add_ncells_and_density_plots: 389 | n_subplots = 3 390 | fig, axes = plt.subplots( 391 | n_subplots, 1, figsize=(width, height * n_subplots), dpi=dpi 392 | ) 393 | ax = axes[0] 394 | else: 395 | n_subplots = 1 396 | fig, ax = plt.subplots( 397 | n_subplots, 1, figsize=(width, height * n_subplots), dpi=dpi 398 | ) 399 | 400 | # Plot 1 401 | im1 = ax.scatter( 402 | x=result["cell_type"], 403 | y=result["contrast"], 404 | c=result["median"], 405 | s=result["binned_variance"], 406 | cmap=palette, 407 | vmin=0.0, vmax=1.0 408 | ) 409 | 410 | ax.tick_params(axis='x', labelrotation=90) 411 | ax.set_axisbelow(True) 412 | ax.grid(color='gray', linestyle='dashed', which='both', axis='both') 413 | ax.spines['top'].set_visible(False) 414 | ax.spines['right'].set_visible(False) 415 | ax.spines['bottom'].set_visible(True) 416 | ax.spines['left'].set_visible(True) 417 | ax.set_title("Pairwise similarity") 418 | 419 | # Adding colorbar to the right of the first plot 420 | divider1 = make_axes_locatable(ax) 421 | cax1 = divider1.append_axes('right', size='3%', pad=0.05) 422 | cbar1 = fig.colorbar(im1, cax=cax1, orientation='vertical') 423 | 424 | cbar1.set_ticks([0.0, 1.0]) 425 | cbar1.set_ticklabels(['Identical Graphs', 'Maximally Different']) 426 | # Define sizes and labels for the variance legend 427 | sizes = [5**2, 10**2, 15**2, 20**2] # Sizes from low to high variance 428 | labels = ['High Variance', '', '', 'Low Variance'] # Corresponding labels 429 | 430 | # Create legend elements for the sizes with labels 431 | legend_elements = [plt.Line2D([0], [0], marker='o', color='w', markerfacecolor='k', 432 | markersize=np.sqrt(size), label=label) 433 | for size, label in zip(sizes, labels)] # Only create labeled elements 434 | 435 | # Add a legend outside the plot, to the right 436 | ax.legend(handles=legend_elements, loc='center left', bbox_to_anchor=(1.1, 0.5), frameon=False) 437 | 438 | if add_ncells_and_density_plots: 439 | # Plot 2 440 | im2 = axes[1].scatter( 441 | x=result_density["cell_type"], 442 | y=result_density["condition"], 443 | s=result_density["median"]*1e4, 444 | c=result_density["binned_variance"], # should be variance 445 | cmap=palette, 446 | vmin=5, vmax=20**2 447 | ) 448 | 449 | axes[1].tick_params(axis='x', labelrotation=90) 450 | axes[1].set_axisbelow(True) 451 | axes[1].grid(color='gray', linestyle='dashed', which='both', axis='both') 452 | axes[1].spines['top'].set_visible(False) 453 | axes[1].spines['right'].set_visible(False) 454 | axes[1].spines['bottom'].set_visible(True) 455 | axes[1].spines['left'].set_visible(True) 456 | axes[1].set_title("Graph density") 457 | 458 | divider2 = make_axes_locatable(axes[1]) 459 | cax2 = divider2.append_axes('right', size='3%', pad=0.05) 460 | cbar2 = fig.colorbar(im2, cax=cax2, orientation='vertical') 461 | cbar2.set_ticks([5, 20**2]) # should be set correctly 462 | cbar2.set_ticklabels(['High variance', 'Low variance']) 463 | 464 | # Plot 3 465 | im3 = axes[2].scatter( 466 | x=result_ncells["cell_type"], 467 | y=result_ncells["condition"], 468 | s=result_ncells["median"], 469 | c=result_ncells["binned_variance"], 470 | cmap=palette, 471 | vmin=5, vmax=20**2 472 | ) 473 | 474 | axes[2].tick_params(axis='x', labelrotation=90) 475 | axes[2].set_axisbelow(True) 476 | axes[2].grid(color='gray', linestyle='dashed', which='both', axis='both') 477 | axes[2].spines['top'].set_visible(False) 478 | axes[2].spines['right'].set_visible(False) 479 | axes[2].spines['bottom'].set_visible(True) 480 | axes[2].spines['left'].set_visible(True) 481 | axes[2].set_title("Number of cells") 482 | 483 | # Adding a shared colorbar for the second and third plots 484 | # Since the colorbar settings are the same for both, we can create 485 | # one common colorbar 486 | divider23 = make_axes_locatable(axes[2]) 487 | cax23 = divider23.append_axes('right', size='3%', pad=0.05) 488 | cbar23 = fig.colorbar(im3, cax=cax23, orientation='vertical') 489 | cbar23.set_ticks([5, 20**2]) 490 | cbar23.set_ticklabels(['High variance', 'Low variance']) 491 | 492 | plt.tight_layout() # Adjust layout to fit everything neatly 493 | 494 | if save: 495 | plt.savefig(save, dpi=dpi) 496 | 497 | if return_ax: 498 | return ax 499 | else: 500 | plt.show() 501 | -------------------------------------------------------------------------------- /src/graphcompass/pl/_filtration_curves.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | import matplotlib.pyplot as plt 4 | 5 | from pathlib import Path 6 | 7 | from anndata import AnnData 8 | 9 | import matplotlib 10 | from matplotlib.axes import Axes 11 | 12 | from typing import Any, Sequence, Tuple, Union 13 | 14 | 15 | def compare_conditions( 16 | adata: AnnData, 17 | node_labels: set, 18 | metric_key: str = "filtration_curves", 19 | return_ax: bool = False, 20 | figsize: Union[Tuple[float, float], None] = None, 21 | dpi: Union[int, None] = 300, 22 | palette: str = "Set2", 23 | right: Union[int, float, None] = None, 24 | save: Union[str, Path, None] = None, 25 | **kwargs: Any, 26 | ) -> Union[Axes, Sequence[Axes], None]: 27 | """ 28 | Plot group comparison for full samples. 29 | 30 | Parameters 31 | ---------- 32 | adata 33 | Annotated data matrix. 34 | node_labels 35 | Set of node labels. 36 | metric_key 37 | Key in `adata.uns` where the metric of interest is stored. 38 | return_ax 39 | If True, then return the axes object. 40 | figsize 41 | Figure size. 42 | dpi 43 | Figure resolution. 44 | palette 45 | matplotlib colormap name. 46 | right 47 | Right x-axis limit. 48 | save 49 | Filename under which to save the plot. 50 | **kwargs 51 | Keyword arguments to be passed to plotting functions. 52 | """ 53 | filtration_curves = adata.uns[metric_key]["curves"] 54 | threshold_vals = adata.uns[metric_key]["threshold_vals"] 55 | 56 | n_node_labels = len(node_labels) 57 | 58 | plt.rcParams["font.size"] = 12 59 | 60 | # Create subplots 61 | if figsize is not None: 62 | fig, axes = plt.subplots( 63 | nrows=1, ncols=n_node_labels, figsize=figsize 64 | ) 65 | else: 66 | fig, axes = plt.subplots( 67 | nrows=1, ncols=n_node_labels, figsize=(8 * n_node_labels, 6) 68 | ) 69 | 70 | # Colormap for categorical values in column 'graph_label' 71 | cmap = matplotlib.colormaps[palette] 72 | 73 | # Get unique categories in 'graph_label' 74 | unique_categories = np.unique( 75 | np.concatenate( 76 | [df["graph_label"].unique() for df in filtration_curves.values()] 77 | ) 78 | ) 79 | 80 | # Create a color map dictionary for each unique category 81 | category_color_map = { 82 | category: cmap(i) for i, category in enumerate(unique_categories) 83 | } 84 | 85 | # Check if axes is an array (multiple subplots) or a single axis 86 | is_single_axis = not isinstance(axes, np.ndarray) 87 | 88 | # Iterate over each DataFrame and cell type 89 | for df in filtration_curves.values(): 90 | label = pd.unique(df.graph_label) 91 | assert label.size == 1 92 | for i, key in enumerate(node_labels): 93 | ax = axes if is_single_axis else axes[i] 94 | try: 95 | ax.step( 96 | df.weight, 97 | df[key], 98 | where='pre', 99 | label=label[0], 100 | color=category_color_map[label[0]], 101 | alpha=0.15 102 | ) 103 | except KeyError: 104 | continue 105 | 106 | # Create mean filtration curves 107 | combined_df = pd.concat(filtration_curves.values()) 108 | grouped_df = combined_df.groupby(['graph_label', 'weight']) 109 | average_df = grouped_df.mean().reset_index() 110 | 111 | for i, key in enumerate(node_labels): 112 | modify_plot( 113 | axes, 114 | i, 115 | key, 116 | average_df, 117 | category_color_map, 118 | threshold_vals, 119 | is_single_axis, 120 | right 121 | ) 122 | 123 | plt.tight_layout() 124 | if save is not None: 125 | plt.savefig(save, dpi=dpi) 126 | if return_ax: 127 | return axes 128 | else: 129 | plt.show() 130 | 131 | 132 | def modify_plot( 133 | axes: Union[matplotlib.axes.Axes, np.ndarray], 134 | i: int, 135 | key: str, 136 | average_df: pd.DataFrame, 137 | category_color_map: dict, 138 | threshold_vals: np.ndarray, 139 | is_single_axis: bool = False, 140 | right: Union[int, float, None] = None 141 | ): 142 | # If it's a single axis, we don't need to index into axes 143 | ax = axes if is_single_axis else axes[i] 144 | 145 | # Plot mean filtration curves 146 | for graph_label in pd.unique(average_df.graph_label): 147 | df = average_df[average_df.graph_label == graph_label] 148 | ax.step( 149 | df.weight, 150 | df[key], 151 | where="pre", 152 | label=graph_label, 153 | color=category_color_map[graph_label] 154 | ) 155 | 156 | # Set custom values on the x-axis 157 | custom_xticks = [round(val, 1) for val in threshold_vals] 158 | ax.set_xticks(custom_xticks) 159 | 160 | # Set maximum value for the x-axis 161 | if right is not None: 162 | ax.set_xlim(left=0, right=right) 163 | 164 | # Set title 165 | ax.set_title(f'Step Plot for Column {key}') 166 | 167 | # Set x-axis label 168 | ax.set_xlabel('Edge weight threshold') 169 | 170 | # Set y-axis label 171 | ax.set_ylabel('Cell count') 172 | 173 | # Add legend 174 | handles, labels = ax.get_legend_handles_labels() 175 | by_label = dict(zip(labels, handles)) 176 | ax.legend(by_label.values(), by_label.keys(), title='Graph label', loc='upper right') 177 | -------------------------------------------------------------------------------- /src/graphcompass/pl/utils.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import numpy as np 3 | import pandas as pd 4 | import seaborn as sns 5 | import matplotlib.pyplot as plt 6 | 7 | import scanpy as sc 8 | import squidpy as sq 9 | 10 | 11 | from pathlib import Path 12 | 13 | from anndata import AnnData 14 | from matplotlib.axes import Axes 15 | from matplotlib.colors import Colormap 16 | from matplotlib.figure import Figure 17 | 18 | from tqdm import tqdm 19 | from graphcompass.tl._distance import compare_groups 20 | 21 | from typing import Any, Callable, List, Mapping, Optional, Sequence, Tuple, Union 22 | 23 | 24 | 25 | # plot graphs for specific cell types in specific library_key 26 | 27 | def graphs_of_cells( 28 | adata: AnnData, 29 | library_key: str, 30 | cluster_key: str, 31 | 32 | samples: Union[List[str], str] = None, 33 | cell_type: Union[List[str], str] = None, 34 | 35 | connectivity_key: str = "spatial_connectivities", 36 | 37 | return_ax: bool = False, 38 | figsize: Union[Tuple[float, float], None] = (7,30), 39 | dpi: Union[int, None] = 300, 40 | save: Union[str, Path, None] = None, 41 | **kwargs: Any 42 | ) -> Union[Axes, Sequence[Axes], None]: 43 | """ 44 | Plot group comparison for each cell type. 45 | 46 | Parameters 47 | ---------- 48 | adata 49 | Annotated data matrix. 50 | library_key 51 | Key in `adata.obs` where the library information is stored. 52 | cluster_key 53 | Key in `adata.obs` where the cluster information is stored. 54 | cell_type 55 | List of cell types to be plotted. 56 | regions 57 | List of regions to be plotted. 58 | fig 59 | Figure object to be used for plotting. 60 | ax 61 | Axes object to be used for plotting. 62 | return_ax 63 | If True, then return the axes object. 64 | figsize 65 | Figure size. 66 | dpi 67 | Figure resolution. 68 | save 69 | Filename under which to save the plot. 70 | **kwargs 71 | Keyword arguments to be passed to plotting functions. 72 | """ 73 | 74 | if samples is None: 75 | samples = adata.obs[library_key].unique() 76 | if cell_type is None: 77 | cell_type = adata.obs[cluster_key].unique() 78 | 79 | ncols=1 80 | nrows=len(samples) 81 | fig, axs = plt.subplots(nrows, ncols, figsize=figsize, dpi=dpi) 82 | 83 | for i, sample in enumerate(samples): 84 | 85 | adata_sample = adata[adata.obs[library_key] == sample] 86 | adata_sample = adata_sample[adata_sample.obs[cluster_key].isin(cell_type)] 87 | 88 | 89 | sq.pl.spatial_scatter( 90 | adata_sample, 91 | library_key=library_key, 92 | library_id=sample, 93 | connectivity_key=connectivity_key, 94 | color=cluster_key, 95 | shape=None, 96 | ax=axs[i], 97 | size=10, 98 | ) 99 | axs[i].set_title(sample) 100 | 101 | plt.tight_layout() 102 | if save: 103 | plt.savefig(save, dpi=dpi) 104 | 105 | if return_ax: 106 | return axs 107 | else: 108 | plt.show() 109 | 110 | -------------------------------------------------------------------------------- /src/graphcompass/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theislab/graphcompass/8bdd09bb0bbb3590ab2ff78ad17ee5056bae3fb6/src/graphcompass/py.typed -------------------------------------------------------------------------------- /src/graphcompass/tl/_WLkernel.py: -------------------------------------------------------------------------------- 1 | """Functions for graph comparisons using the Weisfeiler-Lehman Graph kernel method.""" 2 | 3 | from __future__ import annotations 4 | 5 | import numpy as np 6 | import pandas as pd 7 | import scipy 8 | from tqdm import tqdm 9 | from anndata import AnnData 10 | from graphcompass.tl.utils import _calculate_graph, _get_igraph 11 | from graphcompass.imports.wwl_package import wwl, pairwise_wasserstein_distance 12 | 13 | 14 | def compare_conditions( 15 | adata: AnnData, 16 | library_key: str = "sample", 17 | cluster_key: str = "cell_type", 18 | cell_type_keys: list = None, 19 | compute_spatial_graphs: bool = True, 20 | num_iterations: int = 3, 21 | kwargs_nhood_enrich={}, 22 | kwargs_spatial_neighbors={}, 23 | copy: bool = False, 24 | **kwargs, 25 | ) -> AnnData: 26 | """ 27 | Compares conditions based on entire spatial graphs using the WWL Kernel. 28 | 29 | Parameters 30 | ---------- 31 | adata 32 | Annotated data object. 33 | library_key 34 | If multiple `library_id`, column in :attr:`anndata.AnnData.obs` 35 | which stores mapping between ``library_id`` and obs. 36 | cluster_key 37 | Key in :attr:`anndata.AnnData.obs` where clustering is stored. 38 | cell_type_keys 39 | List of keys in :attr:`anndata.AnnData.obs` where cell types are stored. 40 | compute_spatial_graphs 41 | Set to False if spatial graphs have been calculated or `sq.gr.spatial_neighbors` has already been run before. 42 | kwargs_nhood_enrich 43 | Additional arguments passed to :func:`squidpy.gr.nhood_enrichment` in `graphcompass.tl.utils._calculate_graph`. 44 | kwargs_spatial_neighbors 45 | Additional arguments passed to :func:`squidpy.gr.spatial_neighbors` in `graphcompass.tl.utils._calculate_graph`. 46 | kwargs 47 | Additional arguments passed to :func:`graphcompass.tl._calculate_graph`. 48 | copy 49 | Whether to return a copy of the Wasserstein distance object. 50 | """ 51 | if compute_spatial_graphs: 52 | print("Computing spatial graphs...") 53 | _calculate_graph( 54 | adata=adata, 55 | library_key=library_key, 56 | cluster_key=cluster_key, 57 | kwargs_nhood_enrich=kwargs_nhood_enrich, 58 | kwargs_spatial_neighbors=kwargs_spatial_neighbors, 59 | **kwargs 60 | ) 61 | else: 62 | print("Spatial graphs were previously computed. Skipping computing spatial graphs...") 63 | 64 | samples = adata.obs[library_key].unique() 65 | 66 | graphs = [] 67 | node_features = [] 68 | cell_types = [] 69 | 70 | adata.uns["wl_kernel"] = {} 71 | adata.uns["wl_kernel"] = {} 72 | if cell_type_keys is not None: 73 | for cell_type_key in cell_type_keys: 74 | graphs = [] 75 | node_features = [] 76 | status = [] 77 | cell_types = [] 78 | adata.uns["wl_kernel"] = {} 79 | adata.uns["wl_kernel"] = {} 80 | 81 | adata.uns["wl_kernel"][cell_type_key] = {} 82 | adata.uns["wl_kernel"][cell_type_key] = {} 83 | for sample in samples: 84 | adata_sample = adata[adata.obs[library_key] == sample] 85 | status.append(adata_sample.obs[library_key][0]) 86 | graphs.append(_get_igraph(adata_sample, cluster_key=None)) 87 | 88 | node_features.append(np.array(adata_sample.obs[cell_type_key].values)) 89 | cell_types.append(np.full(len(adata_sample.obs[cell_type_key]), cell_type_key)) 90 | 91 | node_features = np.array(node_features, dtype=object) 92 | 93 | wasserstein_distance = pairwise_wasserstein_distance(graphs, node_features=node_features, num_iterations=num_iterations) 94 | adata.uns["wl_kernel"][cell_type_key]["wasserstein_distance"] = pd.DataFrame(wasserstein_distance, columns=samples, index=samples) 95 | 96 | else: 97 | print("Defining node features...") 98 | for sample in tqdm(samples): 99 | adata_sample = adata[adata.obs[library_key] == sample] 100 | graphs.append( 101 | _get_igraph( 102 | adata_sample, 103 | cluster_key=None 104 | ) 105 | ) 106 | features = adata_sample.X 107 | if isinstance(features, scipy.sparse._csr.csr_matrix): 108 | features = features.toarray() 109 | node_features.append(np.array(features)) 110 | 111 | node_features = np.array(node_features, dtype=object) 112 | wasserstein_distance = pairwise_wasserstein_distance(graphs, node_features=node_features, num_iterations=num_iterations) 113 | adata.uns["wl_kernel"]["wasserstein_distance"] = pd.DataFrame(wasserstein_distance, columns=samples, index=samples) 114 | 115 | print("Done!") 116 | if copy: 117 | return wasserstein_distance -------------------------------------------------------------------------------- /src/graphcompass/tl/__init__.py: -------------------------------------------------------------------------------- 1 | """The spatial DE methods module.""" 2 | from . import _distance as distance 3 | from . import _WLkernel as wlkernel 4 | from . import _filtration_curves as filtration_curves 5 | from . import utils -------------------------------------------------------------------------------- /src/graphcompass/tl/_distance.py: -------------------------------------------------------------------------------- 1 | """Functions for portrait- and diffusion-based graph comparisons.""" 2 | 3 | from __future__ import annotations 4 | 5 | import scipy 6 | import os 7 | import netlsd 8 | import tempfile 9 | 10 | import itertools 11 | 12 | import numpy as np 13 | import pandas as pd 14 | 15 | import networkx as nx 16 | 17 | from tqdm import tqdm 18 | 19 | 20 | from typing import ( 21 | Optional, Union, # noqa: F401 22 | ) 23 | 24 | from joblib import delayed, Parallel 25 | 26 | 27 | from numba import njit, prange # noqa: F401 28 | 29 | from anndata import AnnData 30 | 31 | from scipy.stats import entropy 32 | 33 | from squidpy._docs import d, inject_docs 34 | 35 | from graphcompass.tl.utils import _calculate_graph 36 | 37 | 38 | def compare_conditions( 39 | adata: AnnData, 40 | library_key: str = "sample", 41 | cluster_key: str = "cell_type", 42 | contrasts: list = None, 43 | cell_types: list = None, 44 | method: str = "portrait", 45 | portrait_flavour: Optional[str] = "python", 46 | max_depth: Optional[int] = 500, 47 | compute_spatial_graphs: bool = True, 48 | kwargs_nhood_enrich = {}, 49 | kwargs_spatial_neighbors = {}, 50 | copy: bool = False, 51 | **kwargs, 52 | ) -> AnnData: 53 | # 1. calculate graph 54 | # 2. calculate graph distances 55 | # 3. calculate graph similarities 56 | # 4. calculate graph similarities for contrasts 57 | # in parallel manner 58 | 59 | """ 60 | Compares conditions based on cell-type-specific subgraphs using distance methods. 61 | 62 | Parameters: 63 | ------------ 64 | adata 65 | Annotated data object. 66 | library_key 67 | If multiple `library_id`, column in :attr:`anndata.AnnData.obs` 68 | which stores mapping between ``library_id`` and obs. 69 | cluster_key 70 | Key in :attr:`anndata.AnnData.obs` where clustering is stored. 71 | contrasts 72 | List of tuples or lists defining which sample groups to compare. 73 | method 74 | Whether to use network portrait divergence (method = 'portrait') or diffusion (method = 'diffusion'). 75 | portrait_flavour 76 | Whether to use the Python or C++ implementation of network portrait divergence when method is 'portrait'. 77 | max_depth 78 | Depth limit of the breadth-first search when method is 'portrait'. 79 | compute_spatial_graphs 80 | Set to False if spatial graphs have been calculated or `sq.gr.spatial_neighbors` has already been run before. 81 | kwargs_nhood_enrich 82 | Additional arguments passed to :func:`squidpy.gr.nhood_enrichment` in `graphcompass.tl.utils._calculate_graph`. 83 | kwargs_spatial_neighbors 84 | Additional arguments passed to :func:`squidpy.gr.spatial_neighbors` in `graphcompass.tl.utils._calculate_graph`. 85 | kwargs 86 | Additional arguments passed to :func:`graphcompass.tl._calculate_graph`. 87 | copy 88 | Whether to return a copy of the pairwise similarities object. 89 | """ 90 | 91 | if not isinstance(adata, AnnData): 92 | raise TypeError("Parameter 'adata' must be an AnnData object.") 93 | 94 | if copy: 95 | adata = adata.copy() 96 | 97 | # calculate graph 98 | 99 | if compute_spatial_graphs: 100 | print("Computing spatial graphs...") 101 | _calculate_graph( 102 | adata, 103 | library_key=library_key, 104 | cluster_key=cluster_key, 105 | kwargs_nhood_enrich=kwargs_nhood_enrich, 106 | kwargs_spatial_neighbors=kwargs_spatial_neighbors, 107 | **kwargs 108 | ) 109 | else: 110 | print("Spatial graphs were previously computed. Skipping computing spatial graphs...") 111 | # calculate graph distances 112 | print("Computing graph similarities...") 113 | pairwise_similarities = _calculate_graph_distances( 114 | adata, 115 | library_key=library_key, 116 | cluster_key=cluster_key, 117 | cell_types=cell_types, 118 | method=method, 119 | portrait_flavour=portrait_flavour, 120 | max_depth=max_depth 121 | ) 122 | 123 | # insert pairwise similarities into adata 124 | adata.uns["pairwise_similarities"] = pairwise_similarities 125 | 126 | print("Done!") 127 | if copy: 128 | return pairwise_similarities 129 | 130 | 131 | def _calculate_graph_distances( 132 | adata: AnnData, 133 | library_key: str = "sample", 134 | cluster_key: str = "cell_type", 135 | cell_types: list = None, 136 | n_cell_types_per_graph: int = 1, 137 | method: str = "portrait", 138 | portrait_flavour: Optional[str] = "python", 139 | max_depth: Optional[int] = 500 140 | ) -> pd.DataFrame: 141 | 142 | if not isinstance(adata, AnnData): 143 | raise TypeError("Parameter 'adata' must be an AnnData object.") 144 | 145 | if cell_types is None: 146 | cell_types = adata.obs[cluster_key].unique().tolist() 147 | # TODO: add parameter to specify number of cell types allowed in a graph (default=1) 148 | # find all combinations of cell types with n = n_cell_types_per_graph 149 | # if n_cell_types_per_graph > 1: 150 | # # add cell type combination 151 | # cell_types = itertools.product(cell_types, cell_types) 152 | 153 | if not isinstance(cell_types, list): 154 | raise TypeError("Parameter 'cell_types' must be a list.") 155 | 156 | samples = adata.obs[library_key].unique().tolist() 157 | 158 | 159 | # Pre-allocate memory for results 160 | results = [] 161 | 162 | # Utilize all available CPU cores 163 | n_jobs = 4 164 | 165 | # Loop over cell types and calculate graph distances 166 | for cell_type in tqdm(cell_types): 167 | out = Parallel(n_jobs=n_jobs)( 168 | delayed(_calculate_graph_distance)( 169 | adata=adata, 170 | sample_a=sample_a, 171 | sample_b=sample_b, 172 | cell_type=cell_type, 173 | library_key=library_key, 174 | cluster_key=cluster_key, 175 | method=method, 176 | portrait_flavour=portrait_flavour, 177 | max_depth=max_depth 178 | ) 179 | for sample_a, sample_b in itertools.combinations(samples, 2) 180 | ) 181 | # Collect results 182 | results.extend(out) 183 | 184 | # Create DataFrame from results 185 | column_names = ["sample_a", "sample_b", "cell_type", "ncells_a", "ncells_b", "density_a", "density_b", "similarity_score"] 186 | pairwise_similarities = pd.DataFrame(results, columns=column_names).dropna() 187 | 188 | return pairwise_similarities 189 | 190 | 191 | def _calculate_graph_distance( 192 | adata: AnnData, 193 | sample_a: str, 194 | sample_b: str, 195 | cell_type: Union[str, list], # can be one cell type or list of cell types 196 | library_key: str, 197 | cluster_key: str, 198 | method: str, 199 | portrait_flavour: Optional[str] = "python", 200 | max_depth: Optional[int] = 500 201 | ) -> tuple[str, str, float, str]: 202 | """ 203 | Calculates the similarity between two neighborhood graphs, taking into account graph emptiness and cell density. 204 | """ 205 | if not isinstance(adata, AnnData): 206 | raise TypeError("Parameter 'adata' must be an AnnData object.") 207 | 208 | adata_sample_a = adata[adata.obs[library_key] == sample_a] 209 | adata_sample_b = adata[adata.obs[library_key] == sample_b] 210 | 211 | if isinstance(cell_type, str): 212 | adata_a = adata_sample_a[adata_sample_a.obs[cluster_key] == cell_type] 213 | adata_b = adata_sample_b[adata_sample_b.obs[cluster_key] == cell_type] 214 | elif isinstance(cell_type, list): 215 | adata_a = adata_sample_a[adata_sample_a.obs[cluster_key].isin(cell_type)] 216 | adata_b = adata_sample_b[adata_sample_b.obs[cluster_key].isin(cell_type)] 217 | else: 218 | raise ValueError( 219 | "Parameter 'cell_type' must be of type str or list." 220 | ) 221 | 222 | ncells_a = len(adata_a) 223 | ncells_b = len(adata_b) 224 | 225 | graph_a = adata_a.obsp['spatial_connectivities'] 226 | graph_b = adata_b.obsp['spatial_connectivities'] 227 | 228 | # Integrate cell density into the comparison 229 | density_a = _calculate_graph_density(graph_a) 230 | density_b = _calculate_graph_density(graph_b) 231 | 232 | # Check for empty graphs 233 | if graph_a.size == 0 or graph_b.size == 0: 234 | # Handle empty graph case; e.g., assign maximum dissimilarity 235 | return (sample_a, sample_b, cell_type, ncells_a, ncells_b, density_a, density_b, 1.0 if graph_a.size != graph_b.size else 0.0) 236 | 237 | similarity_score = compare_graphs(graph_a, graph_b, method, portrait_flavour, max_depth) 238 | 239 | return (sample_a, sample_b, cell_type, ncells_a, ncells_b, density_a, density_b, similarity_score) 240 | 241 | 242 | def _calculate_graph_density(graph: Union[nx.Graph, scipy.sparse._csr.csr_matrix]) -> float: 243 | """ 244 | Calculates a density metric for a graph. 245 | """ 246 | if isinstance(graph, scipy.sparse._csr.csr_matrix): 247 | graph = nx.from_scipy_sparse_array(graph) 248 | 249 | num_nodes = graph.number_of_nodes() 250 | num_edges = graph.number_of_edges() 251 | 252 | if num_nodes > 1: 253 | # Example density calculation 254 | density = num_edges / (num_nodes * (num_nodes - 1)) 255 | else: 256 | density = 0 257 | 258 | return density 259 | 260 | 261 | def compare_graphs(graph_a: Union[nx.Graph, scipy.sparse._csr.csr_matrix], 262 | graph_b: Union[nx.Graph, scipy.sparse._csr.csr_matrix], 263 | method: str, 264 | portrait_flavour: Optional[str] = "python", 265 | max_depth: Optional[int] = 500 266 | ) -> float: 267 | """ 268 | Calculates the similarity between two neighborhood graphs. 269 | 270 | Parameters: 271 | ------------ 272 | graph_a 273 | First squidpy-computed neighborhood graph. 274 | graph_b 275 | Second squidpy-computed neighborhood graph. 276 | method (str) 277 | Whether to use network portrait divergence (method = 'portrait') or diffusion (method = 'diffusion'). 278 | portrait_flavour (str) 279 | Whether to use the Python or C++ implementation of network portrait divergence when method is 'portrait'. 280 | max_depth (int) 281 | Depth limit of the breadth-first search when method is 'portrait'. 282 | Returns 283 | ------- 284 | If 'method' is portrait, a single float is returned. If graphs are identical, 0 is returned. If graphs are maximally different, 1 is returned. 285 | """ 286 | if not method in ["diffusion", "portrait"]: 287 | raise ValueError("Parameter 'method' must be either 'diffusion' or 'portrait'.") 288 | 289 | if portrait_flavour not in ["python", "cpp"]: 290 | raise ValueError( 291 | "Parameter 'portrait_flavour' must be either 'python' (default) or 'cpp'." 292 | ) 293 | 294 | if method == "portrait": 295 | if isinstance(graph_a, scipy.sparse._csr.csr_matrix): 296 | graph_a = nx.from_scipy_sparse_array(graph_a) 297 | 298 | if isinstance(graph_b, scipy.sparse._csr.csr_matrix): 299 | graph_b = nx.from_scipy_sparse_array(graph_b) 300 | 301 | similarity_score = _calculate_portrait_divergence(graph_a, graph_b, portrait_flavour, max_depth) 302 | 303 | return similarity_score 304 | 305 | elif method == "diffusion": 306 | descriptor_a = _diffusion_featurization(graph_a) 307 | descriptor_b = _diffusion_featurization(graph_b) 308 | 309 | similarity_score = netlsd.compare(descriptor_a, descriptor_b) 310 | 311 | return similarity_score 312 | 313 | 314 | def compare_groups( 315 | pairwise_similarities: pd.DataFrame, 316 | sample_to_contrasts: pd.DataFrame, 317 | contrasts: list, 318 | output_format: str = "tidy" 319 | ) -> Union[pd.DataFrame, dict]: 320 | """ 321 | Extracts the similarities between two contrasted groups of samples. 322 | 323 | Parameters: 324 | ------------ 325 | pairwise_similarities 326 | pandas.DataFrame containing all pairwise similarity scores. 327 | sample_to_contrasts 328 | pandas.DataFrame establishing which sample_ids correspond to which contrast. 329 | contrasts 330 | List of tuples or lists defining which sample groups to compare. 331 | output_format 332 | Whether to return a tidy pandas.DataFrame or a dict. 333 | Returns 334 | ------- 335 | All similarity scores for the defined contrasts. 336 | """ 337 | 338 | if not isinstance(pairwise_similarities, pd.DataFrame): 339 | raise TypeError("Parameter 'pairwise_similarities' must be a pandas.DataFrame.") 340 | 341 | if not isinstance(sample_to_contrasts, pd.DataFrame): 342 | raise TypeError("Parameter 'sample_to_contrasts' must be a pandas.DataFrame.") 343 | 344 | if not all(c in ["sample_id", "contrast"] for c in sample_to_contrasts.columns): 345 | raise TypeError( 346 | "Parameter 'sample_to_contrasts' must have the two columns 'sample_id' and 'contrast'." 347 | ) 348 | 349 | if not isinstance(contrasts, list): 350 | raise TypeError("Parameter 'contrasts' must be a list.") 351 | 352 | sample_ids = pairwise_similarities.columns 353 | 354 | if not all( 355 | [ 356 | sample_id in sample_to_contrasts.sample_id.tolist() 357 | for sample_id in sample_ids 358 | ] 359 | ): 360 | raise ValueError( 361 | "All samples in 'pairwise_similarities' must also be found in 'sample_to_contrasts'." 362 | ) 363 | 364 | if output_format not in ["tidy", "dict"]: 365 | raise ValueError("Parameter 'output_format' must be either 'tidy' or 'dict'.") 366 | 367 | if output_format == "tidy": 368 | output = pd.DataFrame() 369 | elif output_format == "dict": 370 | output = {} 371 | 372 | for contrast_a, contrast_b in contrasts: 373 | contrast_a_sample_ids = sample_to_contrasts.loc[ 374 | sample_to_contrasts.contrast == contrast_a 375 | ].sample_id.tolist() 376 | contrast_b_sample_ids = sample_to_contrasts.loc[ 377 | sample_to_contrasts.contrast == contrast_b 378 | ].sample_id.tolist() 379 | 380 | vals = pairwise_similarities.loc[ 381 | contrast_a_sample_ids, contrast_b_sample_ids 382 | ].values 383 | vals = [item for sublist in vals for item in sublist] 384 | 385 | if output_format == "tidy": 386 | output = pd.concat( 387 | [ 388 | output, 389 | pd.DataFrame( 390 | { 391 | "contrast": [f"{contrast_a} vs {contrast_b}"] * len(vals), 392 | "vals": vals, 393 | } 394 | ), 395 | ] 396 | ) 397 | 398 | elif output_format == "dict": 399 | output[f"{contrast_a} vs {contrast_b}"] = vals 400 | return output 401 | 402 | 403 | def _diffusion_featurization( 404 | adjacency_matrix: Union[np.ndarray, scipy.sparse._csr.csr_matrix] 405 | ): 406 | """ 407 | Computes a vector describing a graph using NetSLD (arXiv:1805.1071), given that graph's adjacency matrix. 408 | Parameters: 409 | ------------ 410 | adjacency_matrix 411 | Array describing the graph in terms of the pairs of vertices that are adjacent. 412 | This array is stored in AnnData objects under .obsp['spatial_connectivities'] 413 | Returns 414 | ------- 415 | A vector representation of the graph. 416 | """ 417 | descriptor = netlsd.heat(adjacency_matrix) 418 | return descriptor 419 | 420 | 421 | @d.dedent 422 | @inject_docs(tl="graphcompass.tl") 423 | def _pad_portraits_to_same_size( 424 | B1: Union[np.ndarray, scipy.sparse._csr.csr_matrix], 425 | B2: Union[np.ndarray, scipy.sparse._csr.csr_matrix] 426 | ) -> tuple[np.ndarray, np.ndarray]: 427 | """ 428 | Ensures that two matrices are padded with zeros and/or trimmed of 429 | zeros to be the same shape. 430 | """ 431 | ns, ms = B1.shape 432 | nl, ml = B2.shape 433 | 434 | # Bmats have N columns; find last *occupied* column and trim both down: 435 | lastcol1 = max(np.nonzero(B1)[1]) 436 | lastcol2 = max(np.nonzero(B2)[1]) 437 | lastcol = max(lastcol1, lastcol2) 438 | B1 = B1[:, : lastcol + 1] 439 | B2 = B2[:, : lastcol + 1] 440 | 441 | BigB1 = np.zeros((max(ns, nl), lastcol + 1)) 442 | BigB2 = np.zeros((max(ns, nl), lastcol + 1)) 443 | 444 | BigB1[: B1.shape[0], : B1.shape[1]] = B1 445 | BigB2[: B2.shape[0], : B2.shape[1]] = B2 446 | 447 | return BigB1, BigB2 448 | 449 | 450 | def _graph_or_portrait( 451 | X: Union[nx.Graph, nx.DiGraph, scipy.sparse._csr.csr_matrix], 452 | portrait_flavour: str, 453 | max_depth: int 454 | ) -> Union[nx.Graph, nx.DiGraph, scipy.sparse._csr.csr_matrix]: 455 | """ 456 | Checks if X is a nx (di)graph. Obtains its portrait if it is. 457 | Assumes it's a portrait otherwise and returns it. 458 | """ 459 | if isinstance(X, (nx.Graph, nx.DiGraph)): 460 | return _calculate_portrait(X, portrait_flavour, max_depth) 461 | return X 462 | 463 | 464 | def _calculate_portrait_divergence( 465 | G: Union[nx.Graph, scipy.sparse._csr.csr_matrix], 466 | H: Union[nx.Graph, scipy.sparse._csr.csr_matrix], 467 | portrait_flavour: str, 468 | max_depth: int 469 | ) -> float: 470 | """Computes the network portrait divergence between graphs G and H.""" 471 | 472 | BG = _graph_or_portrait(G, portrait_flavour, max_depth) 473 | BH = _graph_or_portrait(H, portrait_flavour, max_depth) 474 | BG, BH = _pad_portraits_to_same_size(BG, BH) 475 | 476 | L, K = BG.shape 477 | V = np.tile(np.arange(K), (L, 1)) 478 | 479 | XG = BG * V / (BG * V).sum() 480 | XH = BH * V / (BH * V).sum() 481 | 482 | # flatten distribution matrices as arrays: 483 | P = XG.ravel() 484 | Q = XH.ravel() 485 | 486 | # lastly, get JSD: 487 | M = 0.5 * (P + Q) 488 | KLDpm = entropy(P, M, base=2) 489 | KLDqm = entropy(Q, M, base=2) 490 | JSDpq = 0.5 * (KLDpm + KLDqm) 491 | 492 | return JSDpq 493 | 494 | 495 | @d.dedent 496 | @inject_docs(tl="graphcompass.tl") 497 | def _calculate_portrait( 498 | graph: Union[nx.Graph, nx.DiGraph], 499 | portrait_flavour: str, 500 | max_depth: int, 501 | fname=None, 502 | keepfile=False 503 | ) -> np.ndarray: 504 | 505 | """ 506 | Computes and generates the portrait of a graph using the compiled B_matrix 507 | executable. 508 | 509 | Unoptimised; source: https://github.com/bagrow/network-portrait-divergence/blob/72993c368114c2e834142787579466d673232fa4/portrait_divergence.py 510 | 511 | """ 512 | 513 | if portrait_flavour not in ["python", "cpp"]: 514 | raise ValueError("Parameter 'portrait_flavour' must be either 'python' or 'cpp'.") 515 | 516 | if portrait_flavour == "cpp": 517 | # file to save to: 518 | f = fname 519 | if fname is None: 520 | f = next(tempfile._get_candidate_names()) 521 | 522 | # make sure nodes are 0,...,N-1 integers: 523 | graph = nx.convert_node_labels_to_integers(graph) 524 | 525 | # write edgelist: 526 | nx.write_edgelist(graph, f + ".edgelist", data=False) 527 | 528 | # make B-matrix: 529 | os.system("./B_matrix {}.edgelist {}.Bmat > /dev/null".format(f, f)) 530 | portrait = np.loadtxt("{}.Bmat".format(f)) 531 | 532 | # clean up: 533 | if not keepfile: 534 | os.remove(f + ".edgelist") 535 | os.remove(f + ".Bmat") 536 | 537 | elif portrait_flavour == "python": 538 | N = graph.number_of_nodes() 539 | # B indices are 0...dia x 0...N-1: 540 | B = np.zeros((max_depth + 1, N)) 541 | 542 | max_path = 1 543 | adj = graph.adj 544 | # breadth-first search (BFS) for each node 545 | for starting_node in graph.nodes(): 546 | nodes_visited = {starting_node: 0} 547 | search_queue = [starting_node] 548 | d = 1 549 | while search_queue and d <= max_depth: 550 | next_depth = [] 551 | extend = next_depth.extend 552 | for n in search_queue: 553 | l = [i for i in adj[n] if i not in nodes_visited] 554 | extend(l) 555 | for j in l: 556 | nodes_visited[j] = d 557 | search_queue = next_depth 558 | d += 1 559 | 560 | node_distances = nodes_visited.values() 561 | max_node_distances = max(node_distances) 562 | 563 | curr_max_path = max_node_distances 564 | if curr_max_path > max_path: 565 | max_path = curr_max_path 566 | 567 | # build individual distribution: 568 | dict_distribution = dict.fromkeys(node_distances, 0) 569 | for d in node_distances: 570 | dict_distribution[d] += 1 571 | 572 | # add individual distribution to matrix: 573 | for shell, count in dict_distribution.items(): 574 | B[shell][count] += 1 575 | 576 | # HACK: count starting nodes that have zero nodes in farther shells 577 | max_shell = max_depth 578 | while max_shell > max_node_distances: 579 | B[max_shell][0] += 1 580 | max_shell -= 1 581 | 582 | portrait = B[: max_path + 1, :] 583 | 584 | return portrait 585 | -------------------------------------------------------------------------------- /src/graphcompass/tl/_filtration_curves.py: -------------------------------------------------------------------------------- 1 | """Functions for graph comparisons using filtration curves.""" 2 | 3 | import warnings 4 | import itertools 5 | from tqdm import tqdm 6 | 7 | import numpy as np 8 | import pandas as pd 9 | 10 | import scipy.sparse 11 | 12 | from scipy.spatial.distance import euclidean 13 | from scipy.sparse import csr_matrix 14 | 15 | from anndata import AnnData 16 | from igraph import Graph 17 | 18 | from typing import Optional, List 19 | 20 | from graphcompass.tl.utils import _calculate_graph 21 | 22 | 23 | def compare_conditions( 24 | adata: AnnData, 25 | library_key: str = "sample", 26 | cluster_key: str = "cell_type", 27 | condition_key: str = "condition", 28 | attribute: str = "weight", 29 | sample_ids: Optional[List[str]] = None, 30 | compute_spatial_graphs: bool = True, 31 | kwargs_nhood_enrich: dict = {}, 32 | kwargs_spatial_neighbors: dict = {}, 33 | copy: bool = False, 34 | **kwargs, 35 | ) -> AnnData: 36 | """ 37 | Compares conditions based on filtration curves. 38 | 39 | Parameters: 40 | ------------ 41 | adata 42 | Annotated data object. 43 | library_key 44 | If multiple `library_id`, column in :attr:`anndata.AnnData.obs` 45 | which stores mapping between ``library_id`` and obs. 46 | cluster_key 47 | Key in :attr:`anndata.AnnData.obs` where clustering is stored. 48 | condition_key 49 | Key in :attr:`anndata.AnnData.obs` where condition is stored. 50 | attribute 51 | Edge attribute name. 52 | sample_ids 53 | List of sample/library identifiers. 54 | compute_spatial_graphs 55 | Set to False if spatial graphs have been calculated or 56 | `sq.gr.spatial_neighbors` has already been run before. 57 | kwargs_nhood_enrich 58 | Additional arguments passed to :func:`squidpy.gr.nhood_enrichment` in 59 | `graphcompass.tl.utils._calculate_graph`. 60 | kwargs_spatial_neighbors 61 | Additional arguments passed to :func:`squidpy.gr.spatial_neighbors` in 62 | `graphcompass.tl.utils._calculate_graph`. 63 | kwargs 64 | Additional arguments passed to :func:`graphcompass.tl.utils._calculate_graph`. 65 | copy 66 | Whether to return a copy of the filtration curves object. 67 | Returns 68 | ------- 69 | If ``copy = True``, returns a :class:`list` of filtration dataframes. 70 | """ 71 | if not isinstance(adata, AnnData): 72 | raise TypeError("Parameter 'adata' must be an AnnData object.") 73 | 74 | if copy: 75 | adata = adata.copy() 76 | 77 | # Create graph from spatial coordinates 78 | if compute_spatial_graphs: 79 | print("Computing spatial graphs...") 80 | _calculate_graph( 81 | adata, 82 | library_key=library_key, 83 | cluster_key=cluster_key, 84 | kwargs_nhood_enrich=kwargs_nhood_enrich, 85 | kwargs_spatial_neighbors=kwargs_spatial_neighbors, 86 | **kwargs 87 | ) 88 | else: 89 | print("Spatial graphs were previously computed. Skipping computing spatial graphs...") 90 | 91 | print("Computing edge weights...") 92 | # Compute edge weights 93 | edge_weights = _compute_edge_weights( 94 | gene_expression_matrix=adata.X, 95 | adjacency_matrix=adata.obsp['spatial_connectivities'] 96 | ) 97 | 98 | adata.obsp['edge_weights'] = edge_weights 99 | 100 | graphs = [] 101 | node_labels = set() 102 | 103 | if sample_ids is not None: 104 | samples = sample_ids 105 | else: 106 | samples = adata.obs[library_key].unique() 107 | 108 | for sample in tqdm(samples): 109 | # Create an igraph graph (initially unweighted) from the adjacency 110 | # matrix 111 | patient_data = adata[adata.obs[library_key] == sample] 112 | adj_matrix = patient_data.obsp['edge_weights'] 113 | graph = Graph.Adjacency((adj_matrix > 0), mode='undirected') 114 | 115 | # Extract weights from the sparse matrix and assign them to the edges 116 | if scipy.sparse.issparse(adj_matrix): 117 | weights = adj_matrix.tocoo() 118 | else: 119 | weights = adj_matrix 120 | 121 | for i, j, weight in zip(weights.row, weights.col, weights.data): 122 | if i <= j: # This ensures that each edge is considered only once 123 | edge_id = graph.get_eid(i, j) 124 | graph.es[edge_id]['weight'] = weight 125 | 126 | # Assign cell type labels to nodes 127 | labels = patient_data.obs[cluster_key] 128 | 129 | for i, attribute in enumerate(labels): 130 | graph.vs[i]['label'] = attribute 131 | 132 | # Assign label to graph 133 | graph_label = pd.unique(patient_data.obs[condition_key]) 134 | assert graph_label.size == 1 135 | graph['label'] = graph_label[0] 136 | 137 | graphs.append(graph) 138 | node_labels.update(set(labels)) 139 | 140 | print("Computing edge weight threshold values...") 141 | # Determine edge weight threshold values 142 | edge_weights = np.array([]) 143 | 144 | for g in graphs: 145 | edge_weights = np.append(edge_weights, g.es['weight']) 146 | 147 | # Sort the edge weight array 148 | sorted_array = np.sort(edge_weights) 149 | 150 | # To calculate 10 thresholds, create an array of percentiles from 0 to 151 | # 100 with 10 steps 152 | percentiles = np.linspace(0, 100, 11) 153 | thresholds = np.percentile(sorted_array, percentiles) 154 | 155 | # The 'thresholds' array now contains the 10 thresholds 156 | threshold_vals = thresholds[1:] 157 | 158 | print("Creating filtration curves...") 159 | filtration_curves = _create_filtration_curves( 160 | graphs, 161 | threshold_vals=threshold_vals 162 | ) 163 | 164 | print("Done!") 165 | adata.uns["filtration_curves"] = {} 166 | my_curves = {str(key): x for key, x in zip(samples, filtration_curves)} 167 | adata.uns["filtration_curves"]["curves"] = my_curves 168 | adata.uns["filtration_curves"]["threshold_vals"] = threshold_vals 169 | 170 | if copy: 171 | return filtration_curves 172 | 173 | 174 | def _compute_edge_weights(gene_expression_matrix, adjacency_matrix): 175 | """ 176 | Computes edge weights based on the Euclidean distance between the gene 177 | expression matrices of two neighboring nodes. 178 | 179 | Parameters: 180 | ------------ 181 | gene_expression_matrix: 182 | Gene expression data, typically stored in adata.X. 183 | adjacency_matrix: 184 | Connection matrix built by Squidpy's spatial_neighbors function. 185 | 186 | Returns 187 | ------- 188 | Edge weights. 189 | """ 190 | edge_weights = csr_matrix(adjacency_matrix.shape, dtype=float) 191 | 192 | rows, cols = adjacency_matrix.nonzero() 193 | for i, j in zip(rows, cols): 194 | if i < j: # To avoid duplicate computation for undirected graphs 195 | try: 196 | distance = euclidean( 197 | gene_expression_matrix[i], 198 | gene_expression_matrix[j], 199 | ) 200 | except ValueError: 201 | distance = euclidean( 202 | gene_expression_matrix[i].toarray()[0], 203 | gene_expression_matrix[j].toarray()[0], 204 | ) 205 | if distance > 0: 206 | edge_weights[i, j] = distance 207 | edge_weights[j, i] = distance # Assuming undirected graph 208 | elif distance == 0: 209 | warnings.warn("This edge's weight is zero. The edge will be removed.") 210 | else: 211 | warnings.warn("This edge's weight is negative or Not a Number (NaN). The edge will be removed.") 212 | 213 | return edge_weights 214 | 215 | 216 | def _create_filtration_curves( 217 | graphs, 218 | threshold_vals, 219 | ): 220 | """ 221 | Creates the node label filtration curves. 222 | 223 | Given a dataset of igraph graphs, we create a filtration curve using node 224 | label histograms. Given an edge weight threshold, we generate a node label 225 | histogram for the subgraph induced by all edges with weight less than or 226 | equal to that given threshold. This function is based on code for the KDD 227 | 2021 paper 'Filtration Curves for Graph Representation': 228 | GitHub: https://github.com/BorgwardtLab/filtration_curves 229 | Paper: https://doi.org/10.1145/3447548.3467442 230 | 231 | Parameters: 232 | ------------ 233 | graphs: list 234 | A collection of graphs 235 | threshold vals: list 236 | List of edge weight threshold values 237 | 238 | Returns 239 | ------- 240 | List of filtrations. 241 | """ 242 | # Ensure edge weights were assigned 243 | for graph in graphs: 244 | assert "weight" in graph.edge_attributes() 245 | 246 | # Get all potential node labels to make sure that the distribution 247 | # can be calculated correctly later on. 248 | node_labels = sorted(set( 249 | itertools.chain.from_iterable(graph.vs['label'] for graph in graphs) 250 | )) 251 | 252 | label_to_index = { 253 | label: index for index, label in enumerate(sorted(node_labels)) 254 | } 255 | 256 | # Build the filtration using the edge weights 257 | filtrated_graphs = [ 258 | _filtration_by_edge_attribute( 259 | graph, 260 | threshold_vals, 261 | attribute='weight', 262 | delete_nodes=True, 263 | stop_early=True 264 | ) 265 | for graph in tqdm(graphs) 266 | ] 267 | 268 | # Create a data frame for every graph and store it; the output is 269 | # determined by the input filename, albeit with a new extension. 270 | 271 | list_of_df = [] 272 | 273 | for index, filtrated_graph in enumerate(tqdm(filtrated_graphs)): 274 | 275 | columns = ['graph_label', 'weight'] + node_labels 276 | rows = [] 277 | 278 | distributions = _node_label_distribution( 279 | filtrated_graph, 280 | label_to_index 281 | ) 282 | 283 | for weight, counts in distributions: 284 | row = { 285 | 'graph_label': graphs[index]['label'], 286 | 'weight': weight 287 | } 288 | row.update( 289 | { 290 | str(node_label): count 291 | for node_label, count in zip(node_labels, counts) 292 | } 293 | ) 294 | rows.append(row) 295 | 296 | df = pd.DataFrame(rows, columns=columns) 297 | list_of_df.append(df) 298 | 299 | return list_of_df 300 | 301 | 302 | def _node_label_distribution(filtration, label_to_index): 303 | """ 304 | Calculates the node label distribution along a filtration. 305 | 306 | Given a filtration from an individual graph, we calculate the node label 307 | histogram (i.e. the count of each unique label) at each step along that 308 | filtration, and return a list of the edge weight threshold and its 309 | associated count vector. This function is based on code for the KDD 2021 310 | paper 'Filtration Curves for Graph Representation': 311 | GitHub: https://github.com/BorgwardtLab/filtration_curves 312 | Paper: https://doi.org/10.1145/3447548.3467442 313 | 314 | Parameters: 315 | ------------ 316 | filtration : list 317 | A filtration of graphs 318 | label_to_index : mappable 319 | A map between labels and indices, required to calculate the histogram 320 | 321 | Returns 322 | ------- 323 | Label distributions along the filtration. Each entry is a tuple 324 | consisting of the weight threshold followed by a count vector. 325 | """ 326 | # D will contain the distributions as count vectors; this distribution is 327 | # calculated for every step of the filtration. 328 | D = [] 329 | 330 | for weight, graph in filtration: 331 | labels = graph.vs['label'] 332 | counts = np.zeros(len(label_to_index)) 333 | 334 | for label in labels: 335 | index = label_to_index[label] 336 | counts[index] += 1 337 | 338 | # The conversion ensures that we can arrange everything later on in a 339 | # 'pd.series'. 340 | D.append((weight, counts.tolist())) 341 | 342 | return D 343 | 344 | 345 | def _filtration_by_edge_attribute( 346 | graph, 347 | threshold_vals, 348 | attribute='weight', 349 | delete_nodes=False, 350 | stop_early=False 351 | ): 352 | """ 353 | Calculates a filtration of a graph based on an edge attribute of the graph. 354 | This function is based on code for the KDD 2021 paper 'Filtration Curves 355 | for Graph Representation': 356 | GitHub: https://github.com/BorgwardtLab/filtration_curves 357 | Paper: https://doi.org/10.1145/3447548.3467442 358 | 359 | Parameters: 360 | ------------ 361 | graph 362 | igraph Graph 363 | threshold_vals 364 | Edge weight thresholds 365 | attribute 366 | Edge attribute name 367 | delete_nodes 368 | If set, removes nodes from the filtration if none of their incident 369 | edges is part of the subgraph. By default, all nodes are kept 370 | stop_early 371 | If set, stops the filtration as soon as the number of nodes has been 372 | reached 373 | 374 | Returns 375 | ------- 376 | Filtration as a list of tuples, where each tuple consists of the weight 377 | threshold and the graph. 378 | """ 379 | 380 | weights = graph.es[attribute] 381 | weights = np.array(weights) 382 | 383 | # Represents the filtration of graphs according to the client-specified 384 | # attribute. 385 | F = [] 386 | 387 | n_nodes = graph.vcount() 388 | 389 | # Checks if graphs have more than a single edge. 390 | if weights.size != 1: 391 | weights = weights 392 | x = False 393 | else: 394 | weights = np.array([[weights]]) 395 | x = True 396 | 397 | for weight in sorted(np.unique(threshold_vals)): 398 | 399 | if x: 400 | weight = weight[0] 401 | edges = graph.es.select(lambda edge: edge[attribute] <= weight) 402 | subgraph = edges.subgraph(delete_vertices=delete_nodes) 403 | 404 | # Store weight and the subgraph induced by the selected edges as one 405 | # part of the filtration. The client can decide whether each node that 406 | # is not adjacent to any edge should be removed from the filtration. 407 | F.append((weight, subgraph)) 408 | 409 | # If we have assembled enough nodes already, we do not need to 410 | # continue. 411 | if stop_early and subgraph.vcount() == n_nodes: 412 | break 413 | 414 | return F 415 | -------------------------------------------------------------------------------- /src/graphcompass/tl/utils.py: -------------------------------------------------------------------------------- 1 | """Functions for graph calculation""" 2 | 3 | from __future__ import annotations 4 | 5 | import squidpy as sq 6 | 7 | from typing import ( 8 | Union, # noqa: F401 9 | ) 10 | 11 | import igraph 12 | from joblib import delayed, Parallel 13 | 14 | 15 | from numba import njit, prange # noqa: F401 16 | 17 | from anndata import AnnData 18 | 19 | 20 | def _calculate_graph( 21 | adata: AnnData, 22 | library_key: str = "sample", 23 | cluster_key: str = "cell_type", 24 | copy: bool = False, 25 | kwargs_nhood_enrich={}, 26 | kwargs_spatial_neighbors={}, 27 | ): 28 | """ 29 | Calculate the spatial graph and perform the sample-wise neighborhood enrichment analysis. 30 | 31 | Parameters: 32 | ------------ 33 | adata 34 | Annotated data object. 35 | library_key 36 | If multiple `library_id`, column in :attr:`anndata.AnnData.obs` 37 | which stores mapping between ``library_id`` and obs. 38 | cluster_key 39 | Key in :attr:`anndata.AnnData.obs` where clustering is stored. 40 | Returns 41 | ------- 42 | If ``copy = True``, returns a :class:`dict` with the z-score and the enrichment count per sample. 43 | 44 | Otherwise, modifies the ``adata`` with the following keys: 45 | 46 | - :attr:`anndata.AnnData.uns` ``['{cluster_key}_{library_key}_nhood_enrichment'][sample]['zscore']`` - the enrichment z-score. 47 | - :attr:`anndata.AnnData.uns` ``['{cluster_key}_{library_key}_nhood_enrichment'][sample['count']`` - the enrichment count. 48 | """ 49 | # calculate spatial neighbors for all samples 50 | sq.gr.spatial_neighbors( 51 | adata=adata, 52 | library_key=library_key, 53 | **kwargs_spatial_neighbors 54 | ) 55 | nhood_enrichment = dict() 56 | # calculate neighborhood graphs per sample 57 | for sample in adata.obs[library_key].unique(): 58 | adata_sample = adata[adata.obs[library_key] == sample].copy() 59 | sq.gr.nhood_enrichment( 60 | adata_sample, cluster_key=cluster_key, **kwargs_nhood_enrich 61 | ) 62 | nhood_enrichment[sample] = adata_sample.uns[f"{cluster_key}_nhood_enrichment"] 63 | 64 | adata.uns[f"{cluster_key}_{library_key}_nhood_enrichment"] = nhood_enrichment 65 | 66 | if copy: 67 | return adata 68 | return 69 | 70 | 71 | 72 | def _get_graph( 73 | adata, 74 | library_key: str, 75 | cluster_key: str, 76 | sample: str, 77 | cell_type: str, 78 | ): 79 | """ 80 | Returns the sample-wise neighborhood enrichment analysis and sample-cell type graph connectivities 81 | 82 | Parameters: 83 | ------------ 84 | adata 85 | Annotated data object. 86 | library_key 87 | If multiple `library_id`, column in :attr:`anndata.AnnData.obs` 88 | which stores mapping between ``library_id`` and obs. 89 | cluster_key 90 | Key in :attr:`anndata.AnnData.obs` where clustering is stored. 91 | sample 92 | Sample ID of interest in library_key field 93 | cell_type 94 | Name of cell type of interest in cluster_key field 95 | Returns 96 | ------- 97 | Dict of sample-wise zscore, counts and cell type spatial connectivities 98 | """ 99 | adata_sample = adata[adata.obs[library_key] == sample].copy() 100 | 101 | nhood_enrichment = adata_sample.uns[ 102 | f"{cluster_key}_{library_key}_nhood_enrichment" 103 | ][sample] 104 | 105 | adata_sample_ct = adata_sample[adata_sample.obs[cluster_key] == cell_type].copy() 106 | nhood_enrichment["spatial_connectivities"] = adata_sample_ct.obsp[ 107 | "spatial_connectivities" 108 | ] 109 | 110 | return nhood_enrichment 111 | 112 | def _get_igraph( 113 | adata, 114 | connectivity_key: str ="spatial_connectivities", 115 | cluster_key: str = None, 116 | ) -> igraph.Graph: 117 | """ 118 | Calculate iGraph object for a specific sample 119 | 120 | Parameters: 121 | ------------ 122 | adata 123 | Annotated data object for specific sample. 124 | connectivity_key 125 | key for adjacency matrix 126 | cluster_key 127 | Key in :attr:`anndata.AnnData.obs` where clustering is stored. 128 | Returns 129 | ------- 130 | iGraph object 131 | """ 132 | from scipy.sparse import coo_matrix 133 | 134 | A = adata.obsp[connectivity_key] # Keep as sparse matrix 135 | A_coo = coo_matrix(A) 136 | 137 | # Create edges directly from the sparse representation 138 | g = igraph.Graph(edges=list(zip(A_coo.row, A_coo.col)), directed=False) 139 | g.es['weight'] = A_coo.data 140 | 141 | if cluster_key: 142 | g.vs['label'] = adata.obs[cluster_key].to_list() 143 | 144 | return g 145 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test suite for the graphcompass package.""" 2 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """Test cases for the __main__ module.""" 2 | import pytest 3 | from click.testing import CliRunner 4 | 5 | from graphcompass import __main__ 6 | 7 | 8 | @pytest.fixture 9 | def runner() -> CliRunner: 10 | """Fixture for invoking command-line interfaces.""" 11 | return CliRunner() 12 | 13 | 14 | def test_main_succeeds(runner: CliRunner) -> None: 15 | """It exits with a status code of zero.""" 16 | result = runner.invoke(__main__.main) 17 | assert result.exit_code == 0 18 | --------------------------------------------------------------------------------