From 3b2538bf9fc785cd95d364b0603b379208bf2c27 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:34:38 +0000 Subject: [PATCH 1/2] fix: migrate CI off legacy isee-based workflow onto wads-managed uv CI Fixes #8: the legacy .github/workflows/ci.yml pinned Python 3.8 and installed an unpinned isee (which pulls in wads); the currently-published wads uses dict[str, Any] at module level, a TypeError on 3.8. Every push/PR has shown red CI for this reason regardless of what changed, since before #9 landed (gated on a local pytest run instead, per that PR's own notes). Migration (durable fix option from #8, following the wads-migrate skill): - setup.cfg/setup.py -> pyproject.toml (wads-migrate setup-to-pyproject), with the known rough edges fixed by hand: SPDX license string instead of the deprecated [project.license] text table; project_name set to "oplot"; testpaths widened to ["oplot", "tests"] (the old CI actually ran `pytest --doctest-modules $PROJECT_NAME`, i.e. the package itself, so this preserves that doctest coverage rather than narrowing it to tests/ only); disabled the dead [tool.wads.ci.metrics] gate (config_path points at a file that doesn't exist, same pattern already fixed in git2py/focal). - .github/workflows/ci.yml -> the modern uv-based template (wads-migrate ci-to-uv). Widening test collection to the package surfaced two real, pre-existing bugs invisible under the broken CI: - oplot/my.py: `import nympy as np` (typo) made an already-orphaned, unimported module fail to even import. Fixed the typo; the module is still dead code (not referenced from oplot/__init__.py), out of scope here. - oplot/plot_stats.py: smooth_scores's doctest asserted numpy<2.0's bare scalar repr (`[1.5]`, `[2, 3]`); currently-installed numpy 2.x reprs scalars as `np.float64(1.5)` / `np.int64(2)`. Not a logic bug -- updated the expected values to match current numpy's actual (correct) behavior. Also applied `ruff format` fleet-wide (quote style, blank lines) to satisfy the new CI's format-check gate -- no logic changes. Dependents check: oplot's one known local dependent, `shaded`, only imports `oplot.plot_data_set.scatter_and_color_according_to_y` (a module untouched by this change); its test suite runs clean (2 skipped, needs audio fixtures not present, 0 failed) against this branch. Closes #8 Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 427 ++++++++++++++++++++++++++++++++----- README.md | 223 ++++++++++--------- docsrc/conf.py | 32 +-- oplot/distributions.py | 32 +-- oplot/matrix.py | 126 +++++------ oplot/multi_plot.py | 18 +- oplot/multiplots.py | 4 +- oplot/my.py | 18 +- oplot/outlier_scores.py | 19 +- oplot/plot_audio.py | 42 ++-- oplot/plot_data_set.py | 132 ++++++------ oplot/plot_mappings.py | 22 +- oplot/plot_stats.py | 241 +++++++++++---------- oplot/sequences.py | 6 +- oplot/ui_scores_mapping.py | 3 +- oplot/util.py | 11 +- pyproject.toml | 161 ++++++++++++++ setup.cfg | 27 --- setup.py | 3 - 19 files changed, 1002 insertions(+), 545 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4de397..e5a7377 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,92 +1,401 @@ -name: Continuous Integration +name: Continuous Integration (uv) +# MIGRATION NOTE: Old CI uses pylint - new CI uses ruff for linting +# MIGRATION NOTE: PyPI auth changed: set secrets.PYPI_PASSWORD to a PyPI API token (uv publish uses UV_PUBLISH_TOKEN, mapped from PYPI_PASSWORD) on: [push, pull_request] + +# Workflow-level env vars from [tool.wads.ci.env] in pyproject.toml. +# Populated by wads-migrate / wads init via template substitution. +# Contains ONLY non-secret values: PROJECT_NAME and literal defaults from +# env.defaults. Secret-backed vars (required/test/extra_envvars) are +# deliberately scoped to the jobs that run tests — see the validation and +# windows-validation jobs below. A workflow-level secret would be in scope +# for the setup job too, and GitHub then refuses to emit any job OUTPUT +# containing its value: a short value (e.g. a test level of "3") silently +# blanks python-versions, the matrix expands to nothing, and the run fails +# with no failing step (i2mint/wads#61). env: PROJECT_NAME: oplot + jobs: + # First job: Read configuration from pyproject.toml + setup: + name: Read Configuration + runs-on: ubuntu-latest + outputs: + project-name: ${{ steps.config.outputs.project-name }} + python-versions: ${{ steps.config.outputs.python-versions }} + pytest-args: ${{ steps.config.outputs.pytest-args }} + coverage-enabled: ${{ steps.config.outputs.coverage-enabled }} + exclude-paths: ${{ steps.config.outputs.exclude-paths }} + test-on-windows: ${{ steps.config.outputs.test-on-windows }} + windows-blocking: ${{ steps.config.outputs.windows-blocking }} + tests-enabled: ${{ steps.config.outputs.tests-enabled }} + build-sdist: ${{ steps.config.outputs.build-sdist }} + build-wheel: ${{ steps.config.outputs.build-wheel }} + metrics-enabled: ${{ steps.config.outputs.metrics-enabled }} + metrics-config-path: ${{ steps.config.outputs.metrics-config-path }} + metrics-storage-branch: ${{ steps.config.outputs.metrics-storage-branch }} + metrics-python-version: ${{ steps.config.outputs.metrics-python-version }} + metrics-force-run: ${{ steps.config.outputs.metrics-force-run }} + ruff-enabled: ${{ steps.config.outputs.ruff-enabled }} + black-enabled: ${{ steps.config.outputs.black-enabled }} + mypy-enabled: ${{ steps.config.outputs.mypy-enabled }} + docs-enabled: ${{ steps.config.outputs.docs-enabled }} + licence-enabled: ${{ steps.config.outputs.licence-enabled }} + publish-enabled: ${{ steps.config.outputs.publish-enabled }} + skip-ci-marker: ${{ steps.config.outputs.skip-ci-marker }} + publish-marker: ${{ steps.config.outputs.publish-marker }} + trigger-mode: ${{ steps.config.outputs.trigger-mode }} + run-ci-marker: ${{ steps.config.outputs.run-ci-marker }} + commit-subject: ${{ steps.commit.outputs.subject }} + + steps: + # ------------------------------------------------------------------ + # The marker gates below (skip-ci, publish) match against the commit + # SUBJECT — its first line — and never against the whole message. + # + # Why: a squash-merge folds the ENTIRE PR BODY into the squash commit + # message. A `contains()` over the full message therefore fires on a + # PR that merely WRITES ABOUT a marker. That is not hypothetical: a PR + # body quoting the publish marker forced a publish on a repo that had + # publishing disabled, and turned a default branch that had been green + # for the first time in a year red. + # + # Why not `startsWith()`: this house's marker convention is TRAILING + # ("cw v1: an MIT replacement for argh ... [bump minor]") and GitHub + # appends " (#N)" to every squash subject. A prefix match would turn a + # gate that fires too often into one that SILENTLY NEVER FIRES — the + # same defect, in the direction nobody notices. + # + # Shell safety: the commit message is untrusted, attacker-influenced, + # multi-line text. It reaches bash ONLY through the environment, never + # spliced into a command line, so quotes, newlines and backticks in it + # cannot become shell syntax. The value is written with the + # $GITHUB_OUTPUT heredoc form under a per-run random delimiter. + # + # On events with no head commit (e.g. pull_request) the expression + # renders empty, the subject is empty, and every `contains()` gate is + # false — the same verdict the full-message form gave. + # ------------------------------------------------------------------ + - name: Extract commit subject + id: commit + env: + HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + subject="${HEAD_COMMIT_MESSAGE%%$'\n'*}" + subject="${subject%$'\r'}" + delimiter="wads-subject-${RANDOM}${RANDOM}${RANDOM}" + { + printf 'subject<<%s\n' "$delimiter" + printf '%s\n' "$subject" + printf '%s\n' "$delimiter" + } >> "$GITHUB_OUTPUT" + printf 'commit subject: %s\n' "$subject" + + - uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python + run: uv python install 3.11 + + - name: Read CI Config + id: config + uses: i2mint/wads/actions/read-ci-config@master + with: + pyproject-path: . + + # TRIGGER GATE. Every job after `setup` carries the same clause: + # (trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' + # || contains(commit-subject, run-ci-marker)) + # [tool.wads.ci.trigger].mode = "auto" (the default) makes it always true, so + # auto repos behave exactly as before. "on-demand" means NOTHING RUNS UNLESS + # ASKED: the commit SUBJECT must carry run_ci_marker (default "[run ci]"), or + # the run must be a manual workflow_dispatch (always allowed). Publishing and + # Pages obey the same gate. `!= 'on-demand'` fails OPEN to auto when + # read-ci-config installed a wads too old to emit the output: a repo that + # never asked for on-demand must not lose its CI to a version skew. + # + # An on-demand caller stub also pre-filters at zero cost (no runner is + # scheduled for an ordinary push). That pre-filter can only test the whole + # message (expressions have no split), so THIS clause, over the extracted + # subject, is the decision: a marker quoted only in a squash-merged PR body + # costs the setup job and runs nothing else. + + # Second job: Validation using the config validation: name: Validation - if: "!contains(github.event.head_commit.message, '[skip ci]')" + if: "!contains(needs.setup.outputs.commit-subject, '[skip ci]') && (needs.setup.outputs.trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.run-ci-marker))" + needs: setup runs-on: ubuntu-latest + # Secret-backed env vars from [tool.wads.ci.env] land here (test jobs + # only, never workflow level — see the note on the top-level env block), + # rendered as `KEY: ${{ secrets.NAME || '' }}` (an unset secret renders + # as an empty string). The block is omitted entirely when none are + # declared. Names also present in env.defaults are not re-emitted here: + # the committed default at workflow level stays authoritative (matching + # the reusable workflow's export-ci-env). The publish and github-pages + # jobs deliberately do not receive these vars either — parity with + # uv-ci.yml, where only the test jobs export them. + strategy: matrix: - python-version: [3.8] + python-version: ${{ fromJson(needs.setup.outputs.python-versions) }} + steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: i2mint/wads/actions/setup-python-uv@master with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + - name: Install System Dependencies + uses: i2mint/wads/actions/install-system-deps@master + with: + pyproject-path: . + + - name: Install Dependencies + uses: i2mint/wads/actions/install-deps-uv@master + + - name: Format Source Code + if: needs.setup.outputs.ruff-enabled != 'false' + run: uvx ruff format . + + - name: Format Source Code (black) + if: needs.setup.outputs.black-enabled == 'true' + run: uvx black . + + - name: Lint Validation + if: needs.setup.outputs.ruff-enabled != 'false' + run: uvx ruff check --output-format=github ${{ needs.setup.outputs.project-name }} + + # Licence perimeter: fail the build if the INSTALLED dependency closure + # carries a copyleft / non-commercial licence the project's policy + # forbids. Opt-in via [tool.wads.licence].enabled = true, so a repo that + # declares nothing sees no change in CI behaviour at all. + # + # Run once, on the FIRST python-versions entry only: the answer does not + # vary by interpreter, and N identical failures across the matrix is + # noise. (The Windows job is a separate job and never runs this.) + # + # `--python` is load-bearing. `uvx` runs the tool in its OWN isolated + # environment, which contains wads and nothing of the project; without + # pointing it at the project's .venv the check would read the wrong + # closure and report a confident, wrong green. + - name: Licence Perimeter + if: needs.setup.outputs.licence-enabled == 'true' && matrix.python-version == fromJson(needs.setup.outputs.python-versions)[0] run: | - python -m pip install --upgrade pip - pip -q install axblack pytest pylint isee - isee install-requires - - - name: Format source code - run: black --line-length=88 . - - # Documentation on "enable" codes: - # http://pylint.pycqa.org/en/latest/technical_reference/features.html#basic-checker-messages - # C0114: missing-module-docstring - # C0115: missing-class-docstring - # C0116: missing-function-docstring - - name: Validate docstrings - run: pylint ./$PROJECT_NAME --ignore=tests,examples,scrap --disable=all --enable=C0114 - - - name: Test - run: pytest --doctest-modules --ignore=scrap -v $PROJECT_NAME + if [ ! -f ".venv/bin/activate" ]; then + echo "::error::no .venv found; the licence gate must read the project's own installed closure, not uvx's isolated one" + exit 1 + fi + source .venv/bin/activate + uvx --from wads wads-licence-check . --python "$VIRTUAL_ENV/bin/python" + + - name: Type Check (mypy) + if: needs.setup.outputs.mypy-enabled == 'true' + run: uvx mypy ${{ needs.setup.outputs.project-name }} + + - name: Run Tests + if: needs.setup.outputs.tests-enabled != 'false' + uses: i2mint/wads/actions/run-tests-uv@master + with: + root-dir: ${{ needs.setup.outputs.project-name }} + pytest-args: ${{ needs.setup.outputs.pytest-args }} + exclude-paths: ${{ needs.setup.outputs.exclude-paths }} + coverage: ${{ needs.setup.outputs.coverage-enabled }} + + - name: Track Code Metrics + if: needs.setup.outputs.metrics-enabled == 'true' + uses: i2mint/umpyre/actions/track-metrics@master + continue-on-error: true + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + config-path: ${{ needs.setup.outputs.metrics-config-path }} + storage-branch: ${{ needs.setup.outputs.metrics-storage-branch }} + python-version: ${{ needs.setup.outputs.metrics-python-version }} + force-run: ${{ needs.setup.outputs.metrics-force-run }} + + # Optional Windows testing (if enabled in config) + windows-validation: + name: Windows Tests + if: "!contains(needs.setup.outputs.commit-subject, '[skip ci]') && needs.setup.outputs.test-on-windows == 'true' && needs.setup.outputs.tests-enabled != 'false' && (needs.setup.outputs.trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.run-ci-marker))" + needs: setup + runs-on: windows-latest + # Fail-closed opt-in ([tool.wads.ci.testing].windows_blocking): only the + # literal 'true' makes the Windows leg block. An unset output - an older + # read-ci-config that does not emit it - yields '' != 'true', i.e. the + # historical informational behaviour. Blocking reddens the RUN; publish + # still does not depend on this job. + continue-on-error: ${{ needs.setup.outputs.windows-blocking != 'true' }} + env: + # PEP 540 UTF-8 mode: avoid cp1252 UnicodeDecode/EncodeError when test + # code reads source files or scripts print non-ASCII characters. + PYTHONUTF8: "1" + PYTHONIOENCODING: "utf-8" + # Secret-backed env vars from [tool.wads.ci.env] (see the note on the + # validation job). Empty when none are declared. Names colliding with + # the literals above are skipped — a duplicate key in one mapping + # would fail the whole workflow at parse time. + + + steps: + - uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + uses: i2mint/wads/actions/setup-python-uv@master + with: + python-version: ${{ fromJson(needs.setup.outputs.python-versions)[0] }} + + - name: Install System Dependencies + uses: i2mint/wads/actions/install-system-deps@master + with: + pyproject-path: . + + - name: Install Dependencies + uses: i2mint/wads/actions/install-deps-uv@master + + - name: Run Tests + uses: i2mint/wads/actions/run-tests-uv@master + with: + root-dir: ${{ needs.setup.outputs.project-name }} + pytest-args: ${{ needs.setup.outputs.pytest-args }} + exclude-paths: ${{ needs.setup.outputs.exclude-paths }} + + # Publishing job + # + # Gated by [tool.wads.ci.publish] in pyproject.toml (read via the setup job): + # - skip-ci-marker : when publishing is enabled, a commit SUBJECT LINE + # containing this substring skips the publish job + # (default "[skip ci]"). + # - publish-enabled : whether publishing runs at all (default true). + # - publish-marker : when publishing is disabled, a commit SUBJECT LINE + # containing this substring forces the publish job + # (default "[publish]"). + # - trigger-mode : in on-demand mode, publish also needs the run-ci + # marker in the subject or a workflow_dispatch — + # the TRIGGER GATE above `validation`. + # The publish-enabled check uses `== 'true'` (fail-closed): if an older wads + # without these outputs is installed by read-ci-config, publishing is skipped + # rather than run unintentionally. + # + # SUBJECT, not message: both markers are matched against the setup job's + # `commit-subject` output (the first line), never the full message, because + # a squash-merge folds the whole PR BODY into the squash commit message — + # see the note on the extraction step in the setup job. publish: name: Publish - if: "!contains(github.event.head_commit.message, '[skip ci]') && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main')" - needs: validation + permissions: + contents: write + if: "!contains(needs.setup.outputs.commit-subject, needs.setup.outputs.skip-ci-marker) && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (needs.setup.outputs.publish-enabled == 'true' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.publish-marker)) && (needs.setup.outputs.trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.run-ci-marker))" + needs: [setup, validation] runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.8] + steps: - - uses: actions/checkout@v2 + # `actions/checkout@v6` defaults to persist-credentials: true, configuring + # HTTPS auth in .git/config using `secrets.GITHUB_TOKEN`. Together with the + # job-level `permissions: contents: write`, this is exactly what the + # post-publish push-back needs — no per-repo SSH deploy key required. Do + # NOT re-add a "Force SSH for git remote" step: rewriting origin to an + # SSH URL overrides these credentials and reintroduces the push-back + # failure on every repo lacking an SSH_PRIVATE_KEY deploy key. + - uses: actions/checkout@v6 with: fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - - name: Configure Git - run: | - git config --global user.email "thorwhalen1@gmail.com" - git config --global user.name "GitHub CI Runner" + - name: Set up uv + uses: astral-sh/setup-uv@v7 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - name: Set up Python + uses: i2mint/wads/actions/setup-python-uv@master with: - python-version: ${{ matrix.python-version }} + python-version: ${{ fromJson(needs.setup.outputs.python-versions)[0] }} + create-venv: "false" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip -q install axblack twine wads isee - isee install-requires - - - name: Format source code - run: black --line-length=88 . + - name: Format Source Code + if: needs.setup.outputs.ruff-enabled != 'false' + run: uvx ruff format . - - name: Update version number - run: | - export VERSION=$(isee gen-semver) - echo "VERSION=$VERSION" >> $GITHUB_ENV - isee update-setup-cfg + - name: Format Source Code (black) + if: needs.setup.outputs.black-enabled == 'true' + run: uvx black . + - name: Update Version Number + id: version + uses: i2mint/isee/actions/bump-version-number@master - - name: Package - run: python setup.py sdist + - name: Build Distribution + uses: i2mint/wads/actions/build-dist-uv@master + with: + sdist: ${{ needs.setup.outputs.build-sdist }} + wheel: ${{ needs.setup.outputs.build-wheel }} - - name: Publish - run: | - twine upload dist/$PROJECT_NAME-$VERSION.tar.gz -u ${{ secrets.PYPI_USERNAME }} -p ${{ secrets.PYPI_PASSWORD }} --non-interactive --skip-existing --disable-progress-bar - epythet make . github + - name: Publish to PyPI + uses: i2mint/wads/actions/pypi-publish-uv@master + with: + pypi-token: ${{ secrets.PYPI_PASSWORD }} - - name: Push Changes - run: pack check-in "**CI** Formatted code + Updated version number and documentation. [skip ci]" --auto-choose-default-action --bypass-docstring-validation --bypass-tests --bypass-code-formatting --verbose + # A second merge landing on the default branch mid-run used to make this + # push-back fail as non-fast-forward: PyPI had the release, but the bump + # commit and tag never landed and the run went red (i2mint/wads#81). The + # git-commit action replays the bump onto the moved branch and retries; + # see its `push-rebase-retries` input, and the note in + # i2mint/wads .github/workflows/uv-ci.yml on why no `concurrency` group. + - name: Commit Changes + uses: i2mint/wads/actions/git-commit@master + with: + commit-message: "**CI** Formatted code + Updated version to ${{ env.VERSION }} [skip ci]" + push: true - name: Tag Repository - run: isee tag-repo $VERSION + uses: i2mint/wads/actions/git-tag@master + with: + tag: ${{ env.VERSION }} + message: "Release version ${{ env.VERSION }}" + push: true + + # Optional GitHub Pages (skipped when [tool.wads.ci.docs].enabled = false) + # Depends on validation (not publish) so docs still publish when the publish + # job is disabled via [tool.wads.ci.publish].enabled = false. + github-pages: + name: Publish GitHub Pages + permissions: + contents: write + pages: write + id-token: write + if: "!contains(needs.setup.outputs.commit-subject, '[skip ci]') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && needs.setup.outputs.docs-enabled != 'false' && (needs.setup.outputs.trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.run-ci-marker))" + needs: [setup, validation] + runs-on: ubuntu-latest + + steps: + # Check out first so install-system-deps can read [tool.wads.ops.*] from + # pyproject.toml. The epythet action self-checks-out again internally; + # apt-installed system deps persist across that re-checkout. + - uses: actions/checkout@v6 + + # Install [tool.wads.ops.*] system deps (e.g. portaudio for pyaudio) so + # the epythet docs build, which pip-installs this package to extract + # docstrings, doesn't fail on a missing native library at import time. + # No-op when the package declares no system deps. + - name: Install System Dependencies + uses: i2mint/wads/actions/install-system-deps@master + with: + pyproject-path: . + + - uses: i2mint/epythet/actions/publish-github-pages@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + ignore: "tests/,scrap/,examples/" diff --git a/README.md b/README.md index 0ff28b5..42534f8 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,17 @@ ```python import pandas as pd -from oplot import heatmap +from oplot import heatmap + d = pd.DataFrame( [ - {'A': 1, 'B': 3, 'C': 1}, - {'A': 1, 'B': 3, 'C': 2}, - {'A': 5, 'B': 5, 'C': 4}, - {'A': 3, 'B': 2, 'C': 2}, - {'A': 1, 'B': 3, 'C': 3}, - {'A': 4, 'B': 3, 'C': 1}, - {'A': 5, 'B': 1, 'C': 3}, + {"A": 1, "B": 3, "C": 1}, + {"A": 1, "B": 3, "C": 2}, + {"A": 5, "B": 5, "C": 4}, + {"A": 3, "B": 2, "C": 2}, + {"A": 1, "B": 3, "C": 3}, + {"A": 4, "B": 3, "C": 1}, + {"A": 5, "B": 1, "C": 3}, ] ) heatmap(d) @@ -48,16 +49,19 @@ For example: def ax_func(ax): ax.plot([1, 5, 3]) -# with an iterable of functions like ax_func, ax_func_to_plot makes -# a simple grid plot. The parameter n_per_row control the number of plots + +# with an iterable of functions like ax_func, ax_func_to_plot makes +# a simple grid plot. The parameter n_per_row control the number of plots # per row -ax_func_to_plot([ax_func] * 6, - n_per_row=3, - width=5, - height_row=3, - x_labels='x axis name', - y_labels='y axis name', - outer_axis_labels_only=True) +ax_func_to_plot( + [ax_func] * 6, + n_per_row=3, + width=5, + height_row=3, + x_labels="x axis name", + y_labels="y axis name", + outer_axis_labels_only=True, +) ``` @@ -137,8 +141,8 @@ Example of a data dict with data having two different distributions: ```python data_dict = { - 'Unicorn Heights': np.random.normal(loc=6, scale=1, size=1000), - 'Dragon Wingspan': np.concatenate( + "Unicorn Heights": np.random.normal(loc=6, scale=1, size=1000), + "Dragon Wingspan": np.concatenate( [ np.random.normal(loc=3, scale=0.5, size=500), np.random.normal(loc=7, scale=0.5, size=500), @@ -167,18 +171,18 @@ density_distribution( data_dict, ax=ax, axvline_kwargs={ - 'Unicorn Heights': {'color': 'magenta', 'linestyle': ':'}, - 'Dragon Wingspan': {'color': 'cyan', 'linestyle': '-.'}, + "Unicorn Heights": {"color": "magenta", "linestyle": ":"}, + "Dragon Wingspan": {"color": "cyan", "linestyle": "-."}, }, line_width=2, - location_linestyle='-.', - colors=('magenta', 'cyan'), + location_linestyle="-.", + colors=("magenta", "cyan"), density_plot_func=sns.histplot, - density_plot_kwargs={'fill': True}, - text_kwargs={'x': 0.1, 'y': 0.9, 'bbox': dict(facecolor='yellow', alpha=0.5)}, - mean_line_kwargs={'linewidth': 2}, + density_plot_kwargs={"fill": True}, + text_kwargs={"x": 0.1, "y": 0.9, "bbox": dict(facecolor="yellow", alpha=0.5)}, + mean_line_kwargs={"linewidth": 2}, ) -ax.set_title('Customized Density Plot') +ax.set_title("Customized Density Plot") plt.show() ``` @@ -198,14 +202,11 @@ from sklearn.datasets import make_classification ```python from oplot import scatter_and_color_according_to_y -X, y = make_classification(n_samples=500, - n_features=20, - n_classes=4, - n_clusters_per_class=1) +X, y = make_classification( + n_samples=500, n_features=20, n_classes=4, n_clusters_per_class=1 +) -scatter_and_color_according_to_y(X, y, - projection='2d', - dim_reduct='PCA') +scatter_and_color_according_to_y(X, y, projection="2d", dim_reduct="PCA") ``` @@ -215,9 +216,7 @@ scatter_and_color_according_to_y(X, y, ```python from oplot import scatter_and_color_according_to_y -scatter_and_color_according_to_y(X, y, - projection='3d', - dim_reduct='LDA') +scatter_and_color_according_to_y(X, y, projection="3d", dim_reduct="LDA") ``` @@ -228,7 +227,7 @@ There is also that little one, which I don't remeber ever using and needs some w ```python from oplot import side_by_side_bar -side_by_side_bar([[1,2,3], [4,5,6]], list_names=['you', 'me']) +side_by_side_bar([[1, 2, 3], [4, 5, 6]], list_names=["you", "me"]) ``` @@ -245,11 +244,10 @@ sklearn function: from oplot.plot_stats import plot_confusion_matrix from sklearn.datasets import make_classification -X, truth = make_classification(n_samples=500, - n_features=20, - n_classes=4, - n_clusters_per_class=1) - +X, truth = make_classification( + n_samples=500, n_features=20, n_classes=4, n_clusters_per_class=1 +) + # making a copy of truth and messing with it y = truth.copy() y[:50] = (y[:50] + 1) % 4 @@ -266,7 +264,9 @@ the aligned list truth from oplot.plot_stats import make_normal_outlier_timeline scores = np.arange(-1, 3, 0.1) -tags = np.array(['normal'] * 20 + ['outlier'] * 15 + ['crazy'] * (len(scores) - 20 - 15)) +tags = np.array( + ["normal"] * 20 + ["outlier"] * 15 + ["crazy"] * (len(scores) - 20 - 15) +) make_normal_outlier_timeline(tags, scores) ``` @@ -310,9 +310,8 @@ from oplot.plot_stats import plot_outlier_metric_curve scores = np.concatenate([np.random.random(100), np.random.random(100) * 2]) truth = np.array([0] * 100 + [1] * 100) -pair_metrics={'x': 'recall', 'y': 'precision'} -plot_outlier_metric_curve(truth, scores, - pair_metrics=pair_metrics) +pair_metrics = {"x": "recall", "y": "precision"} +plot_outlier_metric_curve(truth, scores, pair_metrics=pair_metrics) ``` @@ -324,9 +323,8 @@ less sense, some not at all. ```python from oplot.plot_stats import plot_outlier_metric_curve -pair_metrics={'x': 'false_positive_rate', 'y': 'false_negative_rate'} -plot_outlier_metric_curve(truth, scores, - pair_metrics=pair_metrics) +pair_metrics = {"x": "false_positive_rate", "y": "false_negative_rate"} +plot_outlier_metric_curve(truth, scores, pair_metrics=pair_metrics) ``` @@ -339,40 +337,53 @@ The full list of usable statistics along with synonymous: ```python # all these scores except for MCC gives a score between 0 and 1. # I normalized MMC into what I call NNMC in order to keep the same scale for all. -base_statistics_dict = {'TPR': lambda tn, fp, fn, tp: tp / (tp + fn), - # sensitivity, recall, hit rate, or true positive rate - 'TNR': lambda tn, fp, fn, tp: tn / (tn + fp), # specificity, selectivity or true negative rate - 'PPV': lambda tn, fp, fn, tp: tp / (tp + fp), # precision or positive predictive value - 'NPV': lambda tn, fp, fn, tp: tn / (tn + fn), # negative predictive value - 'FNR': lambda tn, fp, fn, tp: fn / (fn + tp), # miss rate or false negative rate - 'FPR': lambda tn, fp, fn, tp: fp / (fp + tn), # fall-out or false positive rate - 'FDR': lambda tn, fp, fn, tp: fp / (fp + tp), # false discovery rate - 'FOR': lambda tn, fp, fn, tp: fn / (fn + tn), # false omission rate - 'TS': lambda tn, fp, fn, tp: tp / (tp + fn + fp), - # threat score (TS) or Critical Success Index (CSI) - 'ACC': lambda tn, fp, fn, tp: (tp + tn) / (tp + tn + fp + fn), # accuracy - 'F1': lambda tn, fp, fn, tp: (2 * tp) / (2 * tp + fp + fn), # F1 score - 'NMCC': lambda tn, fp, fn, tp: ((tp * tn - fp * fn) / ( - (tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)) ** 0.5 + 1) / 2, - # NORMALIZED TO BE BETWEEN 0 AND 1 Matthews correlation coefficient - 'BM': lambda tn, fp, fn, tp: tp / (tp + fn) + tn / (tn + fp) - 1, - # Informedness or Bookmaker Informedness - 'MK': lambda tn, fp, fn, tp: tp / (tp + fp) + tn / (tn + fn) - 1} # Markedness - -synonyms = {'TPR': ['recall', 'sensitivity', 'true_positive_rate', 'hit_rate', 'tpr'], - 'TNR': ['specificity', 'SPC', 'true_negative_rate', 'selectivity', 'tnr'], - 'PPV': ['precision', 'positive_predictive_value', 'ppv'], - 'NPV': ['negative_predictive_value', 'npv'], - 'FNR': ['miss_rate', 'false_negative_rate', 'fnr'], - 'FPR': ['fall_out', 'false_positive_rate', 'fpr'], - 'FDR': ['false_discovery_rate', 'fdr'], - 'FOR': ['false_omission_rate', 'for'], - 'TS': ['threat_score', 'critical_success_index', 'CSI', 'csi', 'ts'], - 'ACC': ['accuracy', 'acc'], - 'F1': ['f1_score', 'f1', 'F1_score'], - 'NMCC': ['normalized_Matthews_correlation_coefficient', 'nmcc'], - 'BM': ['informedness', 'bookmaker_informedness', 'bi', 'BI', 'bm'], - 'MK': ['markedness', 'mk']} +base_statistics_dict = { + "TPR": lambda tn, fp, fn, tp: tp / (tp + fn), + # sensitivity, recall, hit rate, or true positive rate + "TNR": lambda tn, fp, fn, tp: ( + tn / (tn + fp) + ), # specificity, selectivity or true negative rate + "PPV": lambda tn, fp, fn, tp: ( + tp / (tp + fp) + ), # precision or positive predictive value + "NPV": lambda tn, fp, fn, tp: tn / (tn + fn), # negative predictive value + "FNR": lambda tn, fp, fn, tp: fn / (fn + tp), # miss rate or false negative rate + "FPR": lambda tn, fp, fn, tp: fp / (fp + tn), # fall-out or false positive rate + "FDR": lambda tn, fp, fn, tp: fp / (fp + tp), # false discovery rate + "FOR": lambda tn, fp, fn, tp: fn / (fn + tn), # false omission rate + "TS": lambda tn, fp, fn, tp: tp / (tp + fn + fp), + # threat score (TS) or Critical Success Index (CSI) + "ACC": lambda tn, fp, fn, tp: (tp + tn) / (tp + tn + fp + fn), # accuracy + "F1": lambda tn, fp, fn, tp: (2 * tp) / (2 * tp + fp + fn), # F1 score + "NMCC": lambda tn, fp, fn, tp: ( + ( + (tp * tn - fp * fn) / ((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)) ** 0.5 + + 1 + ) + / 2 + ), + # NORMALIZED TO BE BETWEEN 0 AND 1 Matthews correlation coefficient + "BM": lambda tn, fp, fn, tp: tp / (tp + fn) + tn / (tn + fp) - 1, + # Informedness or Bookmaker Informedness + "MK": lambda tn, fp, fn, tp: tp / (tp + fp) + tn / (tn + fn) - 1, +} # Markedness + +synonyms = { + "TPR": ["recall", "sensitivity", "true_positive_rate", "hit_rate", "tpr"], + "TNR": ["specificity", "SPC", "true_negative_rate", "selectivity", "tnr"], + "PPV": ["precision", "positive_predictive_value", "ppv"], + "NPV": ["negative_predictive_value", "npv"], + "FNR": ["miss_rate", "false_negative_rate", "fnr"], + "FPR": ["fall_out", "false_positive_rate", "fpr"], + "FDR": ["false_discovery_rate", "fdr"], + "FOR": ["false_omission_rate", "for"], + "TS": ["threat_score", "critical_success_index", "CSI", "csi", "ts"], + "ACC": ["accuracy", "acc"], + "F1": ["f1_score", "f1", "F1_score"], + "NMCC": ["normalized_Matthews_correlation_coefficient", "nmcc"], + "BM": ["informedness", "bookmaker_informedness", "bi", "BI", "bm"], + "MK": ["markedness", "mk"], +} ``` @@ -414,7 +425,7 @@ x = np.concatenate([x1, x2]) y = np.concatenate([y1, y2]) # Create a DataFrame -data = pd.DataFrame({'x': x, 'y': y}) +data = pd.DataFrame({"x": x, "y": y}) ``` ## Plot with Boundary Condition `y ≤ x` @@ -429,17 +440,17 @@ boundary_condition = lambda X, Y: Y <= X # Plot using the custom KDE function ax = kdeplot_w_boundary_condition( data=data, - x='x', - y='y', + x="x", + y="y", boundary_condition=boundary_condition, fill=True, - cmap='viridis', + cmap="viridis", figsize=(8, 6), - levels=15 # Increased levels for better resolution + levels=15, # Increased levels for better resolution ) # Add a title -ax.set_title('KDE Plot with Boundary Condition: y ≤ x') +ax.set_title("KDE Plot with Boundary Condition: y ≤ x") # Show the plot plt.show() @@ -454,17 +465,17 @@ plt.show() ```python ax = kdeplot_w_boundary_condition( data=data, - x='x', - y='y', + x="x", + y="y", boundary_condition=None, # No boundary condition fill=True, - cmap='viridis', + cmap="viridis", figsize=(8, 6), - levels=15 + levels=15, ) # Add a title -ax.set_title('KDE Plot without Boundary Condition') +ax.set_title("KDE Plot without Boundary Condition") # Show the plot plt.show() @@ -491,17 +502,17 @@ boundary_condition = lambda X, Y: Y >= X # Plot using the custom KDE function ax = kdeplot_w_boundary_condition( data=data, - x='x', - y='y', + x="x", + y="y", boundary_condition=boundary_condition, fill=True, - cmap='coolwarm', + cmap="coolwarm", figsize=(8, 6), - levels=15 + levels=15, ) # Add a title -ax.set_title('KDE Plot with Boundary Condition: y ≥ x') +ax.set_title("KDE Plot with Boundary Condition: y ≥ x") # Show the plot plt.show() @@ -516,22 +527,22 @@ plt.show() ```python # Define a circular boundary condition function -boundary_condition = lambda X, Y: (X - 2)**2 + (Y - 2)**2 <= 3**2 +boundary_condition = lambda X, Y: (X - 2) ** 2 + (Y - 2) ** 2 <= 3**2 # Plot using the custom KDE function ax = kdeplot_w_boundary_condition( data=data, - x='x', - y='y', + x="x", + y="y", boundary_condition=boundary_condition, fill=True, - cmap='plasma', + cmap="plasma", figsize=(8, 6), - levels=15 + levels=15, ) # Add a title -ax.set_title('KDE Plot with Circular Boundary Condition') +ax.set_title("KDE Plot with Circular Boundary Condition") # Show the plot plt.show() diff --git a/docsrc/conf.py b/docsrc/conf.py index cc88c81..f85e344 100644 --- a/docsrc/conf.py +++ b/docsrc/conf.py @@ -13,14 +13,14 @@ import os import sys -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.abspath("..")) # -- Project information ----------------------------------------------------- from epythet.config_parser import parse_config from pathlib import Path project, copyright, author, release, display_name = parse_config( - Path(__file__).absolute().parent.parent / 'setup.cfg' + Path(__file__).absolute().parent.parent / "setup.cfg" ) # -- General configuration --------------------------------------------------- @@ -29,37 +29,37 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx_toggleprompt', - 'sphinx_copybutton', - 'sphinx.ext.autodoc', # Include documentation from docstrings - 'sphinx.ext.doctest', # Test snippets in the documentation - 'sphinx.ext.githubpages', # This extension creates .nojekyll file - 'sphinx.ext.graphviz', # Add Graphviz graphs - 'sphinx.ext.napoleon', # Support for NumPy and Google style docstrings - 'sphinx.ext.todo', # Support for todo items - 'sphinx.ext.viewcode', # Add links to highlighted source code - 'myst_parser', # Parse .md files + "sphinx_toggleprompt", + "sphinx_copybutton", + "sphinx.ext.autodoc", # Include documentation from docstrings + "sphinx.ext.doctest", # Test snippets in the documentation + "sphinx.ext.githubpages", # This extension creates .nojekyll file + "sphinx.ext.graphviz", # Add Graphviz graphs + "sphinx.ext.napoleon", # Support for NumPy and Google style docstrings + "sphinx.ext.todo", # Support for todo items + "sphinx.ext.viewcode", # Add links to highlighted source code + "myst_parser", # Parse .md files ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # -- Options for Markdown support ------------------------------------------- diff --git a/oplot/distributions.py b/oplot/distributions.py index a143072..3b30a8d 100644 --- a/oplot/distributions.py +++ b/oplot/distributions.py @@ -22,18 +22,18 @@ def kdeplot_w_boundary_condition( fill=True, cmap=None, figsize=None, - **kwargs + **kwargs, ): """ Custom KDE plot that respects a boundary condition and handles datetime data. - This is useful because sometimes when you have data that fits certain conditions - (e.g. y <= x), you want to plot the KDE of the data but only where the condition + This is useful because sometimes when you have data that fits certain conditions + (e.g. y <= x), you want to plot the KDE of the data but only where the condition is met. If you scatter the data, you can see the boundary, but the KDE plot will not respect the boundary. This function allows you to specify a boundary condition - and only plot the KDE where the condition is met. + and only plot the KDE where the condition is met. + - Parameters: - data: DataFrame, optional @@ -59,20 +59,20 @@ def kdeplot_w_boundary_condition( >>> import numpy as np >>> import pandas as pd >>> import matplotlib.pyplot as plt - + Generate sample data >>> np.random.seed(42) >>> x = np.random.normal(0, 1, 500) >>> y = np.random.normal(0, 1, 500) >>> data = pd.DataFrame({'x': x, 'y': y}) - + Define a boundary condition - + >>> boundary_condition = lambda X, Y: Y <= X - + Plot using the custom KDE function - + >>> ax = kdeplot_w_boundary_condition( ... data=data, ... x='x', @@ -88,7 +88,7 @@ def kdeplot_w_boundary_condition( if ax is None: fig, ax = plt.subplots(figsize=figsize) else: - assert figsize is None, 'figsize should not be provided if ax is provided.' + assert figsize is None, "figsize should not be provided if ax is provided." if boundary_condition is None: return sns.kdeplot( @@ -102,7 +102,7 @@ def kdeplot_w_boundary_condition( if isinstance(y, str): y = data[y] if x is None or y is None: - raise ValueError('Both x and y must be provided.') + raise ValueError("Both x and y must be provided.") x = np.asarray(x) y = np.asarray(y) @@ -126,7 +126,7 @@ def kdeplot_w_boundary_condition( xmin, xmax = x_values.min(), x_values.max() ymin, ymax = y_values.min(), y_values.max() - num_grid_points = kwargs.pop('gridsize', 100) + num_grid_points = kwargs.pop("gridsize", 100) x_grid = np.linspace(xmin, xmax, num_grid_points) y_grid = np.linspace(ymin, ymax, num_grid_points) X, Y = np.meshgrid(x_grid, y_grid) @@ -141,7 +141,7 @@ def kdeplot_w_boundary_condition( mask = boundary_condition(X, Y) Z = np.where(mask, Z, 0) else: - raise ValueError('A boundary_condition function must be provided.') + raise ValueError("A boundary_condition function must be provided.") # Plot the density if ax is None: @@ -173,7 +173,7 @@ def kdeplot_w_boundary_condition( plt.setp(ax.get_yticklabels(), rotation=45) # Set labels - ax.set_xlabel(kwargs.get('xlabel', 'x')) - ax.set_ylabel(kwargs.get('ylabel', 'y')) + ax.set_xlabel(kwargs.get("xlabel", "x")) + ax.set_ylabel(kwargs.get("ylabel", "y")) return ax diff --git a/oplot/matrix.py b/oplot/matrix.py index 0d49d04..24466e6 100644 --- a/oplot/matrix.py +++ b/oplot/matrix.py @@ -15,9 +15,9 @@ def xy_boxplot(X, y=None, col_labels=None, grid_size=None): n_cols = X.shape[1] plt.boxplot(X) if col_labels is not None: - assert n_cols == len( - col_labels - ), 'the number of items in col_labels should be equal to the num of cols' + assert n_cols == len(col_labels), ( + "the number of items in col_labels should be equal to the num of cols" + ) plt.xticks(list(range(1, n_cols + 1)), col_labels) else: unik_ys = np.unique(y) @@ -35,21 +35,21 @@ def xy_boxplot(X, y=None, col_labels=None, grid_size=None): plt.gca().set_title(yy) -def vlines_ranges(X, aggr=('min', 'median', 'max'), axis=0, **kwargs): +def vlines_ranges(X, aggr=("min", "median", "max"), axis=0, **kwargs): """vlines plot statistics of X matrix data""" if isinstance(aggr, int): if aggr == 2: - aggr = ('min', 'max') + aggr = ("min", "max") elif aggr == 3: - aggr = ('min', 'median', 'max') - assert len(aggr) >= 2, 'aggr must have at least 2 elements' + aggr = ("min", "median", "max") + assert len(aggr) >= 2, "aggr must have at least 2 elements" lo_val = getattr(np, aggr[0])(X, axis=axis) hi_val = getattr(np, aggr[-1])(X, axis=axis) x = np.arange(len(lo_val)) plt.vlines(x, ymin=lo_val, ymax=hi_val, **kwargs) if len(aggr) > 2: - markers = 'oxsd' + markers = "oxsd" for i, a in enumerate(aggr[1:-1]): plt.plot(x, getattr(np, a)(X, axis=axis), markers[i], **kwargs) @@ -72,19 +72,19 @@ def vlines_of_matrix( ax = plt.gca() n_items, n_cols = X.shape if col_labels is not None: - assert ( - len(col_labels) == n_cols - ), "number of col_labels didn't match the number of columns in the input matrix" + assert len(col_labels) == n_cols, ( + "number of col_labels didn't match the number of columns in the input matrix" + ) for i in range(n_cols): ax.vlines( np.linspace(i + padding, i + 1 - padding, n_items), 0, np.ravel(X[:, i]), - colors='k', + colors="k", alpha=alpha, ) - ax.hlines(0, i + padding, i + 1 - padding, colors='b', alpha=1) + ax.hlines(0, i + padding, i + 1 - padding, colors="b", alpha=1) if y_lim is not None: ax.y_lim = plt.ylim(y_lim) @@ -96,8 +96,8 @@ def vlines_of_matrix( else: plt.xticks([]) - ax.set_facecolor('w') - plt.grid('off', axis='x') + ax.set_facecolor("w") + plt.grid("off", axis="x") else: item_labels = np.unique(y) n_item_labels = len(item_labels) @@ -137,9 +137,9 @@ def heatmap( n_items, n_cols = X.shape if col_labels is not None: if col_labels is not False: - assert ( - len(col_labels) == n_cols - ), 'col_labels length should be the same as the number of columns in the matrix' + assert len(col_labels) == n_cols, ( + "col_labels length should be the same as the number of columns in the matrix" + ) elif isinstance(X, pd.DataFrame): col_labels = list(X.columns) @@ -152,12 +152,12 @@ def heatmap( if cmap is None: if X.min(axis=0).min(axis=0) < 0: - cmap = 'RdBu_r' + cmap = "RdBu_r" else: - cmap = 'hot_r' + cmap = "hot_r" - kwargs['cmap'] = cmap - kwargs = dict(kwargs, interpolation='nearest', aspect='auto') + kwargs["cmap"] = cmap + kwargs = dict(kwargs, interpolation="nearest", aspect="auto") if figsize is not False: plt.figure(figsize=figsize) @@ -170,7 +170,7 @@ def heatmap( if y is not None: y = np.array(y) - assert all(sorted(y) == y), 'This will only work if your row_labels are sorted' + assert all(sorted(y) == y), "This will only work if your row_labels are sorted" unik_ys, unik_ys_idx = np.unique(y, return_index=True) for u, i in zip(unik_ys, unik_ys_idx): @@ -178,16 +178,16 @@ def heatmap( i - 0.5, 0 - 0.5, n_cols - 0.5, - colors='b', - linestyles='dotted', + colors="b", + linestyles="dotted", alpha=0.5, ) plt.hlines( n_items - 0.5, 0 - 0.5, n_cols - 0.5, - colors='b', - linestyles='dotted', + colors="b", + linestyles="dotted", alpha=0.5, ) plt.yticks( @@ -212,33 +212,33 @@ def heatmap( def labeled_heatmap(X, y=None, col_labels=None): n_items, n_cols = X.shape if col_labels is not None: - assert ( - len(col_labels) == n_cols - ), 'col_labels length should be the same as the number of columns in the matrix' + assert len(col_labels) == n_cols, ( + "col_labels length should be the same as the number of columns in the matrix" + ) - heatmap(X, cmap='hot_r') + heatmap(X, cmap="hot_r") plt.grid(None) - assert all(sorted(y) == y), 'This will only work if your row_labels are sorted' + assert all(sorted(y) == y), "This will only work if your row_labels are sorted" unik_ys, unik_ys_idx = np.unique(y, return_index=True) for u, i in zip(unik_ys, unik_ys_idx): plt.hlines( - i - 0.5, 0 - 0.5, n_cols - 0.5, colors='b', linestyles='dotted', alpha=0.5 + i - 0.5, 0 - 0.5, n_cols - 0.5, colors="b", linestyles="dotted", alpha=0.5 ) plt.hlines( - n_items - 0.5, 0 - 0.5, n_cols - 0.5, colors='b', linestyles='dotted', alpha=0.5 + n_items - 0.5, 0 - 0.5, n_cols - 0.5, colors="b", linestyles="dotted", alpha=0.5 ) plt.yticks(unik_ys_idx + np.diff(np.hstack((unik_ys_idx, n_items))) / 2, unik_ys) if col_labels is not None: - assert ( - len(col_labels) == n_cols - ), 'col_labels length should be the same as the number of columns in the matrix' + assert len(col_labels) == n_cols, ( + "col_labels length should be the same as the number of columns in the matrix" + ) plt.xticks(list(range(len(col_labels))), col_labels) else: plt.xticks([]) - plt.gca().xaxis.set_tick_params(labeltop='on') + plt.gca().xaxis.set_tick_params(labeltop="on") DFLT_MAX_FIGSIZE = 11 @@ -275,7 +275,7 @@ def get_figsize_to_fit(shape: tuple[int, int], max_size=DFLT_MAX_FIGSIZE): def heatmap_sns( df: pd.DataFrame, *, - cmap: str = 'Oranges', + cmap: str = "Oranges", xlabel: str = None, ylabel: str = None, xlabel_fontsize: int = 12, @@ -289,9 +289,9 @@ def heatmap_sns( vert_lines: int | list = 5, horiz_lines: int | list = 5, linewidths: float = 0.5, - linecolor: str = 'white', - major_line_color: str = '#D3D3D3', - major_line_style: str = '-', + linecolor: str = "white", + major_line_color: str = "#D3D3D3", + major_line_style: str = "-", vmin: float = 0.2, # To enhance contrast in color mapping vmax: float = 1, # To enhance contrast in color mapping ): @@ -361,17 +361,17 @@ def heatmap_sns( ) # Shift x-ticks to center of cells plt.gca().set_xticklabels( - df.columns, rotation=x_tick_rotation, fontsize=x_tick_fontsize, ha='center' + df.columns, rotation=x_tick_rotation, fontsize=x_tick_fontsize, ha="center" ) plt.gca().set_yticks( [y + 0.5 for y in range(len(df.index))] ) # Shift y-ticks to center of cells plt.gca().set_yticklabels( - df.index, rotation=y_tick_rotation, fontsize=y_tick_fontsize, va='center' + df.index, rotation=y_tick_rotation, fontsize=y_tick_fontsize, va="center" ) - plt.gca().xaxis.set_label_position('top') + plt.gca().xaxis.set_label_position("top") plt.gca().xaxis.tick_top() if xlabel: @@ -396,7 +396,7 @@ def heatmap_sns( def plot_simil_mat_with_labels( simil_mat, y, - inner_class_ordering='mean_shift_clusters', + inner_class_ordering="mean_shift_clusters", brightness=1.0, figsize=(10, 10), ): @@ -408,31 +408,31 @@ def plot_simil_mat_with_labels( simil_mat = simil_mat ** (1 / float(brightness)) d = pd.DataFrame(simil_mat) - d['y'] = y + d["y"] = y - if inner_class_ordering == 'sum_simil': - d['order'] = d.sum(axis=1) - elif inner_class_ordering == 'mean_shift_clusters': - d['order'] = np.nan + if inner_class_ordering == "sum_simil": + d["order"] = d.sum(axis=1) + elif inner_class_ordering == "mean_shift_clusters": + d["order"] = np.nan for y_val in np.unique(y): lidx = y == y_val clus = MeanShift().fit(simil_mat[lidx][:, lidx]) - d['order'].iloc[lidx] = clus.labels_ + d["order"].iloc[lidx] = clus.labels_ else: - raise ValueError('Unknown inner_class_ordering') + raise ValueError("Unknown inner_class_ordering") - d = d.sort(['y', 'order'], ascending=False) - y_vals = d['y'] - d = d.drop(labels=['y', 'order'], axis=1) + d = d.sort(["y", "order"], ascending=False) + y_vals = d["y"] + d = d.drop(labels=["y", "order"], axis=1) permi = d.index.values w = simil_mat[permi][:, permi] plt.figure(figsize=figsize) ax = plt.gca() - ax.matshow(w, cmap='gray_r') + ax.matshow(w, cmap="gray_r") ax.grid(b=False) - ax.set_aspect('equal', 'box') + ax.set_aspect("equal", "box") mids = list() unik_y_vals = np.unique(y_vals) for y_val in unik_y_vals: @@ -447,7 +447,7 @@ def plot_simil_mat_with_labels( height=s, fill=False, linewidth=2, - color='blue', + color="blue", alpha=0.5, ) ) @@ -480,14 +480,14 @@ def hierarchical_cluster_sorted_heatmap( df = df.iloc[ df.index.values, df.index.values ] # to make sure df is an index aligned square df - Y = sch.linkage(np.array(df), method='centroid') - Z = sch.dendrogram(Y, orientation='right', no_plot=True) - index = np.array(Z['leaves']) + Y = sch.linkage(np.array(df), method="centroid") + Z = sch.dendrogram(Y, orientation="right", no_plot=True) + index = np.array(Z["leaves"]) df = df.iloc[index, index] if only_return_sorted_df: return df else: if seaborn_heatmap_kwargs is None: seaborn_heatmap_kwargs = {} - seaborn_heatmap_kwargs = dict({'cbar': False}, **seaborn_heatmap_kwargs) + seaborn_heatmap_kwargs = dict({"cbar": False}, **seaborn_heatmap_kwargs) return sns.heatmap(df, **seaborn_heatmap_kwargs) diff --git a/oplot/multi_plot.py b/oplot/multi_plot.py index 14ea82a..032d9ac 100644 --- a/oplot/multi_plot.py +++ b/oplot/multi_plot.py @@ -10,9 +10,9 @@ def _get_attr_args_and_kwargs_from_ax_call_item(ax_call_item): :return: """ if isinstance(ax_call_item, dict): - attr = ax_call_item['attr'] - args = ax_call_item.pop('args', ()) - kwargs = ax_call_item.pop('kwargs', {}) + attr = ax_call_item["attr"] + args = ax_call_item.pop("args", ()) + kwargs = ax_call_item.pop("kwargs", {}) else: attr = ax_call_item[0] if len(ax_call_item) == 2: @@ -64,9 +64,9 @@ def multi_row_plot( plt.subplot(n_rows, 1, row_idx) specific_ax_calls = () if isinstance(row_data, dict): - specific_ax_calls = row_data.pop('ax_calls', ()) - if 'row_data' in row_data: - row_data = row_data['row_data'] + specific_ax_calls = row_data.pop("ax_calls", ()) + if "row_data" in row_data: + row_data = row_data["row_data"] if isinstance(row_data, dict): plot_func(**dict(plot_func_kwargs, **row_data)) @@ -141,10 +141,10 @@ def ax_func_to_plot( plt.show() -if __name__ == '__main__': +if __name__ == "__main__": def ax_func(ax): ax.plot([1, 5, 3]) - ax.set_title('test_test') + ax.set_title("test_test") - ax_func_to_plot([ax_func] * 6, title='Test', x_labels='x_name_here') + ax_func_to_plot([ax_func] * 6, title="Test", x_labels="x_name_here") diff --git a/oplot/multiplots.py b/oplot/multiplots.py index f790fdb..da2666b 100644 --- a/oplot/multiplots.py +++ b/oplot/multiplots.py @@ -6,8 +6,8 @@ def make_space_above(axes, topmargin=1): - """ increase figure size to make topmargin (in inches) space for - titles, without changing the axes sizes""" + """increase figure size to make topmargin (in inches) space for + titles, without changing the axes sizes""" fig = axes.flatten()[0].figure s = fig.subplotpars diff --git a/oplot/my.py b/oplot/my.py index 549a8cb..a94cea4 100644 --- a/oplot/my.py +++ b/oplot/my.py @@ -1,6 +1,6 @@ """What matplotlib has already -- but with the interface I'd like""" -import nympy as np +import numpy as np import matplotlib.pylab as plt @@ -8,21 +8,21 @@ def vlines( x, ymin=0, ymax=None, - marker='o', + marker="o", marker_kwargs=None, - colors='k', - linestyles='solid', - label='', + colors="k", + linestyles="solid", + label="", hold=None, data=None, - **kwargs + **kwargs, ): if ymax is None: ymax = x x = np.arange(len(ymax)) if ymax is None: - raise ValueError('Need to specify ymax') + raise ValueError("Need to specify ymax") if marker is not None: if marker_kwargs is None: @@ -38,7 +38,5 @@ def vlines( label=label, hold=hold, data=data, - **kwargs + **kwargs, ) - - diff --git a/oplot/outlier_scores.py b/oplot/outlier_scores.py index 0642c10..a73a5c1 100644 --- a/oplot/outlier_scores.py +++ b/oplot/outlier_scores.py @@ -1,7 +1,6 @@ """Visualizing various regions in a list of scores. E.g. adding colored regions corresponding to percentiles to a timeline of outlier scores""" - import numpy as np import matplotlib.pyplot as plt @@ -27,7 +26,7 @@ def sort_scores_truth(scores, truth): def get_confused_part(sorted_scores, sorted_truth): """ Return the scores in the confused zone, i.e. scores whose range of values contains normal and anormal samples. - + :param sorted_scores: an array of outlier scores, higher is more abnormal :param sorted_truth: an array of 0 for normal and 1 for abnormal :return: an array of scores @@ -43,7 +42,7 @@ def get_confused_part(sorted_scores, sorted_truth): def find_last_normal_idx(sorted_truth): """ Return the index of the last 0 in the sorted_truth array - + :param sorted_truth: an array of 0 or 1 :return: an int, the last idx of a 0 in sorted_truth """ @@ -141,7 +140,7 @@ def get_percentiles(scores, n_percentiles): def get_confusion_zones_percentiles(scores, truth, n_percentiles=1): """ Get the percentiles of the normal scores in the confused zone. - + :param scores: an array of outlier scores :param truth: an array of 0 for normal and 1 for abnormal :param n_percentiles: the number of percentiles required @@ -181,7 +180,7 @@ def get_confusion_zones_percentiles(scores, truth, n_percentiles=1): def get_confusion_zones_std(scores, truth=None, n_zones=6, std_per_zone=0.5): """ Get a list of zones boundaries based on the standard deviation of the normal scores - + :param scores: an array of outlier scores :param truth: an array of 0 for normal and 1 for abnormal :param n_zones: the number of zones required @@ -206,7 +205,7 @@ def plot_scores_and_zones(scores, zones, box=None, title=None, lines=True): """ Plot the scores on a timeline with color according to which zone they belong too, green under the first value in zones, red above the last and a shade from green to red. - + :param scores: an array of scores :param zones: the limit of the zones :param box: limits to display the plot @@ -236,9 +235,9 @@ def plot_scores_and_zones(scores, zones, box=None, title=None, lines=True): ] yellow_scores = scores[yellow_idx] - ax.scatter(x=green_idx, y=green_scores, c='g') + ax.scatter(x=green_idx, y=green_scores, c="g") # plotting all the red points: - ax.scatter(x=red_idx, y=red_scores, c='r') + ax.scatter(x=red_idx, y=red_scores, c="r") # plotting everything in between: if len(zones) > 2: @@ -250,9 +249,9 @@ def plot_scores_and_zones(scores, zones, box=None, title=None, lines=True): color = yellow_scores if lines: for zone in zones: - ax.axhline(zone, color='b', lw=0.08, alpha=1) + ax.axhline(zone, color="b", lw=0.08, alpha=1) - ax.scatter(x=yellow_idx, y=yellow_scores, c=color, cmap='Wistia') + ax.scatter(x=yellow_idx, y=yellow_scores, c=color, cmap="Wistia") if box is None: min_ = np.min(scores) max_ = np.max(scores) diff --git a/oplot/plot_audio.py b/oplot/plot_audio.py index b516168..6829ab0 100644 --- a/oplot/plot_audio.py +++ b/oplot/plot_audio.py @@ -10,10 +10,10 @@ def plot_lines( ax, lines_loc, label=None, - color='r', + color="r", line_width=0.5, - line_style='-', - line_type='vert', + line_style="-", + line_type="vert", alpha=1, ): """ @@ -30,7 +30,7 @@ def plot_lines( line_type: 'vert' or 'horiz Examples: - + An initial plot @@ -48,9 +48,9 @@ def plot_lines( """ - if line_type == 'vert': + if line_type == "vert": line_ = ax.axvline - if line_type == 'horiz': + if line_type == "horiz": line_ = ax.axhline for line in lines_loc: line_( @@ -77,7 +77,7 @@ def plot_spectro(ax, wf, chk_size=2048, noverlap=0, sr=44100): ax.specgram(x=wf, NFFT=chk_size, noverlap=noverlap, Fs=sr) -def plot_wf(ax, wf, wf_line_width=0.8, wf_color='b'): +def plot_wf(ax, wf, wf_line_width=0.8, wf_color="b"): """ :param ax: @@ -108,7 +108,7 @@ def plot_wf_and_spectro( alpha_lines=None, n_tick_dec=None, wf_line_width=1, - wf_color='b', + wf_color="b", title=None, title_font_size=10, ): @@ -141,7 +141,7 @@ def plot_wf_and_spectro( fig, ax = plt.subplots(2, 1, figsize=figsize) if n_tick_dec is None: - n_tick_dec = max(str(n_sec_per_tick)[::-1].find('.'), 1) + n_tick_dec = max(str(n_sec_per_tick)[::-1].find("."), 1) if n_sec_per_tick is None: # make a tick every 10% of the whole wf, roughly if possible, or every 1sec if 10% is less than 1sec @@ -150,7 +150,7 @@ def plot_wf_and_spectro( # getting the ticks where we want them ticks_pos = range(0, len(wf), int(sr * n_sec_per_tick)) ticks_labels = [ - f'{round(n_sec_per_tick * i, n_tick_dec)}s' for i in range(len(ticks_pos)) + f"{round(n_sec_per_tick * i, n_tick_dec)}s" for i in range(len(ticks_pos)) ] # TODO: udnerstand wtf is going on here @@ -179,7 +179,6 @@ def plot_wf_and_spectro( ] for lines_idx, lines_loc in enumerate(vert_lines_samp): - if alpha_lines is None: alpha_line = None else: @@ -189,7 +188,7 @@ def plot_wf_and_spectro( else: vert_line_label = vert_lines_labels[lines_idx] if vert_lines_colors is None: - vert_lines_color = 'r' + vert_lines_color = "r" else: vert_lines_color = vert_lines_colors[lines_idx] if vert_lines_width is None: @@ -197,7 +196,7 @@ def plot_wf_and_spectro( else: vert_line_width = vert_lines_width[lines_idx] if vert_lines_style is None: - vert_line_style = '-' + vert_line_style = "-" else: vert_line_style = vert_lines_style[lines_idx] @@ -208,7 +207,7 @@ def plot_wf_and_spectro( color=vert_lines_color, line_width=vert_line_width, line_style=vert_line_style, - line_type='vert', + line_type="vert", alpha=alpha_line, ) first = False @@ -241,7 +240,7 @@ def plot_wf_with_lines( alpha_lines=None, n_tick_dec=None, wf_line_width=1, - wf_color='b', + wf_color="b", title=None, title_font_size=10, ): @@ -269,7 +268,7 @@ def plot_wf_with_lines( fig, ax = plt.subplots(1, 1, figsize=figsize) if n_tick_dec is None: - n_tick_dec = max(str(n_sec_per_tick)[::-1].find('.'), 1) + n_tick_dec = max(str(n_sec_per_tick)[::-1].find("."), 1) if n_sec_per_tick is None: # make a tick every 10% of the whole wf, roughly if possible, or every 1sec if 10% is less than 1sec @@ -278,7 +277,7 @@ def plot_wf_with_lines( # getting the ticks where we want them ticks_pos = range(0, len(wf), int(sr * n_sec_per_tick)) ticks_labels = [ - f'{round(n_sec_per_tick * i, n_tick_dec)}s' for i in range(len(ticks_pos)) + f"{round(n_sec_per_tick * i, n_tick_dec)}s" for i in range(len(ticks_pos)) ] # TODO: udnerstand wtf is going on here @@ -307,17 +306,16 @@ def plot_wf_with_lines( ] for lines_idx, lines_loc in enumerate(vert_lines_samp): - if alpha_lines is None: alpha_line = None else: alpha_line = alpha_lines[lines_idx] if vert_lines_labels is None: - vert_line_label = '' + vert_line_label = "" else: vert_line_label = vert_lines_labels[lines_idx] if vert_lines_colors is None: - vert_lines_color = 'r' + vert_lines_color = "r" else: vert_lines_color = vert_lines_colors[lines_idx] if vert_lines_width is None: @@ -325,7 +323,7 @@ def plot_wf_with_lines( else: vert_line_width = vert_lines_width[lines_idx] if vert_lines_style is None: - vert_line_style = '-' + vert_line_style = "-" else: vert_line_style = vert_lines_style[lines_idx] @@ -336,7 +334,7 @@ def plot_wf_with_lines( color=vert_lines_color, line_width=vert_line_width, line_style=vert_line_style, - line_type='vert', + line_type="vert", alpha=alpha_line, ) diff --git a/oplot/plot_data_set.py b/oplot/plot_data_set.py index 62da3de..eced942 100644 --- a/oplot/plot_data_set.py +++ b/oplot/plot_data_set.py @@ -48,7 +48,7 @@ def ensure_dict(obj: Any) -> Mapping: if isinstance(obj, Iterable): return dict(enumerate(obj)) else: - raise ValueError(f'Cannot convert {obj} to a dictionary.') + raise ValueError(f"Cannot convert {obj} to a dictionary.") def density_distribution( @@ -58,21 +58,21 @@ def density_distribution( axvline_kwargs: dict[str, dict[str, Any]] | None = None, line_width: int = 3, location_func: Callable = np.mean, - location_linestyle: str = '--', + location_linestyle: str = "--", display_location_text: bool = True, - colors: tuple = ('blue', 'orange', 'green', 'red', 'purple', 'brown'), + colors: tuple = ("blue", "orange", "green", "red", "purple", "brown"), density_plot_func: Callable = sns.kdeplot, density_plot_kwargs: dict[str, Any] | None = None, text_kwargs: dict[str, Any] | Iterable[tuple[str, Any]] | None = ( - ('x', 0.05), - ('y', 0.05), - ('bbox', dict(facecolor='white', alpha=0.5)), + ("x", 0.05), + ("y", 0.05), + ("bbox", dict(facecolor="white", alpha=0.5)), ), mean_line_kwargs: dict[str, Any] | None = None, ): """ Plots the density distribution of different data sets (arrays). - + Args: data_dict (dict): A dictionary where keys are labels and values are arrays to plot. ax (plt.Axes, optional): Matplotlib Axes object to plot on. If None, a new figure and axis will be created. @@ -101,7 +101,7 @@ def density_distribution( >>> fig, ax = plt.subplots() >>> density_distribution(data_dict, ax=ax, display_location_text=False, colors=('red', 'blue')) >>> # This will plot the density distributions on the provided axis. - + """ # Define default colors and linestyle colors_cycle = itertools.cycle(colors) @@ -132,8 +132,8 @@ def density_distribution( # Get axvline kwargs for the current label if axvline_kwargs and label in axvline_kwargs: kwargs = axvline_kwargs[label] - color = kwargs.get('color', color) - linestyle = kwargs.get('linestyle', linestyle) + color = kwargs.get("color", color) + linestyle = kwargs.get("linestyle", linestyle) # Add vertical line for the location ax.axvline( @@ -142,14 +142,16 @@ def density_distribution( if display_location_text: text_kwargs = dict( - (('x', 0.05), ('y', 0.05), ('bbox', dict(facecolor='white', alpha=0.5))) + (("x", 0.05), ("y", 0.05), ("bbox", dict(facecolor="white", alpha=0.5))) ) # Add text box displaying the mean of each distribution - text_str = '\n'.join( - [f'{label}: {np.mean(array):.2f}' for label, array in data_dict.items()] + text_str = "\n".join( + [f"{label}: {np.mean(array):.2f}" for label, array in data_dict.items()] ) ax.text( - s=text_str, transform=ax.transAxes, **text_kwargs, + s=text_str, + transform=ax.transAxes, + **text_kwargs, ) # Add legend @@ -159,16 +161,16 @@ def density_distribution( def scatter_and_color_according_to_y( X, y=None, - col='rainbow', - projection='2d', - dim_reduct='LDA', + col="rainbow", + projection="2d", + dim_reduct="LDA", save=False, legend=True, - saving_loc='/home/chris/', - saving_name='myplot-', + saving_loc="/home/chris/", + saving_name="myplot-", plot_tag_name=False, super_alpha=10, - cmap_col='viridis', + cmap_col="viridis", *args, **kwargs, ): @@ -194,51 +196,51 @@ def scatter_and_color_according_to_y( :return: a plot of 2d scatter plot of X with different colors for each tag """ - if projection == '1d': + if projection == "1d": proj_dim = 1 - elif projection == '2d': + elif projection == "2d": proj_dim = 2 - elif projection == '3d': + elif projection == "3d": proj_dim = 3 else: warnings.warn( - 'The choices for the parameter projectionare' + "The choices for the parameter projectionare" " '1d', '2d' or '3d'. Anything else and it will be assumed to be '2d' by default" ) proj_dim = 2 if y is None: - y = np.zeros(len(X), dtype='int') + y = np.zeros(len(X), dtype="int") legend = False n_tags = 1 tags = [0] - if dim_reduct == 'LDA': + if dim_reduct == "LDA": warnings.warn( - 'LDA cannot be used if no y is provided, will use PCA instead' + "LDA cannot be used if no y is provided, will use PCA instead" ) - dim_reduct = 'PCA' + dim_reduct = "PCA" elif isinstance(y[0], float): legend = False cm = plt.get_cmap(cmap_col) colors = None - col = 'continuous' + col = "continuous" no_tag = True - if dim_reduct == 'LDA': + if dim_reduct == "LDA": warnings.warn( - 'LDA cannot be used if the y entries are floats, will use PCA instead' + "LDA cannot be used if the y entries are floats, will use PCA instead" ) - dim_reduct = 'PCA' + dim_reduct = "PCA" else: tags = np.unique(y) n_tags = len(tags) - if dim_reduct == 'LDA' and proj_dim > n_tags - 1: + if dim_reduct == "LDA" and proj_dim > n_tags - 1: warnings.warn( - 'LDA cannot be used to produce {} dimensions if y has less than {} classes,' - ' will use PCA instead'.format(proj_dim, proj_dim + 1) + "LDA cannot be used to produce {} dimensions if y has less than {} classes," + " will use PCA instead".format(proj_dim, proj_dim + 1) ) - dim_reduct = 'PCA' + dim_reduct = "PCA" second_index = 1 third_index = 2 @@ -247,29 +249,29 @@ def scatter_and_color_according_to_y( # use LDA/PCA to project on a 2d/3d space if needed if n_dim > proj_dim: - if dim_reduct == 'LDA': + if dim_reduct == "LDA": LDA = LinearDiscriminantAnalysis(n_components=proj_dim) X = LDA.fit(X, y).transform(X) - elif dim_reduct == 'PCA': + elif dim_reduct == "PCA": pca = PCA(n_components=proj_dim) X = pca.fit_transform(X) - elif dim_reduct == 'TSNE': + elif dim_reduct == "TSNE": X = TSNE(n_components=proj_dim).fit_transform(X) - elif dim_reduct == 'random': + elif dim_reduct == "random": pass else: X = X[:, :proj_dim] - if col == 'rainbow': + if col == "rainbow": colors = matplotlib.cm.rainbow(np.linspace(0, 1, n_tags)) - if col == 'random': + if col == "random": colors = matplotlib.colors.hsv_to_rgb(np.random.rand(n_tags, 3)) - if projection == '1d': + if projection == "1d": fig = plt.figure() ax = fig.add_subplot(111) handles = [] @@ -284,7 +286,7 @@ def scatter_and_color_according_to_y( alpha=alpha, s=10, linewidths=0.05, - marker='+', + marker="+", *args, **kwargs, ) @@ -292,13 +294,15 @@ def scatter_and_color_according_to_y( handle = mpatches.Patch(color=c, label=i) handles.append(handle) ax.legend( - handles=handles, loc='center left', bbox_to_anchor=(1, 0.5) + handles=handles, loc="center left", bbox_to_anchor=(1, 0.5) ) else: - sc = ax.scatter(X[:, 0], X[:, second_index], c=y, alpha=alpha, *args, **kwargs) + sc = ax.scatter( + X[:, 0], X[:, second_index], c=y, alpha=alpha, *args, **kwargs + ) fig.colorbar(sc, ax=ax) - if projection == '2d': + if projection == "2d": fig = plt.figure() ax = fig.add_subplot(111) handles = [] @@ -318,13 +322,15 @@ def scatter_and_color_according_to_y( handle = mpatches.Patch(color=c, label=i) handles.append(handle) ax.legend( - handles=handles, loc='center left', bbox_to_anchor=(1, 0.5) + handles=handles, loc="center left", bbox_to_anchor=(1, 0.5) ) else: - sc = ax.scatter(X[:, 0], X[:, second_index], c=y, alpha=alpha, *args, **kwargs) + sc = ax.scatter( + X[:, 0], X[:, second_index], c=y, alpha=alpha, *args, **kwargs + ) fig.colorbar(sc, ax=ax) - if projection == '3d': + if projection == "3d": handles = [] if n_dim == 1: second_index = 0 @@ -332,7 +338,7 @@ def scatter_and_color_according_to_y( if n_dim == 2: third_index = 1 fig = plt.figure() - ax = fig.add_subplot(111, projection='3d') + ax = fig.add_subplot(111, projection="3d") if colors is not None: for c, i in zip(colors, tags): sc = ax.scatter( @@ -346,7 +352,7 @@ def scatter_and_color_according_to_y( handle = mpatches.Patch(color=c, label=i) handles.append(handle) ax.legend( - handles=handles, loc='center left', bbox_to_anchor=(1, 0.5) + handles=handles, loc="center left", bbox_to_anchor=(1, 0.5) ) else: p = ax.scatter( @@ -363,7 +369,7 @@ def scatter_and_color_according_to_y( if plot_tag_name and not no_tag: for tag in range(n_tags): tag_center = np.mean(X[y == tag], axis=0) - if projection == '2d': + if projection == "2d": plt.text(tag_center[0], tag_center[1], tags[tag]) else: ax.text( @@ -373,14 +379,14 @@ def scatter_and_color_according_to_y( tags[tag], size=20, zorder=1, - color='k', + color="k", ) if save: path = ( - saving_loc + saving_name + datetime.datetime.today().strftime('%Y-%m-%d-%r') + saving_loc + saving_name + datetime.datetime.today().strftime("%Y-%m-%d-%r") ) - plt.savefig(path, bbox_inches='tight') + plt.savefig(path, bbox_inches="tight") plt.show() @@ -394,7 +400,7 @@ def save_figs_to_pdf(figs, pdf_filepath=None): """ if pdf_filepath is None: - pdf_filepath = '' + datetime.datetime.today().strftime('%Y-%m-%d-%r') + '.pdf' + pdf_filepath = "" + datetime.datetime.today().strftime("%Y-%m-%d-%r") + ".pdf" with PdfPages(pdf_filepath) as pdf: for fig in figs: pdf.savefig(fig) @@ -431,9 +437,9 @@ def side_by_side_bar( if colors is None: colors = plt.cm.rainbow(np.linspace(0, 1, n_bars)) else: - assert ( - len(colors) >= n_bars - ), f"There's not enough colors for the number of bars ({n_bars})" + assert len(colors) >= n_bars, ( + f"There's not enough colors for the number of bars ({n_bars})" + ) ax = plt.subplot(111) # making each of the bar plot for i, list_of_values_for_bars in enumerate(list_of_values_for_bars): @@ -441,12 +447,12 @@ def side_by_side_bar( width * j * n_bars + spacing * j + i * width for j in range(len(list_of_values_for_bars)) ] - ax.bar(x, list_of_values_for_bars, width=width, color=colors[i], align='center') + ax.bar(x, list_of_values_for_bars, width=width, color=colors[i], align="center") ax.legend(list_names) ax.xaxis.set_ticklabels([]) -def ratio_comparison_vlines(y1, y2, c1='b', c2='k'): +def ratio_comparison_vlines(y1, y2, c1="b", c2="k"): """ Plots vlines of y1/y2. @@ -459,4 +465,4 @@ def ratio_comparison_vlines(y1, y2, c1='b', c2='k'): y = np.array(y1) / np.array(y2) plt.vlines(list(range(len(y))), 1, y) plt.hlines(1, 0, len(y) - 1, colors=c2) - return plt.plot(list(range(len(y))), y, 'o', color=c1) + return plt.plot(list(range(len(y))), y, "o", color=c1) diff --git a/oplot/plot_mappings.py b/oplot/plot_mappings.py index 6171fa9..567f3d7 100644 --- a/oplot/plot_mappings.py +++ b/oplot/plot_mappings.py @@ -3,7 +3,7 @@ def dict_bar_plot( d: dict, - title='', + title="", figsize=(12, 5), *, numeric_x_axis=None, @@ -87,9 +87,9 @@ def dict_bar_plot( x_sorted, y_sorted, width=bar_width, - align='center', - edgecolor='black', - color='skyblue', + align="center", + edgecolor="black", + color="skyblue", ) # Map original keys to bars @@ -97,9 +97,9 @@ def dict_bar_plot( y_max = max(y_sorted) else: # Use seaborn.barplot for non-numerical keys - sns.set_style('whitegrid') - sns.set_context('talk') - sns.set_palette('muted') + sns.set_style("whitegrid") + sns.set_context("talk") + sns.set_palette("muted") plt.figure(figsize=figsize) ax = sns.barplot(x=x, y=y) sns.despine() @@ -110,7 +110,7 @@ def dict_bar_plot( y_max = max(y) plt.title(title) - plt.grid(axis='y', linestyle='dotted', color='black') + plt.grid(axis="y", linestyle="dotted", color="black") if xlabel: plt.xlabel(xlabel) if ylabel: @@ -123,7 +123,7 @@ def dict_bar_plot( def new_annotations(): for k, v in annotations.items(): if len(v) > annotations_cutoff_length: - yield k, v[:annotations_cutoff_length] + '...' + yield k, v[:annotations_cutoff_length] + "..." else: yield k, v @@ -145,8 +145,8 @@ def new_annotations(): x_pos, y_pos, text, - ha='center', - va='bottom', + ha="center", + va="bottom", fontsize=annotations_font_size, rotation=annotations_rotation, ) diff --git a/oplot/plot_stats.py b/oplot/plot_stats.py index 8d30d87..78092ce 100644 --- a/oplot/plot_stats.py +++ b/oplot/plot_stats.py @@ -31,19 +31,19 @@ def plot_freqs_stats(X, upper_frequency=22050, n_bins=1025, normalized=True): mean_each_freq /= normalization_factor plt.figure(figsize=(20, 10)) - plt.plot(np.linspace(0, upper_frequency, n_bins), max_each_freq, label='max') - plt.plot(np.linspace(0, upper_frequency, n_bins), min_each_freq, label='min') - plt.plot(np.linspace(0, upper_frequency, n_bins), mean_each_freq, label='mean') - plt.legend(loc='best') - plt.xlabel('frequencies') - plt.ylabel('intensities') + plt.plot(np.linspace(0, upper_frequency, n_bins), max_each_freq, label="max") + plt.plot(np.linspace(0, upper_frequency, n_bins), min_each_freq, label="min") + plt.plot(np.linspace(0, upper_frequency, n_bins), mean_each_freq, label="mean") + plt.legend(loc="best") + plt.xlabel("frequencies") + plt.ylabel("intensities") plt.show() plt.figure(figsize=(20, 10)) freq_var = np.var(X, axis=0) - plt.plot(np.linspace(0, upper_frequency, n_bins), freq_var, label='variance') - plt.xlabel('frequencies') - plt.ylabel('variance') + plt.plot(np.linspace(0, upper_frequency, n_bins), freq_var, label="variance") + plt.xlabel("frequencies") + plt.ylabel("variance") plt.show() @@ -55,7 +55,7 @@ def make_heatmap( make_symmetric=False, fill_diag=None, cmap=plt.cm.Blues, - name='', + name="", ): """ Makes a heatmap plot of the matrix_results where the entries are rounded. @@ -82,14 +82,14 @@ def make_heatmap( ax.set_xticklabels(tags) ax.set_yticklabels(tags) # Rotate the tick labels and set their alignment. - plt.setp(ax.get_xticklabels(), rotation=45, ha='right', rotation_mode='anchor') + plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor") # Loop over data dimensions and create text annotations. for i in range(len(tags)): for j in range(len(tags)): text = ax.text( - j, i, matrix_results[i, j], ha='center', va='center', color='w' + j, i, matrix_results[i, j], ha="center", va="center", color="w" ) - ax.set_title('Pairwise Classification accuracy ' + name) + ax.set_title("Pairwise Classification accuracy " + name) fig.tight_layout() plt.show() @@ -125,10 +125,10 @@ def plot_confusion_matrix( cm = cm.reshape((2, 2)) if normalize: - cm = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis] + cm = cm.astype("float") / cm.sum(axis=1)[:, np.newaxis] if ax is None: fig, ax = plt.subplots(figsize=figsize) - im = ax.imshow(cm, interpolation='nearest', cmap=cmap) + im = ax.imshow(cm, interpolation="nearest", cmap=cmap) if color_bar: ax.figure.colorbar(im, ax=ax) # We want to show all ticks... @@ -139,15 +139,15 @@ def plot_confusion_matrix( xticklabels=classes, yticklabels=classes, title=title, - ylabel='True label', - xlabel='Predicted label', + ylabel="True label", + xlabel="Predicted label", ) ax.grid(False) # Rotate the tick labels and set their alignment. - plt.setp(ax.get_xticklabels(), rotation=45, ha='right', rotation_mode='anchor') + plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor") # Loop over data dimensions and create text annotations. - fmt = '.2f' if normalize else 'd' + fmt = ".2f" if normalize else "d" thresh = cm.max() / 2.0 for i in range(cm.shape[0]): for j in range(cm.shape[1]): @@ -157,14 +157,14 @@ def plot_confusion_matrix( j, i, format(cm[i, j], fmt), - ha='center', - va='center', - color='white' if cm[i, j] > thresh else 'black', + ha="center", + va="center", + color="white" if cm[i, j] > thresh else "black", ) if fig is not None: fig.tight_layout() if saving_path is not None: - fig.savefig(saving_path, bbox_inches='tight', dpi=200) + fig.savefig(saving_path, bbox_inches="tight", dpi=200) if plot: plt.show() @@ -269,20 +269,20 @@ def make_tables_tn_fp_fn_tp( df = pd.DataFrame( row, columns=[ - 'Threshold', - 'True Negative', - 'False Positive', - 'False Negative', - 'True Positive', + "Threshold", + "True Negative", + "False Positive", + "False Negative", + "True Positive", ], ) if normalize: total_positive = np.sum(truth) total_negative = len(truth) - total_positive - df['True Negative'] = df['True Negative'].apply(lambda x: x / total_negative) - df['False Positive'] = df['False Positive'].apply(lambda x: x / total_negative) - df['False Negative'] = df['False Negative'].apply(lambda x: x / total_positive) - df['True Positive'] = df['True Positive'].apply(lambda x: x / total_positive) + df["True Negative"] = df["True Negative"].apply(lambda x: x / total_negative) + df["False Positive"] = df["False Positive"].apply(lambda x: x / total_negative) + df["False Negative"] = df["False Negative"].apply(lambda x: x / total_positive) + df["True Positive"] = df["True Positive"].apply(lambda x: x / total_positive) return df @@ -319,10 +319,10 @@ def make_tn_fp_fn_tp_tag_lists(truth, scores, threshold, tags=None): false_negative = Counter(false_negative) return { - 'tp': true_positive, - 'tn': true_negative, - 'fp': false_positive, - 'fn': false_negative, + "tp": true_positive, + "tn": true_negative, + "fp": false_positive, + "fn": false_negative, } @@ -330,11 +330,11 @@ def vlines( x, ymin=0, ymax=None, - marker='o', + marker="o", marker_kwargs=None, - colors='k', - linestyles='solid', - label='', + colors="k", + linestyles="solid", + label="", data=None, **kwargs, ): @@ -344,7 +344,7 @@ def vlines( x = np.arange(len(ymax)) if ymax is None: - raise ValueError('Need to specify ymax') + raise ValueError("Need to specify ymax") if marker is not None: if marker_kwargs is None: @@ -370,7 +370,7 @@ def make_normal_outlier_timeline( vertical_sep=False, saving_path=None, fig_size=(16, 5), - name='normal/outlier scores', + name="normal/outlier scores", smooth=False, legend_size=10, title_font_size=10, @@ -401,7 +401,7 @@ def make_normal_outlier_timeline( y_order = list(dict.fromkeys(y)) else: if set(np.unique(y)) != set(y_order): - warnings.warn('y_order does not include the values present in y') + warnings.warn("y_order does not include the values present in y") if smooth: new_scores = [] @@ -415,8 +415,8 @@ def make_normal_outlier_timeline( new_y_order.append(i) except ValueError: print( - f'There are less scores corresponding to {i} than the smoothing window size. ' - f'These scores will be dropped' + f"There are less scores corresponding to {i} than the smoothing window size. " + f"These scores will be dropped" ) scores = np.array(new_scores) y = np.array(new_y) @@ -447,32 +447,32 @@ def make_normal_outlier_timeline( ) if xticks is not None and xticks_labels is not None: plt.xticks(ticks=xticks, labels=xticks_labels, rotation=xticks_rotation) - if vertical_sep == 'auto': + if vertical_sep == "auto": group_len = apply_function_on_consecutive(y, y, lambda x: len(x)) vertical_lines_pos = np.cumsum(group_len) ax1.vlines( vertical_lines_pos, ymin=np.min(scores), ymax=np.max(scores), - colors='k', + colors="k", linewidth=0.3, - linestyles='-.', + linestyles="-.", ) elif vertical_sep: ax1.vlines( vertical_sep, ymin=np.min(scores), ymax=np.max(scores), - colors='k', + colors="k", linewidth=0.3, - linestyles='-.', + linestyles="-.", ) if legend_size: - plt.legend(prop={'size': legend_size}, loc=(1.04, 0), ncol=legend_n_cols) + plt.legend(prop={"size": legend_size}, loc=(1.04, 0), ncol=legend_n_cols) plt.title(name, fontsize=title_font_size) if saving_path is not None: - plt.savefig(saving_path, bbox_inches='tight', dpi=200) + plt.savefig(saving_path, bbox_inches="tight", dpi=200) plt.show() @@ -517,16 +517,16 @@ def render_mpl_table( col_width=3.0, row_height=0.625, font_size=14, - header_color='#40466e', - row_colors=['#f1f1f2', 'w'], - edge_color='w', + header_color="#40466e", + row_colors=["#f1f1f2", "w"], + edge_color="w", bbox=[0, 0, 1, 1], header_columns=0, ax=None, path_to_save=None, round_decimals=3, cols_to_round=(), - cols_to_int='all_other', + cols_to_int="all_other", dpi=300, **kwargs, ): @@ -539,7 +539,7 @@ def render_mpl_table( else: for col in cols_to_round: data[col] = data[col].apply(lambda x: round(x, round_decimals)) - if cols_to_int == 'all_others': + if cols_to_int == "all_others": for col in data.columns: if col not in cols_to_round: data[col] = data[col].apply(lambda x: int(x)) @@ -548,7 +548,7 @@ def render_mpl_table( [col_width, row_height] ) fig, ax = plt.subplots(figsize=size) - ax.axis('off') + ax.axis("off") mpl_table = ax.table( cellText=data.values, bbox=bbox, colLabels=data.columns, **kwargs ) @@ -559,65 +559,70 @@ def render_mpl_table( for k, cell in mpl_table._cells.items(): cell.set_edgecolor(edge_color) if k[0] == 0 or k[1] < header_columns: - cell.set_text_props(weight='bold', color='w') + cell.set_text_props(weight="bold", color="w") cell.set_facecolor(header_color) else: cell.set_facecolor(row_colors[k[0] % len(row_colors)]) if path_to_save: - plt.savefig(path_to_save, bbox_inches='tight', dpi=dpi) + plt.savefig(path_to_save, bbox_inches="tight", dpi=dpi) plt.show() # all these scores except for MCC gives a score between 0 and 1. # I normalized MMC into what I call NNMC in order to keep the same scale for all. base_statistics_dict = { - 'TPR': lambda tn, fp, fn, tp: tp / (tp + fn), + "TPR": lambda tn, fp, fn, tp: tp / (tp + fn), # sensitivity, recall, hit rate, or true positive rate - 'TNR': lambda tn, fp, fn, tp: tn - / (tn + fp), # specificity, selectivity or true negative rate - 'PPV': lambda tn, fp, fn, tp: tp - / (tp + fp), # precision or positive predictive value - 'NPV': lambda tn, fp, fn, tp: tn / (tn + fn), # negative predictive value - 'FNR': lambda tn, fp, fn, tp: fn / (fn + tp), # miss rate or false negative rate - 'FPR': lambda tn, fp, fn, tp: fp / (fp + tn), # fall-out or false positive rate - 'FDR': lambda tn, fp, fn, tp: fp / (fp + tp), # false discovery rate - 'FOR': lambda tn, fp, fn, tp: fn / (fn + tn), # false omission rate - 'TS': lambda tn, fp, fn, tp: tp / (tp + fn + fp), + "TNR": lambda tn, fp, fn, tp: ( + tn / (tn + fp) + ), # specificity, selectivity or true negative rate + "PPV": lambda tn, fp, fn, tp: ( + tp / (tp + fp) + ), # precision or positive predictive value + "NPV": lambda tn, fp, fn, tp: tn / (tn + fn), # negative predictive value + "FNR": lambda tn, fp, fn, tp: fn / (fn + tp), # miss rate or false negative rate + "FPR": lambda tn, fp, fn, tp: fp / (fp + tn), # fall-out or false positive rate + "FDR": lambda tn, fp, fn, tp: fp / (fp + tp), # false discovery rate + "FOR": lambda tn, fp, fn, tp: fn / (fn + tn), # false omission rate + "TS": lambda tn, fp, fn, tp: tp / (tp + fn + fp), # threat score (TS) or Critical Success Index (CSI) - 'ACC': lambda tn, fp, fn, tp: (tp + tn) / (tp + tn + fp + fn), # accuracy - 'F1': lambda tn, fp, fn, tp: (2 * tp) / (2 * tp + fp + fn), # F1 score - 'NMCC': lambda tn, fp, fn, tp: ( - (tp * tn - fp * fn) / ((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)) ** 0.5 + 1 - ) - / 2, + "ACC": lambda tn, fp, fn, tp: (tp + tn) / (tp + tn + fp + fn), # accuracy + "F1": lambda tn, fp, fn, tp: (2 * tp) / (2 * tp + fp + fn), # F1 score + "NMCC": lambda tn, fp, fn, tp: ( + ( + (tp * tn - fp * fn) / ((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)) ** 0.5 + + 1 + ) + / 2 + ), # NORMALIZED TO BE BETWEEN 0 AND 1 Matthews correlation coefficient - 'BM': lambda tn, fp, fn, tp: tp / (tp + fn) + tn / (tn + fp) - 1, + "BM": lambda tn, fp, fn, tp: tp / (tp + fn) + tn / (tn + fp) - 1, # Informedness or Bookmaker Informedness - 'MK': lambda tn, fp, fn, tp: tp / (tp + fp) + tn / (tn + fn) - 1, + "MK": lambda tn, fp, fn, tp: tp / (tp + fp) + tn / (tn + fn) - 1, } # Markedness synonyms = { - 'TPR': ['recall', 'sensitivity', 'true_positive_rate', 'hit_rate', 'tpr'], - 'TNR': ['specificity', 'SPC', 'true_negative_rate', 'selectivity', 'tnr'], - 'PPV': ['precision', 'positive_predictive_value', 'ppv'], - 'NPV': ['negative_predictive_value', 'npv'], - 'FNR': ['miss_rate', 'false_negative_rate', 'fnr'], - 'FPR': ['fall_out', 'false_positive_rate', 'fpr'], - 'FDR': ['false_discovery_rate', 'fdr'], - 'FOR': ['false_omission_rate', 'for'], - 'TS': ['threat_score', 'critical_success_index', 'CSI', 'csi', 'ts'], - 'ACC': ['accuracy', 'acc'], - 'F1': ['f1_score', 'f1', 'F1_score'], - 'NMCC': ['normalized_Matthews_correlation_coefficient', 'nmcc'], - 'BM': ['informedness', 'bookmaker_informedness', 'bi', 'BI', 'bm'], - 'MK': ['markedness', 'mk'], + "TPR": ["recall", "sensitivity", "true_positive_rate", "hit_rate", "tpr"], + "TNR": ["specificity", "SPC", "true_negative_rate", "selectivity", "tnr"], + "PPV": ["precision", "positive_predictive_value", "ppv"], + "NPV": ["negative_predictive_value", "npv"], + "FNR": ["miss_rate", "false_negative_rate", "fnr"], + "FPR": ["fall_out", "false_positive_rate", "fpr"], + "FDR": ["false_discovery_rate", "fdr"], + "FOR": ["false_omission_rate", "for"], + "TS": ["threat_score", "critical_success_index", "CSI", "csi", "ts"], + "ACC": ["accuracy", "acc"], + "F1": ["f1_score", "f1", "F1_score"], + "NMCC": ["normalized_Matthews_correlation_coefficient", "nmcc"], + "BM": ["informedness", "bookmaker_informedness", "bi", "BI", "bm"], + "MK": ["markedness", "mk"], } def pair_metrics_to_reference( - pair_metrics={'x': 'TPR', 'y': 'FPR'}, + pair_metrics={"x": "TPR", "y": "FPR"}, outlier_proportion=0.2, - label='chance line', + label="chance line", base_statistics_dict=base_statistics_dict, synonyms=synonyms, ): @@ -627,9 +632,9 @@ def pair_metrics_to_reference( """ # r = rate of positive of a random outlier model - r = sp.symbols('r') + r = sp.symbols("r") # R = rate of positive in the test set - R = sp.symbols('R') + R = sp.symbols("R") tn = (1 - r) * (1 - R) fp = r * (1 - R) @@ -643,8 +648,8 @@ def pair_metrics_to_reference( statistics_dict[k] = v statistics_dict[alt] = v - fx = statistics_dict[pair_metrics['x']] - fy = statistics_dict[pair_metrics['y']] + fx = statistics_dict[pair_metrics["x"]] + fy = statistics_dict[pair_metrics["y"]] simp_fx = sp.expand(fx(tn, fp, fn, tp)) simp_fy = sp.expand(fy(tn, fp, fn, tp)) @@ -653,7 +658,7 @@ def pair_metrics_to_reference( y_values = [ simp_fy.evalf(subs={R: outlier_proportion, simp_fx: i}) for i in x_values ] - plt.plot(x_values, y_values, '--', c='r', label=label) + plt.plot(x_values, y_values, "--", c="r", label=label) def wiggle_values_keep_order(values): @@ -693,12 +698,12 @@ def wiggle_values_keep_order(values): def cumulative_tn_fp_fn_tp(truth, scores): - """Compute efficiently the cumulative tn, fp, fn and tp """ + """Compute efficiently the cumulative tn, fp, fn and tp""" truth = np.array(truth) scores = np.array(scores) - sorted_idx = np.argsort(scores, kind='mergesort') + sorted_idx = np.argsort(scores, kind="mergesort") sorted_truth = truth[sorted_idx] total_true_positive = np.sum(sorted_truth) @@ -741,7 +746,7 @@ def wiggle_scores(scores, truth): def plot_outlier_metric_curve( truth, scores, - pair_metrics={'x': 'TPR', 'y': 'PPV'}, + pair_metrics={"x": "TPR", "y": "PPV"}, plot_curve=True, curve_legend_name=None, title=None, @@ -832,8 +837,8 @@ def plot_outlier_metric_curve( tns, fps, fns, tps = cumulative_tn_fp_fn_tp(truth, scores) x = [] y = [] - fx = statistics_dict[pair_metrics['x']] - fy = statistics_dict[pair_metrics['y']] + fx = statistics_dict[pair_metrics["x"]] + fy = statistics_dict[pair_metrics["y"]] for tn, fp, fn, tp in zip(tns, fps, fns, tps): x.append(fx(tn, fp, fn, tp)) y.append(fy(tn, fp, fn, tp)) @@ -879,24 +884,24 @@ def plot_outlier_metric_curve( if plot_table or plot_curve: if not axis_name_dict: - x_label = pair_metrics['x'] - y_label = pair_metrics['y'] + x_label = pair_metrics["x"] + y_label = pair_metrics["y"] else: - x_label = axis_name_dict['x'] - y_label = axis_name_dict['y'] + x_label = axis_name_dict["x"] + y_label = axis_name_dict["y"] # plotting the curve if plot_curve: plt.xlabel(x_label) plt.ylabel(y_label) - curve_name = y_label + '_' + x_label + '_curve' + curve_name = y_label + "_" + x_label + "_curve" if not curve_legend_name: curve_legend_name = curve_name if not title: title = curve_name - plt.plot(x, y, color='b', alpha=0.2, label=curve_legend_name) - plt.fill_between(x, y, alpha=0.2, color='b') + plt.plot(x, y, color="b", alpha=0.2, label=curve_legend_name) + plt.fill_between(x, y, alpha=0.2, color="b") plt.ylim([0.0, 1.1]) plt.xlim([0.0, 1.0]) @@ -909,11 +914,11 @@ def plot_outlier_metric_curve( # adding the points to the curve if plot_table_points_on_curve: - plt.scatter(x_points, y_points, label='table points') + plt.scatter(x_points, y_points, label="table points") # saving the curve if saving_root: path_to_save = os.path.join(saving_root, title) - plt.savefig(path_to_save, bbox_inches='tight', dpi=200, figsize=(6, 6)) + plt.savefig(path_to_save, bbox_inches="tight", dpi=200, figsize=(6, 6)) plt.legend(bbox_to_anchor=(1, 1), fancybox=True, shadow=True) plt.show() @@ -922,7 +927,7 @@ def plot_outlier_metric_curve( rows = list(zip(x_points, y_points)) df = pd.DataFrame(rows, columns=[x_label, y_label]) if saving_root: - path_to_save = os.path.join(saving_root, title + '_table') + path_to_save = os.path.join(saving_root, title + "_table") else: path_to_save = None render_mpl_table(df, path_to_save=path_to_save, dpi=table_dpi) @@ -971,9 +976,9 @@ def smooth_scores(scores, window_size=2, window_step=None, smooth_func=np.mean): >>> list(smooth_scores([1], window_size=2)) [] >>> list(smooth_scores([1, 2], window_size=2)) - [1.5] + [np.float64(1.5)] >>> list(smooth_scores([1, 2, 3], window_size=2, window_step=1, smooth_func=np.max)) - [2, 3] + [np.int64(2), np.int64(3)] """ if window_step is None: @@ -1031,7 +1036,7 @@ def apply_function_on_consecutive(scores, arr_for_consec, func=np.mean): def parallel_sort(iterable_list, sort_idx=0): """ Sort several lists in iterable_list in parallel, according to the the list of index sort_idx - + :param iterable_list: list of list, all the lists have the same length :param sort_idx: int, the index of the list to sort by :return: a list sorted tuples diff --git a/oplot/sequences.py b/oplot/sequences.py index e5d601f..7bf9369 100644 --- a/oplot/sequences.py +++ b/oplot/sequences.py @@ -24,7 +24,7 @@ def bars( zero_thickness: float | None = None, barplot_kwargs: dict = (), density_sigma: int = 20, - density_line_kwargs: dict = (('color', 'black'),), + density_line_kwargs: dict = (("color", "black"),), ax=None, # Add an ax parameter ): """ @@ -149,8 +149,8 @@ def bars( if density_line: y_density = gaussian_filter1d(y, sigma=density_sigma) - if 'linewidth' in density_line_kwargs: - density_line_kwargs['linewidth'] = int(density_line_kwargs['linewidth']) + if "linewidth" in density_line_kwargs: + density_line_kwargs["linewidth"] = int(density_line_kwargs["linewidth"]) sns.lineplot( y=y_density, diff --git a/oplot/ui_scores_mapping.py b/oplot/ui_scores_mapping.py index 62274e4..046ce22 100644 --- a/oplot/ui_scores_mapping.py +++ b/oplot/ui_scores_mapping.py @@ -1,5 +1,6 @@ """Functions to create and plot outlier scores (or other) in a fixed bounded range. Intended to use to show the results of an outlier algorithm in a user friendly UI""" + import numpy as np @@ -66,7 +67,7 @@ def make_ui_score_mapping( Plot a sigmoid function to map outlier scores to (by default) the range (0, 10) The function is not only continuous but also smooth and the radius of the corners are controlled by the floats top_base and bottom_base - + :param min_lin_score: float, the minimum scores which is map with a linear function :param max_lin_score: float, the maximum scores which is map with a linear function :param top_base: float, the base of the exponential function on top of the linear part diff --git a/oplot/util.py b/oplot/util.py index fbe9b38..aa6cf59 100644 --- a/oplot/util.py +++ b/oplot/util.py @@ -4,7 +4,7 @@ from functools import wraps import pandas as pd -DFLT_DIRPATH = os.path.expanduser('~') +DFLT_DIRPATH = os.path.expanduser("~") # -------------------------------------------------------------------------------------- @@ -54,7 +54,7 @@ def wrapped_func(*args, **kwargs): return decorator -def timestamp_to_float(timestamps, reference_time=pd.Timestamp('1970-01-01')): +def timestamp_to_float(timestamps, reference_time=pd.Timestamp("1970-01-01")): """ Convert an array-like of Timestamps to absolute floats representing days since a fixed reference datetime. @@ -75,7 +75,7 @@ def timestamp_to_float(timestamps, reference_time=pd.Timestamp('1970-01-01')): return (timestamps - reference_time).dt.total_seconds() / (24 * 3600) -def float_to_timestamp(floats, reference_time=pd.Timestamp('1970-01-01')): +def float_to_timestamp(floats, reference_time=pd.Timestamp("1970-01-01")): """ Convert an array-like of floats (days since reference datetime) back to Timestamps. @@ -92,7 +92,7 @@ def float_to_timestamp(floats, reference_time=pd.Timestamp('1970-01-01')): Array of pandas Timestamps. """ floats = np.array(floats) # Ensure it's a numpy array - return pd.Series(reference_time + pd.to_timedelta(floats * 24 * 3600, unit='s')) + return pd.Series(reference_time + pd.to_timedelta(floats * 24 * 3600, unit="s")) # -------------------------------------------------------------------------------------- @@ -282,7 +282,7 @@ def fixed_step_chunker( start_at = 0 # if the input is a list - if hasattr(it, '__getitem__') and hasattr(it, '__len__'): + if hasattr(it, "__getitem__") and hasattr(it, "__len__"): if stop_at is None: stop_at = len(it) else: @@ -308,7 +308,6 @@ def fixed_step_chunker( # if the input is an iterator else: if chk_step < chk_size: - it = islice(it, start_at, stop_at) chk = list(islice(it, chk_size)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..76c6daa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,161 @@ +[build-system] +requires = [ + "hatchling", +] +build-backend = "hatchling.build" + +[project] +name = "oplot" +version = "0.1.25" +description = "A medley of plotting tools for data analysis" +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +keywords = [] +authors = [] +dependencies = [ + "matplotlib", + "scipy", + "scikit-learn", + "numpy", + "six", + "pandas", + "sympy", + "seaborn", +] + +[project.urls] +Homepage = "https://github.com/i2mint/oplot" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "ruff>=0.1.0", +] +docs = [ + "sphinx>=6.0", + "sphinx-rtd-theme>=1.0", +] + +[tool.ruff] +line-length = 88 +target-version = "py310" +exclude = [ + "**/*.ipynb", + ".git", + ".venv", + "build", + "dist", + "tests", + "examples", + "scrap", +] + +[tool.ruff.lint] +select = [ + "D100", +] +ignore = [ + "D203", + "E501", + "B905", +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"**/tests/*" = [ + "D", +] +"**/examples/*" = [ + "D", +] +"**/scrap/*" = [ + "D", +] + +[tool.pytest.ini_options] +minversion = "6.0" +testpaths = [ + "oplot", + "tests", +] +doctest_optionflags = [ + "NORMALIZE_WHITESPACE", + "ELLIPSIS", +] + +[tool.wads.ci] +project_name = "oplot" + +[tool.wads.ci.commands] +pre_test = [] +test = [] +post_test = [] +lint = [] +format = [] + +[tool.wads.ci.env] +required_envvars = [] +test_envvars = [] +extra_envvars = [] + +[tool.wads.ci.env.defaults] + +[tool.wads.ci.quality.ruff] +enabled = true + +[tool.wads.ci.quality.black] +enabled = false + +[tool.wads.ci.quality.mypy] +enabled = false + +[tool.wads.ci.testing] +enabled = true +python_versions = [ + "3.10", + "3.12", +] +pytest_args = [ + "-v", + "--tb=short", +] +coverage_enabled = true +coverage_threshold = 0 +coverage_report_format = [ + "term", + "xml", +] +exclude_paths = [ + "examples", + "scrap", +] +test_on_windows = true + +[tool.wads.ci.metrics] +enabled = false +config_path = ".github/umpyre-config.yml" +storage_branch = "code-metrics" +python_version = "3.10" +force_run = false + +[tool.wads.ci.build] +sdist = true +wheel = true + +[tool.wads.ci.publish] +enabled = true +skip_ci_marker = "[skip ci]" +publish_marker = "[publish]" + +[tool.wads.ci.docs] +enabled = true +builder = "epythet" +ignore_paths = [ + "tests/", + "scrap/", + "examples/", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a2b67d4..0000000 --- a/setup.cfg +++ /dev/null @@ -1,27 +0,0 @@ -[metadata] -name = oplot -version = 0.1.25 -url = https://github.com/i2mint/oplot -platforms = any -description_file = README.md -root_url = https://github.com/i2mint/ -license = apache-2.0 -description = A medley of plotting tools for data analysis -long_description = file:README.md -long_description_content_type = text/markdown -keywords = -display_name = oplot - -[options] -packages = find: -include_package_data = True -zip_safe = False -install_requires = - matplotlib - scipy - scikit-learn - numpy - six - pandas - sympy - seaborn \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 201cd4c..0000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() # Note: Everything should be in the local setup.cfg From ef5f4fabd43494e49a8c3019a94f54c8cca3abfb Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:59:35 +0000 Subject: [PATCH 2/2] fix: force MPLBACKEND=Agg in CI (Windows Tests hung on plt.show()) The Windows leg (just enabled by this branch's CI migration -- the old workflow never had a Windows job at all) hung indefinitely instead of completing: several oplot modules' doctests call plt.show(), and with no non-interactive backend forced, matplotlib picked a GUI backend on the Windows runner that blocked rather than no-op'ing (unlike Linux, which has no DISPLAY and falls back to Agg automatically). Set MPLBACKEND=Agg via [tool.wads.ci.env.defaults] (re-ran wads-migrate ci-to-uv to wire it into the generated workflow's env: block). Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 2 +- pyproject.toml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5a7377..d21689a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,5 @@ name: Continuous Integration (uv) # MIGRATION NOTE: Old CI uses pylint - new CI uses ruff for linting -# MIGRATION NOTE: PyPI auth changed: set secrets.PYPI_PASSWORD to a PyPI API token (uv publish uses UV_PUBLISH_TOKEN, mapped from PYPI_PASSWORD) on: [push, pull_request] # Workflow-level env vars from [tool.wads.ci.env] in pyproject.toml. @@ -15,6 +14,7 @@ on: [push, pull_request] # with no failing step (i2mint/wads#61). env: PROJECT_NAME: oplot + MPLBACKEND: Agg jobs: # First job: Read configuration from pyproject.toml diff --git a/pyproject.toml b/pyproject.toml index 76c6daa..150fee8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,11 @@ test_envvars = [] extra_envvars = [] [tool.wads.ci.env.defaults] +# Force matplotlib's non-interactive backend in CI. Without this, plt.show() +# in doctests/tests (oplot's core job is plotting) can pick a GUI backend on +# some runners and hang instead of no-op-ing, which is what actually happened +# enabling the Windows leg for the first time (see #8's CI migration). +MPLBACKEND = "Agg" [tool.wads.ci.quality.ruff] enabled = true