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" + )