diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..17ea435
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,30 @@
+# DataFrame
+
+Each specific analysis plot is based on a type from acore `acore.types`. Use the pandera
+schema to validate the input DataFrame.
+
+- use the pandas API whenever you can
+
+## Format
+
+Use black to format the src folder
+
+```bash
+black src
+```
+
+## Ruff checks
+
+```
+ruff check src --fix
+```
+
+## Staging
+
+Ask me if I want to stage the files and provide the command to do so.
+
+## Docstrings
+
+Use the default format for type annotated functions is numpy style.
+
+https://numpydoc.readthedocs.io/en/latest/format.html#parameters
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 14c19e3..aa4b495 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,32 +1,33 @@
# Contributing to VueCore
-[VueCore][vuecore-repo] is an **open source project**, and we welcome contributions of all kinds via **GitHub issues** and
-**pull requests**: correct or improve our [documentation][vuecore-docs], report or fix bugs, propose changes, and
-implement new features. Please follow these guidelines to make sure that your contribution is easily integrated
-into the project.
+[VueCore][vuecore-repo] is an **open source project**, and we welcome contributions of all
+kinds via **GitHub issues** and **pull requests**: correct or improve our
+[documentation][vuecore-docs], report or fix bugs, propose changes, and implement new
+features. Please follow these guidelines to make sure that your contribution is easily
+integrated into the project.
## Contributor Agreement
By contributing, you agree that we may redistribute your work under [our
-license](LICENSE.md). In exchange, we will address your issues and/or assess
-your change proposal as promptly as we can, and help you become a member of our
-community.
+license](LICENSE.md). In exchange, we will address your issues and/or assess your change
+proposal as promptly as we can, and help you become a member of our community.
## What to Contribute?
-The easiest way to get started is by **reporting an issue** that needs to be fixed,
-such as a bug in the code, unclear explanations, conceptual errors, or other details.
-If you are familiar with Python, Git,, and GitHub, you can **fix the bug** yourself
-and submit a **pull request (PR)** with your changes, as described below.
+The easiest way to get started is by **reporting an issue** that needs to be fixed, such
+as a bug in the code, unclear explanations, conceptual errors, or other details. If you
+are familiar with Python, Git,, and GitHub, you can **fix the bug** yourself and submit a
+**pull request (PR)** with your changes, as described below.
-You can also contribute by **fixing existing bugs** tagged with the `bug` and
-`help wanted` labels in the [list of open issues][issues] of the repository. There are
-**new features and imporvements** tagged with `enhancement` and `help wanted` as well, so
-feel free to start a discussion if you would like to work on one of those. If you come up with
-any **ideas for new features or improvements** that are not yet reported, we are also happy to hear about them.
+You can also contribute by **fixing existing bugs** tagged with the `bug` and `help
+wanted` labels in the [list of open issues][issues] of the repository. There are **new
+features and imporvements** tagged with `enhancement` and `help wanted` as well, so feel
+free to start a discussion if you would like to work on one of those. If you come up with
+any **ideas for new features or improvements** that are not yet reported, we are also
+happy to hear about them.
-Feedback from beginners is especially valuable, as experienced users may overlook how
-challenging certain aspects of the software can be for newcomers. Therefore, we encourage
+Feedback from beginners is especially valuable, as experienced users may overlook how
+challenging certain aspects of the software can be for newcomers. Therefore, we encourage
you to share any suggestions or observations you have.
## How to Contribute?
@@ -35,41 +36,44 @@ Here are the ways you can submit your suggestions and contribute to the project:
### 1. Reporting Issues or Suggesting Improvements
-If you have a [GitHub][github] account (or are willing to [open one][github-join]) but are unfamiliar with
-Git, you can report bugs or suggest improvements by [creating an issue][new-issue]. This GitHub feature allows
-for discussion threads on reported issues and proposed enhancements.
+If you have a [GitHub][github] account (or are willing to [open one][github-join]) but
+are unfamiliar with Git, you can report bugs or suggest improvements by [creating an
+issue][new-issue]. This GitHub feature allows for discussion threads on reported issues
+and proposed enhancements.
-When reporting an issue, please provide as much relevant information as possible, including:
+When reporting an issue, please provide as much relevant information as possible,
+including:
-* A clear and descriptive title
-* A detailed description of the problem or suggestion
-* Steps to reproduce the issue (if applicable)
-* Any relevant screenshots or error messages
-* Your operating system, software version, and additonal details about your local setup that might be helpful in troubleshooting
+- A clear and descriptive title
+- A detailed description of the problem or suggestion
+- Steps to reproduce the issue (if applicable)
+- Any relevant screenshots or error messages
+- Your operating system, software version, and additonal details about your local setup
+ that might be helpful in troubleshooting
-> [!TIP]
-> This guide from the [GitHub Docs][github-docs] provides useful tips on [how to write an issue][issue-github-guide].
+> [!TIP] This guide from the [GitHub Docs][github-docs] provides useful tips on [how to
+> write an issue][issue-github-guide].
### 2. Submitting Changes via Pull Requests (PR)
-If you are comfortable using Git/GitHub and would like to add or modify a functionality, you can submit a **PR**.
-You may want to look at [How to Contribute to an Open Source Project on GitHub][how-contribute].
-In brief, we use [GitHub flow][github-flow] to manage changes:
+If you are comfortable using Git/GitHub and would like to add or modify a functionality,
+you can submit a **PR**. You may want to look at [How to Contribute to an Open Source
+Project on GitHub][how-contribute]. In brief, we use [GitHub flow][github-flow] to manage
+changes:
-> [!TIP]
-> Consider using an IDE (e.g., [VSCode][vscode]) or a GUI client (e.g., [GitHub Desktop][github-desktop]) to help
-> you with some of the common steps described below.
+> [!TIP] Consider using an IDE (e.g., [VSCode][vscode]) or a GUI client (e.g., [GitHub
+> Desktop][github-desktop]) to help you with some of the common steps described below.
-1. Fork the [vuecore][vuecore-repo] repo on GitHub.
+1. Fork the [vuecore][vuecore-repo] repo on GitHub.
2. Clone your fork locally. Replace `yourusername` with your GitHub username.
```bash
git clone https://github.com/yourusername/vuecore.git
```
-3. Install your local copy into a virtual environment. Assuming you have Python available
- on your system, this can be done using `venv`. Alternatives are `conda`, `uv`, or `poetry`
- to create and manage virtual environments.
+3. Install your local copy into a virtual environment. Assuming you have Python available
+ on your system, this can be done using `venv`. Alternatives are `conda`, `uv`, or
+ `poetry` to create and manage virtual environments.
```bash
cd vuecore/
@@ -78,15 +82,16 @@ In brief, we use [GitHub flow][github-flow] to manage changes:
pip install -e .[dev]
```
-4. Create a new branch in your desktop copy of this repository for each significant change.
+4. Create a new branch in your desktop copy of this repository for each significant
+ change.
```bash
git checkout -b name-of-your-new-branch
```
-5. When you're done making changes, check that your changes are formatted and pass `black` and
- `ruff` checks (some changes ruff can automatically fix for you, if you pass the `--fix` flag).
- Also, run the `tests` to make sure everything is working as expected:
+5. When you're done making changes, check that your changes are formatted and pass `black`
+ and `ruff` checks (some changes ruff can automatically fix for you, if you pass the
+ `--fix` flag). Also, run the `tests` to make sure everything is working as expected:
```bash
black .
@@ -107,36 +112,41 @@ In brief, we use [GitHub flow][github-flow] to manage changes:
git push origin name-of-your-new-branch
```
-8. Submit a pull request from that branch to the [upstream repository][vuecore-repo] via GitHub.
- See the **PR General Guidelines** below for more details.
-9. If you receive feedback, make changes on your desktop and push to your branch on GitHub: the
- pull request will update automatically.
+8. Submit a pull request from that branch to the [upstream repository][vuecore-repo] via
+ GitHub. See the **PR General Guidelines** below for more details.
+9. If you receive feedback, make changes on your desktop and push to your branch on
+ GitHub: the pull request will update automatically.
-> [!TIP]
-> The documentation for [Git][git-docs] and [GitHub][github-docs] are easy to follow, and you can learn the
-> basics using their official guides.
+> [!TIP] The documentation for [Git][git-docs] and [GitHub][github-docs] are easy to
+> follow, and you can learn the basics using their official guides.
#### PR General Guidelines
-We have a general [PR template][general-pr-template] that is loaded autmatically when you open a new PR.
-Also, if you are adding a new plot, we created a [new plot PR template][new-plot-pr-template]
-with a checklist of all the steps to follow, which you can use with a query paramter by clicking [here][new-plot-pr-query-param].
+We have a general [PR template][general-pr-template] that is loaded autmatically when you
+open a new PR. Also, if you are adding a new plot, we created a [new plot PR
+template][new-plot-pr-template] with a checklist of all the steps to follow, which you can
+use with a query paramter by clicking [here][new-plot-pr-query-param].
Before you submit a PR, check that it meets these guidelines:
1. The pull request should include tests.
-2. If the pull request adds functionality, the docs should be updated. Put
- your new functionality into a function with a docstring.
+2. If the pull request adds functionality, the docs should be updated. Put your new
+ functionality into a function with a docstring.
3. The pull request should pass the workflows on GitHub.
## Code & Documentation Standards
-To maintain consistency across the codebase, please adhere to the following standards when contributing:
+To maintain consistency across the codebase, please adhere to the following standards when
+contributing:
-* **Docstrings:** Follow the [NumPy docstring style][numpy-docstring-guide]. Include examples where relevant.
-* **Type Hints:** Use Python type hints for function signatures and variable annotations (**PEP 484**).
-* **Code Formatting:** Use `black` for code formatting and `ruff` for linting (as mentioned in the PR guidelines).
-* **Naming Conventions:** Follow **PEP8** for naming (e.g., snake_case for variables/functions, CamelCase for classes).
+- **Docstrings:** Follow the [NumPy docstring style][numpy-docstring-guide]. Include
+ examples where relevant.
+- **Type Hints:** Use Python type hints for function signatures and variable annotations
+ (**PEP 484**).
+- **Code Formatting:** Use `black` for code formatting and `ruff` for linting (as
+ mentioned in the PR guidelines).
+- **Naming Conventions:** Follow **PEP8** for naming (e.g., snake_case for
+ variables/functions, CamelCase for classes).
Here is an example of a simple function with a proper docstring and type hints:
@@ -163,19 +173,23 @@ def calculate_average(values: List[float]) -> float:
return sum(values) / len(values)
```
-## Test Guidelines
+## Test Guidelines
-We encourage comprehensive testing to maintain code quality, so all contributions should include
-appropriate tests that verify functionality. We use `pytest` as our testing framework. Here are some considerations:
+We encourage comprehensive testing to maintain code quality, so all contributions should
+include appropriate tests that verify functionality. We use `pytest` as our testing
+framework. Here are some considerations:
-* **Structure:** Place tests in the tests/ directory, mirroring the source structure.
-* **Coverage:** Aim for high test coverage, especially for new features or bug fixes. Try to cover typical use cases as well as edge cases.
-* **Naming:** Use descriptive test function names that indicate what is being tested.
-* **Docstrings:** Include docstrings in your test functions to explain their purpose, following the previous docstring guidelines.
-* **Isolation:** Each test should be independent and not rely on other tests.
-* **Local Execution:** Ensure that tests can be run locally using `pytest` before submitting a PR.
+- **Structure:** Place tests in the tests/ directory, mirroring the source structure.
+- **Coverage:** Aim for high test coverage, especially for new features or bug fixes. Try
+ to cover typical use cases as well as edge cases.
+- **Naming:** Use descriptive test function names that indicate what is being tested.
+- **Docstrings:** Include docstrings in your test functions to explain their purpose,
+ following the previous docstring guidelines.
+- **Isolation:** Each test should be independent and not rely on other tests.
+- **Local Execution:** Ensure that tests can be run locally using `pytest` before
+ submitting a PR.
-Here is an example of a test script for the `calculate_average` function:
+Here is an example of a test script for the `calculate_average` function:
```python
import pytest
@@ -203,23 +217,26 @@ Run the following command in the root directory to execute the tests locally:
pytest .
```
-It's possible to run specific test files or functions by providing their paths.
-See the [pytest documentation][pytest-docs] for more details.
+It's possible to run specific test files or functions by providing their paths. See the
+[pytest documentation][pytest-docs] for more details.
## Deployment
-We created a [CI/CD worflow][cicd-workflow] using **GitHub Actions** to automatically deploy the Python package to
-[PyP][vuecore-pypi] when a new release is created. To create a new release, make sure all changed are merged into the `main` branch,
-then go to the [Releases section][releases-vuecore] of the GitHub repository and click on **Draft a new release**. Fill in the
-release title and description, then click on **Publish release**. This will trigger the GitHub Actions workflow to build and deploy the package to PyPI.
+We created a [CI/CD worflow][cicd-workflow] using **GitHub Actions** to automatically
+deploy the Python package to [PyP][vuecore-pypi] when a new release is created. To create
+a new release, make sure all changed are merged into the `main` branch, then go to the
+[Releases section][releases-vuecore] of the GitHub repository and click on **Draft a new
+release**. Fill in the release title and description, then click on **Publish release**.
+This will trigger the GitHub Actions workflow to build and deploy the package to PyPI.
-Also, we have a GitHub Action that automatically deploys the documentation to [Read the Docs][vuecore-docs] in
-every push to a branch or when a PR is merged into `main`.
+Also, we have a GitHub Action that automatically deploys the documentation to [Read the
+Docs][vuecore-docs] in every push to a branch or when a PR is merged into `main`.
## Credits
-This contribution guide was modified under the [Creative Commons Attribution 4.0 International License][ccby] from
-the [Software Carpentry guides][soft-cp-guides] and the [acore][acore-repo] project.
+This contribution guide was modified under the [Creative Commons Attribution 4.0
+International License][ccby] from the [Software Carpentry guides][soft-cp-guides] and the
+[acore][acore-repo] project.
[vuecore-repo]: https://github.com/Multiomics-Analytics-Group/vuecore
[vuecore-docs]: https://vuecore.readthedocs.io/
diff --git a/docs/.gitignore b/docs/.gitignore
index 4818210..651dd1a 100644
--- a/docs/.gitignore
+++ b/docs/.gitignore
@@ -5,4 +5,4 @@ _build
jupyter_execute
# VsCode
-*.DS_Store
\ No newline at end of file
+*.DS_Store
diff --git a/docs/analysis_plots/enrichment_analysis.ipynb b/docs/analysis_plots/enrichment_analysis.ipynb
new file mode 100644
index 0000000..4728c43
--- /dev/null
+++ b/docs/analysis_plots/enrichment_analysis.ipynb
@@ -0,0 +1,358 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "6f1bc633",
+ "metadata": {},
+ "source": [
+ "# Enrichment Analysis Plots\n",
+ "\n",
+ "Loads an example file created using acore. Presents the current plotting functions for\n",
+ "an enrichment analysis result. The functions are available in the [`vuecore.enrichment_analysis` module](vuecore.enrichment_analysis).\n",
+ "\n",
+ "## Overview\n",
+ "For both static and interactive versions of the plots:\n",
+ "- show acore related function that checks the input for compatibility\n",
+ " ([`EnrichmentAnalysisSchema`](acore.types.EnrichmentAnalysisSchema)) and calls the\n",
+ " backend specific plotting function with the required parameters.\n",
+ "- show manually calling the backend specific plotting function with the required\n",
+ " parameters. For these, the setup of the data is the responsibility of the user. The\n",
+ " function will not check for compatibility and accepts [`DataFrame`s](pandas.DataFrame)\n",
+ " with only the required columns for that plot."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6fc064dc",
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "%pip install vuecore"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5fddfa43",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import numpy as np\n",
+ "import pandas as pd\n",
+ "\n",
+ "from vuecore.enrichment_analysis import (\n",
+ " get_enrichment_plot_interactive,\n",
+ " get_enrichment_plot_static,\n",
+ ")\n",
+ "from vuecore.enrichment_analysis.interactive import get_enrichment_plot_plotly\n",
+ "\n",
+ "# DEFAULT_DPI should move\n",
+ "from vuecore.enrichment_analysis.static import (\n",
+ " DEFAULT_DPI,\n",
+ " get_enrichment_plot_mpl,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "fab26596",
+ "metadata": {},
+ "source": [
+ "# Load example data\n",
+ "- compatible with the `EnrichmentAnalysisSchema` type in acore.types"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "f8ad610f",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "fname = \"../../tests/data/enrichment_analysis.csv\"\n",
+ "enrichment_results = pd.read_csv(fname, index_col=0).reset_index(drop=True)\n",
+ "enrichment_results"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f4cb6eac",
+ "metadata": {
+ "lines_to_next_cell": 2
+ },
+ "source": [
+ "## Reduced dataset for manual plotting function calling\n",
+ "For illustration we will create a reduced dataset for manual plotting function calling.\n",
+ "- here we only have one comparison, but for illustration we keep the boilerplate code\n",
+ " (show code) it as if there were multiple comparisons. The user has to select one of\n",
+ " the available comparisons for plotting. Most importantly, each category of the `direction`\n",
+ " column will be plotted in a different color. The `foreground` column will be used to\n",
+ " scale the marker size."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6f34216b",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "sel_columns = [\n",
+ " \"terms\",\n",
+ " # \"identifiers\",\n",
+ " \"foreground\",\n",
+ " # \"background\",\n",
+ " # \"foreground_pop\",\n",
+ " # \"background_pop\",\n",
+ " \"pvalue\",\n",
+ " \"padj\",\n",
+ " \"rejected\",\n",
+ " \"direction\",\n",
+ " # \"comparison\",\n",
+ "]\n",
+ "selected_comparison = \"control~10 µm sulforaphane\"\n",
+ "df = (\n",
+ " enrichment_results.query(f\"comparison == '{selected_comparison}' and rejected\")[\n",
+ " sel_columns\n",
+ " ]\n",
+ " .copy()\n",
+ " .sort_values(by=[\"direction\", \"padj\"], ascending=False)\n",
+ " .reset_index(drop=True)\n",
+ ")\n",
+ "df[\"x\"] = -np.log10(df[\"pvalue\"])\n",
+ "df"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "cd968ce4",
+ "metadata": {},
+ "source": [
+ "# Scatter Plot (Static)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "0c0d4f20",
+ "metadata": {},
+ "source": [
+ "Acore function has defaults for the result type\n",
+ "- the default size reflects in the width and height standards of journals"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "212e5339",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "help(get_enrichment_plot_static)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6c5422e7",
+ "metadata": {
+ "lines_to_next_cell": 0
+ },
+ "outputs": [],
+ "source": [
+ "static_fig = get_enrichment_plot_static(\n",
+ " enrichment_results,\n",
+ ")\n",
+ "print(\n",
+ " f\"Static figure size (inches based on DPI {DEFAULT_DPI}):\"\n",
+ " f\" {static_fig.get_size_inches()}\"\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1b8ececa",
+ "metadata": {},
+ "source": [
+ "which can be adjusted."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "85a4a89a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "static_fig = get_enrichment_plot_static(\n",
+ " enrichment_results,\n",
+ " width=900,\n",
+ " height=500,\n",
+ ")\n",
+ "# re-creating the legend resets the marker sizes to the data-driven ones\n",
+ "print(\n",
+ " f\"Static figure size (inches based on DPI {DEFAULT_DPI}):\"\n",
+ " f\" {static_fig.get_size_inches()}\"\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6fb61111",
+ "metadata": {},
+ "source": [
+ "## Manual calling of matplotlib (mpl) related function\n",
+ "- data has to make sense for the plot\n",
+ "- size and other details can be adjusted"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b970a011",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "help(get_enrichment_plot_mpl)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b0f1069c",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "static_fig = get_enrichment_plot_mpl(\n",
+ " df=df,\n",
+ " comparison_key=selected_comparison,\n",
+ " group=\"direction\",\n",
+ " width=900,\n",
+ " height=500,\n",
+ " title=\"Enrichment manual\",\n",
+ " colors={\n",
+ " \"upregulated in control\": \"pink\",\n",
+ " \"upregulated in 10 µm sulforaphane\": \"orange\",\n",
+ " },\n",
+ ")\n",
+ "print(\n",
+ " f\"Static figure size (inches based on DPI {DEFAULT_DPI}):\"\n",
+ " f\" {static_fig.get_size_inches()}\"\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1a4c4c1c",
+ "metadata": {},
+ "source": [
+ "# Scatter Plot (Interactive)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b2464f0b",
+ "metadata": {},
+ "source": [
+ "Acore function has defaults for the result type"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "0c561511",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "help(get_enrichment_plot_interactive)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "fbb6da45",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "interactive_fig = get_enrichment_plot_interactive(\n",
+ " enrichment_results, width=790, height=500\n",
+ ")\n",
+ "interactive_fig"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9da16927",
+ "metadata": {},
+ "source": [
+ "## Manual calling of plotly related function\n",
+ "- data has to make sense for the plot"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "bdcaa9c1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "help(get_enrichment_plot_plotly)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "625ebbdf",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "interactive_fig = get_enrichment_plot_plotly(\n",
+ " df,\n",
+ " x=\"x\",\n",
+ " y=\"terms\",\n",
+ " group=\"direction\",\n",
+ " symbol=\"direction\",\n",
+ " size=\"foreground\",\n",
+ " title=\"Enrichment manual\",\n",
+ " x_title=\"-log10(pvalue)\",\n",
+ " y_title=\"Enriched terms\",\n",
+ " width=790,\n",
+ " height=500,\n",
+ " colors={\n",
+ " \"upregulated in control\": \"pink\",\n",
+ " \"upregulated in 10um sulforaphane\": \"green\",\n",
+ " },\n",
+ ")\n",
+ "print(\n",
+ " f\"Interactive figure size (pixels): {interactive_fig.layout.width} x {interactive_fig.layout.height}\"\n",
+ ")\n",
+ "# get rid of the left and right large margin\n",
+ "interactive_fig.update_layout(\n",
+ " margin=dict(l=5, r=5),\n",
+ ")\n",
+ "interactive_fig"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1f72f396",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "jupytext": {
+ "cell_metadata_filter": "tags,-all",
+ "main_language": "python",
+ "notebook_metadata_filter": "-all"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/analysis_plots/enrichment_analysis.py b/docs/analysis_plots/enrichment_analysis.py
new file mode 100644
index 0000000..a46d5bc
--- /dev/null
+++ b/docs/analysis_plots/enrichment_analysis.py
@@ -0,0 +1,189 @@
+# %% [markdown]
+# # Enrichment Analysis Plots
+#
+# Loads an example file created using acore. Presents the current plotting functions for
+# an enrichment analysis result. The functions are available in the [`vuecore.enrichment_analysis` module](vuecore.enrichment_analysis).
+#
+# ## Overview
+# For both static and interactive versions of the plots:
+# - show acore related function that checks the input for compatibility
+# ([`EnrichmentAnalysisSchema`](acore.types.EnrichmentAnalysisSchema)) and calls the
+# backend specific plotting function with the required parameters.
+# - show manually calling the backend specific plotting function with the required
+# parameters. For these, the setup of the data is the responsibility of the user. The
+# function will not check for compatibility and accepts [`DataFrame`s](pandas.DataFrame)
+# with only the required columns for that plot.
+
+# %% tags=["hide-output"]
+# %pip install vuecore
+
+# %%
+import numpy as np
+import pandas as pd
+
+from vuecore.enrichment_analysis import (
+ get_enrichment_plot_interactive,
+ get_enrichment_plot_static,
+)
+from vuecore.enrichment_analysis.interactive import get_enrichment_plot_plotly
+
+# DEFAULT_DPI should move
+from vuecore.enrichment_analysis.static import (
+ DEFAULT_DPI,
+ get_enrichment_plot_mpl,
+)
+
+# %% [markdown]
+# # Load example data
+# - compatible with the `EnrichmentAnalysisSchema` type in acore.types
+
+# %%
+fname = "../../tests/data/enrichment_analysis.csv"
+enrichment_results = pd.read_csv(fname, index_col=0).reset_index(drop=True)
+enrichment_results
+
+# %% [markdown]
+# ## Reduced dataset for manual plotting function calling
+# For illustration we will create a reduced dataset for manual plotting function calling.
+# - here we only have one comparison, but for illustration we keep the boilerplate code
+# (show code) it as if there were multiple comparisons. The user has to select one of
+# the available comparisons for plotting. Most importantly, each category of the `direction`
+# column will be plotted in a different color. The `foreground` column will be used to
+# scale the marker size.
+
+
+# %%
+sel_columns = [
+ "terms",
+ # "identifiers",
+ "foreground",
+ # "background",
+ # "foreground_pop",
+ # "background_pop",
+ "pvalue",
+ "padj",
+ "rejected",
+ "direction",
+ # "comparison",
+]
+selected_comparison = "control~10 µm sulforaphane"
+df = (
+ enrichment_results.query(f"comparison == '{selected_comparison}' and rejected")[
+ sel_columns
+ ]
+ .copy()
+ .sort_values(by=["direction", "padj"], ascending=False)
+ .reset_index(drop=True)
+)
+df["x"] = -np.log10(df["pvalue"])
+df
+
+# %% [markdown]
+# # Scatter Plot (Static)
+
+# %% [markdown]
+# Acore function has defaults for the result type
+# - the default size reflects in the width and height standards of journals
+
+# %%
+help(get_enrichment_plot_static)
+
+# %%
+static_fig = get_enrichment_plot_static(
+ enrichment_results,
+)
+print(
+ f"Static figure size (inches based on DPI {DEFAULT_DPI}):"
+ f" {static_fig.get_size_inches()}"
+)
+# %% [markdown]
+# which can be adjusted.
+
+# %%
+static_fig = get_enrichment_plot_static(
+ enrichment_results,
+ width=900,
+ height=500,
+)
+# re-creating the legend resets the marker sizes to the data-driven ones
+print(
+ f"Static figure size (inches based on DPI {DEFAULT_DPI}):"
+ f" {static_fig.get_size_inches()}"
+)
+
+# %% [markdown]
+# ## Manual calling of matplotlib (mpl) related function
+# - data has to make sense for the plot
+# - size and other details can be adjusted
+
+# %%
+help(get_enrichment_plot_mpl)
+
+# %%
+static_fig = get_enrichment_plot_mpl(
+ df=df,
+ comparison_key=selected_comparison,
+ group="direction",
+ width=900,
+ height=500,
+ title="Enrichment manual",
+ colors={
+ "upregulated in control": "pink",
+ "upregulated in 10 µm sulforaphane": "orange",
+ },
+)
+print(
+ f"Static figure size (inches based on DPI {DEFAULT_DPI}):"
+ f" {static_fig.get_size_inches()}"
+)
+
+# %% [markdown]
+# # Scatter Plot (Interactive)
+
+# %% [markdown]
+# Acore function has defaults for the result type
+
+# %%
+help(get_enrichment_plot_interactive)
+
+# %%
+interactive_fig = get_enrichment_plot_interactive(
+ enrichment_results, width=790, height=500
+)
+interactive_fig
+
+# %% [markdown]
+# ## Manual calling of plotly related function
+# - data has to make sense for the plot
+
+# %%
+help(get_enrichment_plot_plotly)
+
+# %%
+interactive_fig = get_enrichment_plot_plotly(
+ df,
+ x="x",
+ y="terms",
+ group="direction",
+ symbol="direction",
+ size="foreground",
+ title="Enrichment manual",
+ x_title="-log10(pvalue)",
+ y_title="Enriched terms",
+ width=790,
+ height=500,
+ colors={
+ "upregulated in control": "pink",
+ "upregulated in 10um sulforaphane": "green",
+ },
+)
+print(
+ f"Interactive figure size (pixels): {interactive_fig.layout.width} x {interactive_fig.layout.height}"
+)
+# get rid of the left and right large margin
+interactive_fig.update_layout(
+ margin=dict(l=5, r=5),
+)
+interactive_fig
+
+# %%
diff --git a/docs/api_examples/jupytext.toml b/docs/analysis_plots/jupytext.toml
similarity index 100%
rename from docs/api_examples/jupytext.toml
rename to docs/analysis_plots/jupytext.toml
diff --git a/docs/api_examples/bar_plot.ipynb b/docs/basic_plot_examples/bar_plot.ipynb
similarity index 100%
rename from docs/api_examples/bar_plot.ipynb
rename to docs/basic_plot_examples/bar_plot.ipynb
diff --git a/docs/api_examples/bar_plot.py b/docs/basic_plot_examples/bar_plot.py
similarity index 100%
rename from docs/api_examples/bar_plot.py
rename to docs/basic_plot_examples/bar_plot.py
diff --git a/docs/api_examples/box_violin_plot.ipynb b/docs/basic_plot_examples/box_violin_plot.ipynb
similarity index 100%
rename from docs/api_examples/box_violin_plot.ipynb
rename to docs/basic_plot_examples/box_violin_plot.ipynb
diff --git a/docs/api_examples/box_violin_plot.py b/docs/basic_plot_examples/box_violin_plot.py
similarity index 100%
rename from docs/api_examples/box_violin_plot.py
rename to docs/basic_plot_examples/box_violin_plot.py
diff --git a/docs/api_examples/histogram_plot.ipynb b/docs/basic_plot_examples/histogram_plot.ipynb
similarity index 100%
rename from docs/api_examples/histogram_plot.ipynb
rename to docs/basic_plot_examples/histogram_plot.ipynb
diff --git a/docs/api_examples/histogram_plot.py b/docs/basic_plot_examples/histogram_plot.py
similarity index 100%
rename from docs/api_examples/histogram_plot.py
rename to docs/basic_plot_examples/histogram_plot.py
diff --git a/docs/basic_plot_examples/jupytext.toml b/docs/basic_plot_examples/jupytext.toml
new file mode 100644
index 0000000..b662bef
--- /dev/null
+++ b/docs/basic_plot_examples/jupytext.toml
@@ -0,0 +1,6 @@
+# all notebooks in this directory are synced percent format when typing
+# (jupytext is a dev dependency)
+# jupytext --sync *.ipynb
+# or from root directory
+# jupytext --sync docs/basic_plot_examples/*.ipynb
+formats = "ipynb,py:percent"
diff --git a/docs/api_examples/line_plot.ipynb b/docs/basic_plot_examples/line_plot.ipynb
similarity index 100%
rename from docs/api_examples/line_plot.ipynb
rename to docs/basic_plot_examples/line_plot.ipynb
diff --git a/docs/api_examples/line_plot.py b/docs/basic_plot_examples/line_plot.py
similarity index 100%
rename from docs/api_examples/line_plot.py
rename to docs/basic_plot_examples/line_plot.py
diff --git a/docs/basic_plot_examples/outputs/bar_plot_advanced.html b/docs/basic_plot_examples/outputs/bar_plot_advanced.html
new file mode 100644
index 0000000..40b3a83
--- /dev/null
+++ b/docs/basic_plot_examples/outputs/bar_plot_advanced.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/basic_plot_examples/outputs/bar_plot_basic.png b/docs/basic_plot_examples/outputs/bar_plot_basic.png
new file mode 100644
index 0000000..69de8cc
Binary files /dev/null and b/docs/basic_plot_examples/outputs/bar_plot_basic.png differ
diff --git a/docs/basic_plot_examples/outputs/box_plot_advanced.html b/docs/basic_plot_examples/outputs/box_plot_advanced.html
new file mode 100644
index 0000000..023b54f
--- /dev/null
+++ b/docs/basic_plot_examples/outputs/box_plot_advanced.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/basic_plot_examples/outputs/box_plot_basic.png b/docs/basic_plot_examples/outputs/box_plot_basic.png
new file mode 100644
index 0000000..4f1c0f4
Binary files /dev/null and b/docs/basic_plot_examples/outputs/box_plot_basic.png differ
diff --git a/docs/basic_plot_examples/outputs/line_plot_advanced.html b/docs/basic_plot_examples/outputs/line_plot_advanced.html
new file mode 100644
index 0000000..0bb489a
--- /dev/null
+++ b/docs/basic_plot_examples/outputs/line_plot_advanced.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/basic_plot_examples/outputs/line_plot_basic.png b/docs/basic_plot_examples/outputs/line_plot_basic.png
new file mode 100644
index 0000000..e025374
Binary files /dev/null and b/docs/basic_plot_examples/outputs/line_plot_basic.png differ
diff --git a/docs/basic_plot_examples/outputs/scatter_advanced.html b/docs/basic_plot_examples/outputs/scatter_advanced.html
new file mode 100644
index 0000000..3208804
--- /dev/null
+++ b/docs/basic_plot_examples/outputs/scatter_advanced.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/basic_plot_examples/outputs/scatter_basic.png b/docs/basic_plot_examples/outputs/scatter_basic.png
new file mode 100644
index 0000000..7433673
Binary files /dev/null and b/docs/basic_plot_examples/outputs/scatter_basic.png differ
diff --git a/docs/api_examples/scatter_plot.ipynb b/docs/basic_plot_examples/scatter_plot.ipynb
similarity index 100%
rename from docs/api_examples/scatter_plot.ipynb
rename to docs/basic_plot_examples/scatter_plot.ipynb
diff --git a/docs/api_examples/scatter_plot.py b/docs/basic_plot_examples/scatter_plot.py
similarity index 100%
rename from docs/api_examples/scatter_plot.py
rename to docs/basic_plot_examples/scatter_plot.py
diff --git a/docs/conf.py b/docs/conf.py
index ae0a710..05e12d8 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -102,6 +102,7 @@
# "scikit-learn": ("https://scikit-learn.org/stable/", None),
"matplotlib": ("https://matplotlib.org/stable/", None),
"numpy": ("https://numpy.org/doc/stable/", None),
+ "acore": ("https://acore.readthedocs.io/en/latest/", None),
}
# Options for the autodoc_pydantic extension
diff --git a/docs/index.md b/docs/index.md
index a3dd259..3dccea9 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,4 +1,5 @@
## VueCore Documentation
+
@@ -7,7 +8,7 @@
:relative-images:
```
-```{toctree}
+```{toctree}
:maxdepth: 1
:caption: Overview
@@ -17,15 +18,22 @@ sections_readme/execution
sections_readme/license
```
+```{toctree}
+:maxdepth: 1
+:caption: Analysis Plots
+
+analysis_plots/enrichment_analysis
+```
+
```{toctree}
:maxdepth: 1
:caption: API Usage Examples
-api_examples/scatter_plot
-api_examples/line_plot
-api_examples/bar_plot
-api_examples/box_violin_plot
-api_examples/histogram_plot
+basic_plot_examples/scatter_plot
+basic_plot_examples/line_plot
+basic_plot_examples/bar_plot
+basic_plot_examples/box_violin_plot
+basic_plot_examples/histogram_plot
```
```{toctree}
@@ -36,7 +44,7 @@ api_examples/histogram_plot
reference/vuecore
```
-```{toctree}
+```{toctree}
:maxdepth: 1
:caption: Project Support
diff --git a/pyproject.toml b/pyproject.toml
index 5081212..88692df 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,14 +23,17 @@ classifiers = [
dependencies = [
"numpy",
"pandas",
- # "dsp-pandas", # has only pandas related dependencies
+ # "acore[all]", # network_analysis, wgcna_analysis imports
+ "python-louvain", # community detection for network analysis
+ "snfpy", # similarity network fusion for network analysis
+ "networkx",
+ # "dsp-pandas", # has only pandas related dependencies
"scipy",
"plotly",
# "biopython",
"beautifulsoup4",
"requests",
- "dash", # from dash import html
- "networkx",
+ "dash", # from dash import html
"matplotlib",
"kaleido",
"pyvis",
diff --git a/src/vuecore/enrichment_analysis/__init__.py b/src/vuecore/enrichment_analysis/__init__.py
new file mode 100644
index 0000000..71fd3f3
--- /dev/null
+++ b/src/vuecore/enrichment_analysis/__init__.py
@@ -0,0 +1,18 @@
+"""
+Focus on returning a single comparison using `get_enrichment_plot`.
+
+- should the vuecore interface be combined?
+
+"""
+
+from .interactive import get_enrichment_plot as get_enrichment_plot_interactive
+from .interactive import get_enrichment_plot_plotly
+from .static import get_enrichment_plot as get_enrichment_plot_static
+from .static import get_enrichment_plot_mpl
+
+__all__ = [
+ "get_enrichment_plot_interactive",
+ "get_enrichment_plot_mpl",
+ "get_enrichment_plot_plotly",
+ "get_enrichment_plot_static",
+]
diff --git a/src/vuecore/enrichment_analysis/common.py b/src/vuecore/enrichment_analysis/common.py
new file mode 100644
index 0000000..6401cdc
--- /dev/null
+++ b/src/vuecore/enrichment_analysis/common.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+DEFAULT_COMPARISON_SEPARATOR = "~"
+DEFAULT_DIRECTION_COLORS = ["#cb181d", "#3288bd", "#ae017e", "#fcc5c0"]
+
+
+def split_comparison_key(comparison_key: str, separator: str = "~") -> tuple[str, str]:
+ """Split a key of the form 'group1~group2' into individual labels."""
+ if separator not in comparison_key:
+ return comparison_key, ""
+ return comparison_key.split(separator, 1)
+
+
+def format_comparison_title(
+ base_title: str, comparison_key: str, separator: str = DEFAULT_COMPARISON_SEPARATOR
+) -> str:
+ """Build a readable figure title from base title and comparison key."""
+ group_1, group_2 = split_comparison_key(comparison_key, separator)
+ if not group_2:
+ return f"{base_title} {group_1}"
+ return f"{base_title} {group_1} vs {group_2}"
+
+
+def build_color_map(
+ values: Iterable[str],
+ colors: dict[str, str] | list[str] | None = None,
+) -> dict[str, str]:
+ """Map each unique value in `values` to a color.
+
+ If `colors` is a dict, it is returned as-is (a custom scheme). If it is a
+ list (or omitted), unique values are assigned colors in order, cycling
+ through the list if there are more values than colors.
+ """
+ if isinstance(colors, dict):
+ return colors
+ palette = colors if colors is not None else DEFAULT_DIRECTION_COLORS
+ unique_values = sorted(set(values))
+ return {value: palette[i % len(palette)] for i, value in enumerate(unique_values)}
diff --git a/src/vuecore/enrichment_analysis/interactive.py b/src/vuecore/enrichment_analysis/interactive.py
new file mode 100644
index 0000000..d876384
--- /dev/null
+++ b/src/vuecore/enrichment_analysis/interactive.py
@@ -0,0 +1,300 @@
+# %%
+from __future__ import annotations
+
+from logging import getLogger
+
+import numpy as np
+import pandas as pd
+import plotly.express as px
+import plotly.graph_objects as go
+from acore.types.enrichment_analysis import EnrichmentAnalysisSchema
+from pandera.typing.pandas import DataFrame
+
+from vuecore.enrichment_analysis.common import (
+ build_color_map,
+ format_comparison_title,
+)
+
+logger = getLogger(__name__)
+
+ENRICHMENT_HOVERING_COLS = [
+ "foreground",
+ "foreground_pop",
+ "background",
+ "background_pop",
+ "pvalue",
+ "padj",
+ "identifiers",
+]
+
+
+def set_legend_marker_size(figure: go.Figure, size: float = 14) -> None:
+ """Show all legend entries with the same, fixed marker size.
+
+ Plotly derives the legend symbols from the trace markers, so scaling the
+ markers by a column (``size``) makes the legend entries differ in size. The
+ legend entries are therefore replaced by legend-only proxy traces, which
+ leaves the plotted markers untouched. Proxies share the ``legendgroup`` of
+ the trace they replace, so clicking an entry still toggles its group.
+
+ Parameters
+ ----------
+ figure : plotly.graph_objects.Figure
+ Figure to update in place.
+ size : float, optional
+ Marker size in pixels, as in ``marker.size``.
+ """
+ proxies = []
+ for trace in figure.data:
+ if trace.showlegend is False or not trace.name:
+ continue
+ legendgroup = trace.legendgroup or trace.name
+ trace.update(showlegend=False, legendgroup=legendgroup)
+ proxies.append(
+ go.Scatter(
+ x=[None],
+ y=[None],
+ mode="markers",
+ marker={
+ "size": size,
+ "color": trace.marker.color,
+ "symbol": trace.marker.symbol,
+ "opacity": trace.marker.opacity,
+ "line": trace.marker.line,
+ },
+ name=trace.name,
+ legendgroup=legendgroup,
+ showlegend=True,
+ hoverinfo="skip",
+ )
+ )
+ figure.add_traces(proxies)
+
+
+def get_enrichment_plot_plotly(
+ data: pd.DataFrame,
+ x: str = "x",
+ y: str = "y",
+ group: str | None = None,
+ hovering_cols: list[str] | None = None,
+ size: str | None = None,
+ symbol: str | None = None,
+ trendline: str | None = None,
+ text: str | None = None,
+ title: str = "Scatter plot",
+ x_title: str = "x",
+ y_title: str = "y",
+ height: int = 800,
+ width: int = 800,
+ colors: dict[str, str] | None = None,
+ legend_marker_size: float | None = 14,
+) -> go.Figure:
+ """
+ Plot a simple scatter plot.
+
+ Parameters
+ ----------
+ data : pd.DataFrame
+ DataFrame with the columns referenced by the other arguments, at least the
+ x and y values to plot.
+ x : str, optional
+ Column in `data` with the values for x.
+ y : str, optional
+ Column in `data` with the values for y.
+ group : str, optional
+ Column in `data` with the groups - translates into colors.
+ hovering_cols : list[str], optional
+ Columns in `data` that will be shown when hovering over a dot.
+ size : str, optional
+ Column in `data` that contains the size of the dots.
+ symbol : str, optional
+ Column in `data` that contains the symbol of the dots.
+ trendline : str, optional
+ Trendline to draw, as in `plotly.express.scatter` (e.g. 'ols').
+ text : str, optional
+ Column in `data` that contains the values shown for each dot.
+ title : str, optional
+ Title of the figure.
+ x_title : str, optional
+ Plot x axis title.
+ y_title : str, optional
+ Plot y axis title.
+ height : int, optional
+ Plot height.
+ width : int, optional
+ Plot width.
+ colors : dict[str, str], optional
+ Mapping of group name to color, used for each group.
+ legend_marker_size : float, optional
+ Fixed marker size (in pixels) for the legend entries, independent of the
+ marker sizes in the plot. Pass `None` to let the legend follow the plotted
+ marker sizes.
+
+ Returns
+ -------
+ plotly.graph_objects.Figure
+ The scatter plot figure.
+
+ Examples
+ --------
+ >>> result = get_enrichment_plot_plotly(
+ ... data,
+ ... title="Scatter Plot",
+ ... x_title="x_axis",
+ ... y_title="y_axis",
+ ... height=100,
+ ... width=100,
+ ... )
+ """
+ figure = px.scatter(
+ data,
+ x=x,
+ y=y,
+ color=group,
+ color_discrete_map=colors,
+ hover_data=hovering_cols,
+ size=size,
+ symbol=symbol,
+ trendline=trendline,
+ text=text,
+ )
+
+ figure.update_traces(
+ marker={
+ "opacity": 0.7,
+ "line": {"width": 0.5, "color": "DarkSlateGrey"},
+ },
+ selector={"mode": "markers"},
+ )
+ figure["layout"] = go.Layout(
+ title=title,
+ xaxis={"title": x_title},
+ yaxis={"title": y_title},
+ legend={
+ "orientation": "h",
+ "yanchor": "bottom",
+ "y": 1.0,
+ "xanchor": "right",
+ "x": 1,
+ },
+ hovermode="closest",
+ height=height,
+ width=width,
+ annotations=[
+ {"xref": "paper", "yref": "paper", "showarrow": False, "text": ""}
+ ],
+ template="plotly_white",
+ )
+
+ if group is not None and legend_marker_size is not None:
+ set_legend_marker_size(figure, legend_marker_size)
+
+ return figure
+
+
+# acore related plotting function using a defined type in acore.types
+# ToDo: move to acore.plotting?
+def get_enrichment_plot(
+ enrichment_results: pd.DataFrame | DataFrame[EnrichmentAnalysisSchema],
+ comparison: str | None = None,
+ width: int = 900,
+ height: int = 800,
+ title: str = "Enrichment",
+ colors: dict[str, str] | list[str] | None = None,
+ hovering_cols: list = ENRICHMENT_HOVERING_COLS,
+ legend_marker_size: float | None = 14,
+ **kwargs,
+) -> go.Figure:
+ """
+ Create an interactive enrichment scatter plot for a single comparison.
+
+ Parameters
+ ----------
+ enrichment_results : Union[pd.DataFrame, DataFrame[EnrichmentAnalysisSchema]]
+ Enrichment results validated against `EnrichmentAnalysisSchema`, with
+ one or more comparisons stacked in the 'comparison' column.
+ comparison : str, optional
+ Comparison to plot. Required when more than one comparison is present;
+ inferred automatically when there is only one.
+ width : int, optional
+ Plot width.
+ height : int, optional
+ Plot height.
+ title : str, optional
+ Base title for the plot.
+ colors : dict[str, str] or list[str], optional
+ Color mapping by direction, or a palette to assign to directions in
+ order. Defaults to a built-in palette, cycling if there are more
+ unique directions than colors.
+ hovering_cols : list, optional
+ Hover columns shown in tooltips.
+ legend_marker_size : float, optional
+ Fixed marker size (in pixels) for the legend entries, independent of the
+ marker sizes in the plot. Pass `None` to let the legend follow the
+ plotted marker sizes.
+ **kwargs : dict
+ Additional keyword arguments for API parity with the static version.
+
+ Returns
+ -------
+ plotly.graph_objects.Figure
+ The scatter plot for the selected comparison.
+ """
+ df: pd.DataFrame = EnrichmentAnalysisSchema.validate(enrichment_results)
+
+ if kwargs:
+ logger.info(f"Additional unused kwargs passed to get_enrichment_plot: {kwargs}")
+
+ available = sorted(df["comparison"].unique())
+ if comparison is None:
+ if len(available) > 1:
+ raise ValueError(
+ "Multiple comparisons are available: "
+ f"{', '.join(available)}. Pass `comparison` to select one."
+ )
+ comparison = available[0]
+ elif comparison not in available:
+ raise ValueError(
+ f"Comparison '{comparison}' not found. Available comparisons: "
+ f"{', '.join(available)}."
+ )
+
+ df = df.query("comparison == @comparison and rejected").copy()
+ df = df.sort_values(by=["direction", "padj"], ascending=False).reset_index(
+ drop=True
+ )
+ df["x"] = -np.log10(df["padj"])
+ colors = build_color_map(df["direction"], colors)
+
+ return get_enrichment_plot_plotly(
+ df,
+ x="x",
+ y="terms",
+ group="direction",
+ symbol="direction",
+ size="foreground", # column foreground in enrichment_result
+ hovering_cols=hovering_cols,
+ title=format_comparison_title(title, comparison),
+ x_title="-log10(padj)",
+ y_title="Enriched terms",
+ width=width,
+ height=height,
+ colors=colors,
+ legend_marker_size=legend_marker_size,
+ )
+
+
+# %%
+if __name__ == "__main__":
+ # %%
+ import pandas as pd
+
+ fname = "/Users/heweb/Documents/repos/vuecore/tests/data/enrichment_analysis.csv"
+ enrichment_results = pd.read_csv(fname, index_col=0)
+
+ figure = get_enrichment_plot(
+ enrichment_results, width=1500, height=800, title="Enrichment"
+ )
+ figure.show()
+
+# %%
diff --git a/src/vuecore/enrichment_analysis/static.py b/src/vuecore/enrichment_analysis/static.py
new file mode 100644
index 0000000..eb0e4f2
--- /dev/null
+++ b/src/vuecore/enrichment_analysis/static.py
@@ -0,0 +1,259 @@
+# %%
+from __future__ import annotations
+
+from logging import getLogger
+
+import matplotlib.axes
+import matplotlib.figure
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+from acore.types.enrichment_analysis import EnrichmentAnalysisSchema
+from pandera.typing.pandas import DataFrame
+
+from vuecore.enrichment_analysis.common import build_color_map, format_comparison_title
+
+DEFAULT_DPI = 100
+
+logger = getLogger(__name__)
+
+
+def _scale_marker_sizes(
+ values: pd.Series, min_size: float = 60.0, max_size: float = 320.0
+):
+ """Scale foreground counts into matplotlib marker areas."""
+ numeric = pd.to_numeric(values, errors="coerce").fillna(0)
+ if numeric.empty:
+ return []
+ min_value = float(numeric.min())
+ max_value = float(numeric.max())
+ if max_value <= min_value:
+ return [min_size] * len(numeric)
+ scaled = (numeric - min_value) / (max_value - min_value)
+ return (min_size + scaled * (max_size - min_size)).tolist()
+
+
+def set_legend_marker_size(ax: matplotlib.axes.Axes, size: float = 14) -> None:
+ """Pin the markers of the axes' legend to a fixed size.
+
+ Legend handles of a scatter plot inherit the data-driven marker areas, which
+ makes entries differ in size. Call this on any legend you (re-)create, e.g.
+ after ``ax.legend(title=...)``, as that discards the sizes set before.
+
+ Parameters
+ ----------
+ ax : matplotlib.axes.Axes
+ Axes whose current legend should be updated. Without a legend, nothing
+ happens.
+ size : float, optional
+ Marker size as a diameter in points, as in ``Line2D.markersize``.
+ """
+ legend = ax.get_legend()
+ if legend is None:
+ return
+ for handle in legend.legend_handles:
+ # `s` of `Axes.scatter` is an area in points squared
+ handle.set_sizes([size**2])
+
+
+def get_enrichment_plot_mpl(
+ df: pd.DataFrame,
+ comparison_key: str,
+ group: str | None,
+ width: int,
+ height: int,
+ title: str,
+ colors: dict[str, str],
+ col_x: str = "x",
+ col_markersize: str = "foreground",
+ legend_marker_size: float = 14,
+) -> matplotlib.figure.Figure:
+ """Create one enrichment scatter plot as a matplotlib figure.
+
+ Terms are drawn top to bottom in the order of `df`, so sort the rows before
+ calling. Marker areas are scaled linearly between a minimum and maximum from
+ the values in `col_markersize`.
+
+ Parameters
+ ----------
+ df : pd.DataFrame
+ Rows to plot, one per enriched term. Must contain the columns `col_x`,
+ `col_markersize`, 'terms' (used as y tick labels) and, if `group` is
+ given, the grouping column.
+ comparison_key : str
+ Comparison identifier of the form 'group1~group2', appended to `title`
+ as 'group1 vs group2'. A key without the separator is appended as is.
+ group : str, optional
+ Column to color and label the points by, e.g. 'direction'. One legend
+ entry is added per unique value. Pass None to draw all points in a
+ single color without a legend.
+ width : int
+ Plot width in pixels at 100 DPI.
+ height : int
+ Plot height in pixels at 100 DPI.
+ title : str
+ Base title, combined with `comparison_key` into the figure title.
+ colors : dict[str, str]
+ Color per value of the `group` column. Values missing from the mapping
+ fall back to a default blue. Ignored when `group` is None.
+ col_x : str, optional
+ Column with the x values, expected to be -log10 transformed adjusted
+ p-values.
+ col_markersize : str, optional
+ Column driving the marker sizes, typically the foreground counts.
+ Non-numeric and missing values are treated as 0.
+ legend_marker_size : float, optional
+ Fixed marker size for the legend entries, given as a diameter in points
+ (as in `Line2D.markersize`), independent of the marker sizes in the plot.
+
+ Returns
+ -------
+ matplotlib.figure.Figure
+ The scatter plot for the given comparison.
+ """
+ fig_width = width / DEFAULT_DPI
+ fig_height = height / DEFAULT_DPI
+ fig, ax = plt.subplots(figsize=(fig_width, fig_height), dpi=DEFAULT_DPI)
+
+ y_positions = np.arange(len(df))
+ marker_sizes = np.asarray(_scale_marker_sizes(df[col_markersize]), dtype=float)
+
+ if group is None:
+ ax.scatter(
+ df[col_x],
+ y_positions,
+ s=marker_sizes,
+ alpha=0.7,
+ linewidths=0.5,
+ edgecolors="DarkSlateGrey",
+ color="#4c78a8",
+ )
+ else:
+ x_values = df[col_x].to_numpy()
+ # positional indices, so that a non-unique index cannot mislabel rows
+ grouped = df.groupby(group, sort=False, observed=True)
+ for group_value, positions in grouped.indices.items():
+ ax.scatter(
+ x_values[positions],
+ y_positions[positions],
+ s=marker_sizes[positions],
+ alpha=0.7,
+ linewidths=0.5,
+ edgecolors="DarkSlateGrey",
+ color=colors.get(group_value, "#4c78a8"),
+ label=group_value,
+ )
+ ax.legend(loc="best", frameon=False)
+ set_legend_marker_size(ax, legend_marker_size)
+ ax.set_yticks(y_positions)
+ ax.set_yticklabels(df["terms"])
+ ax.set_xlabel("-log10(padj)")
+ ax.set_ylabel("Enriched terms")
+ ax.set_title(format_comparison_title(title, comparison_key))
+ ax.grid(axis="x", linestyle="--", linewidth=0.5, alpha=0.4)
+ ax.set_axisbelow(True)
+ ax.invert_yaxis()
+ fig.tight_layout()
+ return fig
+
+
+# acore related plotting function using a defined type in acore.types
+# ToDo: move to acore.plotting?
+def get_enrichment_plot(
+ enrichment_results: pd.DataFrame | DataFrame[EnrichmentAnalysisSchema],
+ comparison: str | None = None,
+ width: int = 700,
+ height: int = 500,
+ title: str = "Enrichment",
+ colors: dict[str, str] | list[str] | None = None,
+ legend_marker_size: float = 14,
+ **kwargs,
+) -> matplotlib.figure.Figure:
+ """
+ Create a static enrichment scatter plot for a single comparison based on an
+ EnrichmentAnalysisSchema compliant DataFrame. The plot is rendered using matplotlib.
+
+ Parameters
+ ----------
+ enrichment_results : Union[pd.DataFrame, DataFrame[EnrichmentAnalysisSchema]]
+ Enrichment results validated against `EnrichmentAnalysisSchema`, with
+ one or more comparisons stacked in the 'comparison' column.
+ comparison : str, optional
+ Comparison to plot. Required when more than one comparison is present;
+ inferred automatically when there is only one.
+ width : int, optional
+ Plot width.
+ height : int, optional
+ Plot height.
+ title : str, optional
+ Base title for the plot.
+ colors : dict[str, str] or list[str], optional
+ Color mapping by direction, or a palette to assign to directions in
+ order. Defaults to a built-in palette, cycling if there are more
+ unique directions than colors.
+ legend_marker_size : float, optional
+ Fixed marker size for the legend entries, given as a diameter in points
+ (as in `Line2D.markersize`), independent of the marker sizes in the plot.
+ **kwargs : dict
+ Additional keyword arguments for API parity with the interactive version. Static figures do
+ not render hover tooltips.
+
+ Returns
+ -------
+ matplotlib.figure.Figure
+ The scatter plot for the selected comparison.
+ """
+
+ df: pd.DataFrame = EnrichmentAnalysisSchema.validate(enrichment_results)
+
+ if kwargs:
+ logger.info(f"Additional unused kwargs passed to get_enrichment_plot: {kwargs}")
+
+ available = sorted(df["comparison"].unique())
+ if comparison is None:
+ if len(available) > 1:
+ raise ValueError(
+ "Multiple comparisons are available: "
+ f"{', '.join(available)}. Pass `comparison` to select one."
+ )
+ comparison = available[0]
+ elif comparison not in available:
+ raise ValueError(
+ f"Comparison '{comparison}' not found. Available comparisons: "
+ f"{', '.join(available)}."
+ )
+
+ df = df.query("comparison == @comparison and rejected").copy()
+ df = df.sort_values(by=["direction", "padj"], ascending=False).reset_index(
+ drop=True
+ )
+ df["x"] = -np.log10(df["padj"])
+ colors = build_color_map(df["direction"], colors)
+
+ return get_enrichment_plot_mpl(
+ df=df,
+ comparison_key=comparison,
+ group="direction",
+ width=width,
+ height=height,
+ title=title,
+ colors=colors,
+ col_x="x",
+ col_markersize="foreground",
+ legend_marker_size=legend_marker_size,
+ )
+
+
+# %%
+if __name__ == "__main__":
+ # %%
+ import pandas as pd
+
+ fname = "/Users/heweb/Documents/repos/vuecore/tests/data/enrichment_analysis.csv"
+ enrichment_results = pd.read_csv(fname, index_col=0)
+
+ figure = get_enrichment_plot(
+ enrichment_results, width=1500, height=800, title="Enrichment"
+ )
+
+# %%
diff --git a/src/vuecore/interactive.py b/src/vuecore/interactive.py
new file mode 100644
index 0000000..7bcd7a1
--- /dev/null
+++ b/src/vuecore/interactive.py
@@ -0,0 +1,3 @@
+from vuecore.enrichment_analysis.interactive import get_enrichment_plot
+
+__all__ = ["get_enrichment_plot"]
diff --git a/src/vuecore/static.py b/src/vuecore/static.py
new file mode 100644
index 0000000..8af3d18
--- /dev/null
+++ b/src/vuecore/static.py
@@ -0,0 +1,3 @@
+from vuecore.enrichment_analysis.static import get_enrichment_plot
+
+__all__ = ["get_enrichment_plot"]
diff --git a/src/vuecore/viz.py b/src/vuecore/viz.py
index 6e30446..a8361a8 100644
--- a/src/vuecore/viz.py
+++ b/src/vuecore/viz.py
@@ -3415,88 +3415,3 @@ def get_polar_plot(df, identifier, args):
)
return dcc.Graph(id=identifier, figure=figure)
-
-
-def get_enrichment_plots(enrichment_results, identifier, args):
- """
- This function generates a scatter plot with enriched terms (y-axis)
- and their adjusted pvalues (x-axis)
-
- :param pandas.DataFrame enrichment_results: dataframe with the enrichment data to plot
- (see enrichment functions for format)
- :param str identifier: identifier to be used in the app
- :param dict args: dictionary containing the arguments needed to plot the figure
- (width, height, title)
- :return list: list of scatter plots one for each enrichment table available
- (i.e pairwise comparisons)
-
- Example::
-
- figure = get_enrichment_plots(df,
- identifier='enrichment',
- args={'width':1500,
- 'height':800,
- 'title':'Enrichment'}
- )
- """
- figures = []
- width = 900
- height = 800
- colors = {
- "upregulated": "#cb181d",
- "downregulated": "#3288bd",
- "regulated": "#ae017e",
- "non-regulated": "#fcc5c0",
- }
- title = "Enrichment"
- if "width" in args:
- width = args["width"]
- if "height" in args:
- height = args["height"]
- if "title" in args:
- title = args["title"]
-
- if not isinstance(enrichment_results, dict):
- aux = enrichment_results.copy()
- enrichment_results = {"regulated~non-regulated": aux}
-
- for g in enrichment_results:
- g1, g2 = g.split("~")
- group = "direction"
- nid = identifier + f"_{g1}_{g2}"
- if not enrichment_results[g].empty:
- df = enrichment_results[g][enrichment_results[g].rejected]
- if "direction" not in df:
- group = None
- if not df.empty:
- df = df.sort_values(by=[group, "padj"], ascending=False)
- df["x"] = -np.log10(df["padj"])
- fig = get_scatterplot(
- df,
- identifier=nid,
- args={
- "x": "x",
- "y": "terms",
- "group": group,
- "title": f"{title} {g1} vs {g2}",
- "symbol": group,
- "colors": colors,
- "x_title": "-log10(padj)",
- "y_title": "Enriched terms",
- "width": width,
- "height": height,
- "hovering_cols": [
- "foreground",
- "foreground_pop",
- "background",
- "background_pop",
- "pvalue",
- "padj",
- "identifiers",
- ],
- "size": "foreground",
- },
- )
- figures.append(fig)
-
- return figures
diff --git a/tests/data/enrichment_analysis.csv b/tests/data/enrichment_analysis.csv
new file mode 100644
index 0000000..71864eb
--- /dev/null
+++ b/tests/data/enrichment_analysis.csv
@@ -0,0 +1,21 @@
+,terms,identifiers,foreground,background,foreground_pop,background_pop,pvalue,padj,rejected,direction,comparison
+0, citrate CoA-transferase activity [GO:0008814],P75726,2,0,3,1446,0.0006915629322268326,0.001659751037344398,True,upregulated in control,control~10 µm sulforaphane
+1, citrate metabolic process [GO:0006101],P75726,2,1,5,1446,0.0013831258644536651,0.001659751037344398,True,upregulated in control,control~10 µm sulforaphane
+3,ATP-independent citrate lyase complex [GO:0009346],P75726,3,1,3,1446,0.0013831258644536651,0.001659751037344398,True,upregulated in control,control~10 µm sulforaphane
+4,acetyl-CoA metabolic process [GO:0006084],P75726,1,1,1,1446,0.0013831258644536651,0.001659751037344398,True,upregulated in control,control~10 µm sulforaphane
+5,citrate (pro-3S)-lyase activity [GO:0008815],P75726,1,1,1,1446,0.0013831258644536651,0.001659751037344398,True,upregulated in control,control~10 µm sulforaphane
+2, cytoplasm [GO:0005737],P75726,1,28,1,1446,0.020055325034578145,0.020055325034578145,True,upregulated in control,control~10 µm sulforaphane
+1, maltodextrin transmembrane transporter activity [GO:0042958],P02943,1,0,1,1446,0.0006915629322268326,0.0019363762102351312,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+2, maltose transmembrane transport [GO:1904981],P02943,1,0,1,1446,0.0006915629322268326,0.0019363762102351312,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+3, maltose transmembrane transporter activity [GO:0005363],P02943,1,0,1,1446,0.0006915629322268326,0.0019363762102351312,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+4, maltose transporting porin activity [GO:0015481],P02943,1,0,1,1446,0.0006915629322268326,0.0019363762102351312,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+7, polysaccharide transport [GO:0015774],P02943,1,0,1,1446,0.0006915629322268326,0.0019363762102351312,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+0, maltodextrin transmembrane transport [GO:0042956],P02943,1,1,1,1446,0.0013831258644536651,0.0024204702627939143,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+10, virus receptor activity [GO:0001618],P02943,1,1,1,1446,0.0013831258644536651,0.0024204702627939143,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+12,carbohydrate transmembrane transporter activity [GO:0015144],P02943,1,1,1,1446,0.0013831258644536651,0.0024204702627939143,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+5, monoatomic ion transport [GO:0006811],P02943,1,2,1,1446,0.002074688796680498,0.002904564315352697,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+6, outer membrane protein complex [GO:0106234],P02943,1,2,1,1446,0.002074688796680498,0.002904564315352697,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+8, pore complex [GO:0046930],P02943,1,5,1,1446,0.004149377593360996,0.004840940525587829,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+9, porin activity [GO:0015288],P02943,1,5,1,1446,0.004149377593360996,0.004840940525587829,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+13,cell outer membrane [GO:0009279],P02943,1,37,1,1446,0.02627939142461964,0.028300883072667305,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
+11,DNA damage response [GO:0006974],P02943,1,42,1,1446,0.029737206085753802,0.029737206085753802,True,upregulated in 10um sulforaphane,control~10 µm sulforaphane
diff --git a/tests/test_barplot.py b/tests/plots_basic/test_barplot.py
similarity index 100%
rename from tests/test_barplot.py
rename to tests/plots_basic/test_barplot.py
diff --git a/tests/test_boxplot.py b/tests/plots_basic/test_boxplot.py
similarity index 100%
rename from tests/test_boxplot.py
rename to tests/plots_basic/test_boxplot.py
diff --git a/tests/test_histogramplot.py b/tests/plots_basic/test_histogramplot.py
similarity index 100%
rename from tests/test_histogramplot.py
rename to tests/plots_basic/test_histogramplot.py
diff --git a/tests/test_lineplot.py b/tests/plots_basic/test_lineplot.py
similarity index 100%
rename from tests/test_lineplot.py
rename to tests/plots_basic/test_lineplot.py
diff --git a/tests/test_scatterplot.py b/tests/plots_basic/test_scatterplot.py
similarity index 100%
rename from tests/test_scatterplot.py
rename to tests/plots_basic/test_scatterplot.py
diff --git a/tests/test_violinplot.py b/tests/plots_basic/test_violinplot.py
similarity index 100%
rename from tests/test_violinplot.py
rename to tests/plots_basic/test_violinplot.py
diff --git a/tests/test_enrichment_plot.py b/tests/test_enrichment_plot.py
new file mode 100644
index 0000000..e3d01a8
--- /dev/null
+++ b/tests/test_enrichment_plot.py
@@ -0,0 +1,93 @@
+from pathlib import Path
+
+import pandas as pd
+import pytest
+from matplotlib.figure import Figure
+from pandera.errors import SchemaError
+from plotly.graph_objects import Figure as PlotlyFigure
+
+from vuecore.enrichment_analysis import (
+ get_enrichment_plot_interactive,
+ get_enrichment_plot_static,
+)
+
+
+@pytest.fixture
+def enrichment_df() -> pd.DataFrame:
+ data_path = Path(__file__).parent / "data" / "enrichment_analysis.csv"
+ return pd.read_csv(data_path, index_col=0)
+
+
+def test_get_enrichment_plot_infers_single_comparison(
+ enrichment_df: pd.DataFrame,
+):
+ figure = get_enrichment_plot_interactive(enrichment_results=enrichment_df)
+
+ assert isinstance(figure, PlotlyFigure)
+
+
+def test_get_enrichment_plot_splits_by_comparison_column(
+ enrichment_df: pd.DataFrame,
+):
+ other_df = enrichment_df.copy()
+ other_df["comparison"] = "control~20 µm sulforaphane"
+ combined = pd.concat([enrichment_df, other_df], ignore_index=True)
+
+ with pytest.raises(ValueError, match="Multiple comparisons are available"):
+ get_enrichment_plot_interactive(enrichment_results=combined)
+
+ figure = get_enrichment_plot_interactive(
+ enrichment_results=combined, comparison="control~20 µm sulforaphane"
+ )
+ assert isinstance(figure, PlotlyFigure)
+
+
+def test_get_enrichment_plot_unknown_comparison_raises(
+ enrichment_df: pd.DataFrame,
+):
+ with pytest.raises(ValueError, match="not found"):
+ get_enrichment_plot_interactive(
+ enrichment_results=enrichment_df, comparison="does-not-exist"
+ )
+
+
+def test_get_enrichment_plot_missing_required_columns_raises(
+ enrichment_df: pd.DataFrame,
+):
+ bad_df = enrichment_df.drop(columns=["padj"])
+
+ with pytest.raises(SchemaError):
+ get_enrichment_plot_interactive(enrichment_results=bad_df)
+
+
+def test_get_enrichment_plots_static_infers_single_comparison(
+ enrichment_df: pd.DataFrame,
+):
+ figure = get_enrichment_plot_static(enrichment_results=enrichment_df)
+
+ assert isinstance(figure, Figure)
+
+
+def test_get_enrichment_plots_static_splits_by_comparison_column(
+ enrichment_df: pd.DataFrame,
+):
+ other_df = enrichment_df.copy()
+ other_df["comparison"] = "control~20 µm sulforaphane"
+ combined = pd.concat([enrichment_df, other_df], ignore_index=True)
+
+ with pytest.raises(ValueError, match="Multiple comparisons are available"):
+ get_enrichment_plot_static(enrichment_results=combined)
+
+ figure = get_enrichment_plot_static(
+ enrichment_results=combined, comparison="control~20 µm sulforaphane"
+ )
+ assert isinstance(figure, Figure)
+
+
+def test_get_enrichment_plots_static_unknown_comparison_raises(
+ enrichment_df: pd.DataFrame,
+):
+ with pytest.raises(ValueError, match="not found"):
+ get_enrichment_plot_static(
+ enrichment_results=enrichment_df, comparison="does-not-exist"
+ )