From 144befb82d7039022d1718439d419ab7aa217889 Mon Sep 17 00:00:00 2001 From: Dan Redding <125183946+dangotbanned@users.noreply.github.com> Date: Sat, 18 Jan 2025 12:01:01 +0000 Subject: [PATCH 1/3] docs(example): Adds Confidence Interval Ellipses (#3747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Create deviation_ellipses.py example showing bivariate deviation ellipses of petal length and width of three iris species * docs: Initial rewrite of (#514) Happy with the end result, but not comfortable merging so much complexity I don't understand yet #3715 * ci(typing): Adds `scipy-stubs` to `altair[doc]` `scipy` is only used for one example in the user guide, but this will be the second https://docs.scipy.org/doc/scipy/release/1.15.0-notes.html#other-changes * fix: Only install `scipy-stubs` on `>=3.10` * chore(typing): Ignore incorrect `pandas` stubs * ci(typing): ignore `scipy` on `3.9` https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-library-stubs-or-py-typed-marker https://github.com/vega/altair/actions/runs/12612565960/job/35149436953?pr=3747 * docs: Add missing category * fix: Add missing support for `from __future__ import annotations` Fixes https://github.com/vega/altair/actions/runs/12612637008/job/35149593128?pr=3747#step:6:25 * test: skip example when `scipy` not installed Temporary fix for https://github.com/vega/altair/actions/runs/12612997919/job/35150338097?pr=3747 * docs: reduce segments `100` -> `50` Observed no visible reduction in quality. Slightly visible at `<=40` * docs: Clean up `numpy`, `scipy` docs/comments * refactor: Simplify `numpy` transforms * docs: add tooltip, increase size * fix: Remove incorrect range stop Previously returned `segments+1` rows, but this isn't specified in `ggplot2 https://github.com/tidyverse/ggplot2/blob/efc53cc000e7d86e3db22e1f43089d366fe24f2e/R/stat-ellipse.R#L122 * refactor: Remove special casing `__future__` import I forgot that the only requirement was that the import is the **first statement**. Partially reverts (https://github.com/vega/altair/pull/3747/commits/7cd2a775566891d5be861252ac0f87b351ac6351) * docs: Remove unused `method` code Also resolves https://github.com/vega/altair/pull/3747#discussion_r1903159487 * docs: rename to 'Confidence Interval Ellipses' * docs: add references to description * docs: Adds methods syntax version Includes comment removal suggestion in (https://github.com/vega/altair/pull/3747#discussion_r1904633378) * refactor: Rewrite `pd_ellipse` - Fixed a type ignore (causes by incomplete stubs) - Renamed variables - Make replace the implicit `"index"` column with naming it `"order"` https://github.com/vega/altair/pull/3747#discussion_r1903144682 * ci(uv): sync `scipy-stubs` https://github.com/vega/altair/pull/3747/commits/dc7639d4a41baf3a476403238c1d970927ba352f https://github.com/vega/altair/pull/3747/commits/a296b8248d7375069eb8e9c680778c8abe2ccddb * refactor(typing): Try removing `from __future__ import annotations` https://github.com/vega/altair/pull/3747#issuecomment-2596884190, https://github.com/vega/altair/pull/3747#issuecomment-2597901908 * refactor: rename `np_ellipse` -> `confidence_region_2d` https://github.com/vega/altair/pull/3747#issuecomment-2596884190 * refactor: rename `pd_ellipse` -> `grouped_confidence_regions` https://github.com/vega/altair/pull/3747#issuecomment-2596884190 * docs: change category to `"case studies"` https://github.com/vega/altair/pull/3747#issuecomment-2596884190 * styling --------- Co-authored-by: Serge-Étienne Parent Co-authored-by: Mattijn van Hoek --- pyproject.toml | 3 + sphinxext/utils.py | 6 +- tests/__init__.py | 5 ++ .../deviation_ellipses.py | 88 +++++++++++++++++++ .../deviation_ellipses.py | 88 +++++++++++++++++++ uv.lock | 26 ++++++ 6 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 tests/examples_arguments_syntax/deviation_ellipses.py create mode 100644 tests/examples_methods_syntax/deviation_ellipses.py diff --git a/pyproject.toml b/pyproject.toml index ada8245c5..8922cc9f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,7 @@ doc = [ "sphinx_copybutton", "sphinx-design", "scipy", + "scipy-stubs; python_version>=\"3.10\"", ] [tool.altair.vega] @@ -314,8 +315,10 @@ module = [ "ipykernel.*", "ibis.*", "vegafusion.*", + "scipy.*" ] ignore_missing_imports = true +disable_error_code = ["import-untyped"] [tool.pyright] enableExperimentalFeatures=true diff --git a/sphinxext/utils.py b/sphinxext/utils.py index ed3e974c6..7fcd2287f 100644 --- a/sphinxext/utils.py +++ b/sphinxext/utils.py @@ -58,7 +58,7 @@ def create_generic_image( """ -def _parse_source_file(filename: str) -> tuple[ast.Module | None, str]: +def _parse_source_file(filename: str | Path) -> tuple[ast.Module | None, str]: """ Parse source file into AST node. @@ -88,7 +88,7 @@ def _parse_source_file(filename: str) -> tuple[ast.Module | None, str]: return node, content -def get_docstring_and_rest(filename: str) -> tuple[str, str | None, str, int]: +def get_docstring_and_rest(filename: str | Path) -> tuple[str, str | None, str, int]: """ Separate ``filename`` content between docstring and the rest. @@ -160,7 +160,7 @@ def get_docstring_and_rest(filename: str) -> tuple[str, str | None, str, int]: if ( node.body and isinstance(node.body[0], ast.Expr) - and isinstance(node.body[0].value, (ast.Str, ast.Constant)) + and isinstance(node.body[0].value, ast.Constant) ): docstring_node = node.body[0] docstring = docstring_node.value.s # pyright: ignore[reportAttributeAccessIssue] diff --git a/tests/__init__.py b/tests/__init__.py index 17a33e91e..5d78dce0d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -96,6 +96,10 @@ def windows_has_tzdata() -> bool: https://github.com/vega/vegafusion """ +skip_requires_scipy: pytest.MarkDecorator = pytest.mark.skipif( + find_spec("scipy") is None, reason="`scipy` not installed." +) + @overload def skip_requires_pyarrow( @@ -236,6 +240,7 @@ def _distributed_examples( "wind_vector_map": slow, r"\.point_map\.py": slow, "line_chart_with_color_datum": slow, + "deviation_ellipses": skip_requires_scipy, }, ) ), diff --git a/tests/examples_arguments_syntax/deviation_ellipses.py b/tests/examples_arguments_syntax/deviation_ellipses.py new file mode 100644 index 000000000..0cc45d317 --- /dev/null +++ b/tests/examples_arguments_syntax/deviation_ellipses.py @@ -0,0 +1,88 @@ +""" +Confidence Interval Ellipses +---------------------------- +This example shows bivariate deviation ellipses of petal length and width of three iris species. + +Inspired by `ggplot2.stat_ellipse`_ and directly based on `Deviation ellipses example`_ by `@essicolo`_ + +.. _ggplot2.stat_ellipse: + https://ggplot2.tidyverse.org/reference/stat_ellipse.html#ref-examples +.. _Deviation ellipses example: + https://github.com/vega/altair/pull/514 +.. _@essicolo: + https://github.com/essicolo +""" + +# category: case studies +import numpy as np +import pandas as pd +from scipy.stats import f as F + +import altair as alt +from vega_datasets import data + + +def confidence_region_2d(arr, conf_level=0.95, segments=50): + """ + Calculate confidence interval ellipse. + + Parameters + ---------- + arr + numpy array with 2 columns + conf_level + lower tail probability + segments + number of points describing the ellipse. + """ + n_elements = len(arr) + # Degrees of freedom of the chi-squared distribution in the **numerator** + dfn = 2 + # Degrees of freedom of the chi-squared distribution in the **denominator** + dfd = n_elements - 1 + # Percent point function at `conf_level` of an F continuous random variable + quantile = F.ppf(conf_level, dfn=dfn, dfd=dfd) + radius = np.sqrt(2 * quantile) + angles = np.arange(0, segments) * 2 * np.pi / segments + circle = np.column_stack((np.cos(angles), np.sin(angles))) + center = np.mean(arr, axis=0) + cov_mat = np.cov(arr, rowvar=False) + return center + radius * (circle @ np.linalg.cholesky(cov_mat).T) + + +def grouped_confidence_regions(df, col_x, col_y, col_group): + cols = [col_x, col_y] + ellipses = [] + ser: pd.Series[float] = df[col_group] + for group in ser.drop_duplicates(): + arr = df.loc[ser == group, cols].to_numpy() + ellipse = pd.DataFrame(confidence_region_2d(arr), columns=cols) + ellipse[col_group] = group + ellipses.append(ellipse) + return pd.concat(ellipses).reset_index(names="order") + + +col_x = "petalLength" +col_y = "petalWidth" +col_group = "species" + +x = alt.X(col_x, scale=alt.Scale(zero=False)) +y = alt.Y(col_y, scale=alt.Scale(zero=False)) +color = alt.Color(col_group) + +source = data.iris() +ellipse = grouped_confidence_regions(source, col_x=col_x, col_y=col_y, col_group=col_group) +points = alt.Chart(source).mark_circle(size=50, tooltip=True).encode( + x=x, + y=y, + color=color +) +lines = alt.Chart(ellipse).mark_line(filled=True, fillOpacity=0.2).encode( + x=x, + y=y, + color=color, + order="order" +) + +chart = (lines + points).properties(height=500, width=500) +chart diff --git a/tests/examples_methods_syntax/deviation_ellipses.py b/tests/examples_methods_syntax/deviation_ellipses.py new file mode 100644 index 000000000..e33af7203 --- /dev/null +++ b/tests/examples_methods_syntax/deviation_ellipses.py @@ -0,0 +1,88 @@ +""" +Confidence Interval Ellipses +---------------------------- +This example shows bivariate deviation ellipses of petal length and width of three iris species. + +Inspired by `ggplot2.stat_ellipse`_ and directly based on `Deviation ellipses example`_ by `@essicolo`_ + +.. _ggplot2.stat_ellipse: + https://ggplot2.tidyverse.org/reference/stat_ellipse.html#ref-examples +.. _Deviation ellipses example: + https://github.com/vega/altair/pull/514 +.. _@essicolo: + https://github.com/essicolo +""" + +# category: case studies +import numpy as np +import pandas as pd +from scipy.stats import f as F + +import altair as alt +from vega_datasets import data + + +def confidence_region_2d(arr, conf_level=0.95, segments=50): + """ + Calculate confidence interval ellipse. + + Parameters + ---------- + arr + numpy array with 2 columns + conf_level + lower tail probability + segments + number of points describing the ellipse. + """ + n_elements = len(arr) + # Degrees of freedom of the chi-squared distribution in the **numerator** + dfn = 2 + # Degrees of freedom of the chi-squared distribution in the **denominator** + dfd = n_elements - 1 + # Percent point function at `conf_level` of an F continuous random variable + quantile = F.ppf(conf_level, dfn=dfn, dfd=dfd) + radius = np.sqrt(2 * quantile) + angles = np.arange(0, segments) * 2 * np.pi / segments + circle = np.column_stack((np.cos(angles), np.sin(angles))) + center = np.mean(arr, axis=0) + cov_mat = np.cov(arr, rowvar=False) + return center + radius * (circle @ np.linalg.cholesky(cov_mat).T) + + +def grouped_confidence_regions(df, col_x, col_y, col_group): + cols = [col_x, col_y] + ellipses = [] + ser: pd.Series[float] = df[col_group] + for group in ser.drop_duplicates(): + arr = df.loc[ser == group, cols].to_numpy() + ellipse = pd.DataFrame(confidence_region_2d(arr), columns=cols) + ellipse[col_group] = group + ellipses.append(ellipse) + return pd.concat(ellipses).reset_index(names="order") + + +col_x = "petalLength" +col_y = "petalWidth" +col_group = "species" + +x = alt.X(col_x).scale(zero=False) +y = alt.Y(col_y).scale(zero=False) +color = alt.Color(col_group) + +source = data.iris() +ellipse = grouped_confidence_regions(source, col_x=col_x, col_y=col_y, col_group=col_group) +points = alt.Chart(source).mark_circle(size=50, tooltip=True).encode( + x=x, + y=y, + color=color +) +lines = alt.Chart(ellipse).mark_line(filled=True, fillOpacity=0.2).encode( + x=x, + y=y, + color=color, + order="order" +) + +chart = (lines + points).properties(height=500, width=500) +chart diff --git a/uv.lock b/uv.lock index d6a2a3a93..6ca37e0b0 100644 --- a/uv.lock +++ b/uv.lock @@ -101,6 +101,7 @@ doc = [ { name = "pydata-sphinx-theme" }, { name = "scipy", version = "1.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "scipy", version = "1.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "scipy-stubs", marker = "python_full_version >= '3.10'" }, { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "sphinx-copybutton" }, @@ -143,6 +144,7 @@ requires-dist = [ { name = "pytest-xdist", extras = ["psutil"], marker = "extra == 'dev'", specifier = "~=3.5" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.4" }, { name = "scipy", marker = "extra == 'doc'" }, + { name = "scipy-stubs", marker = "python_full_version >= '3.10' and extra == 'doc'" }, { name = "sphinx", marker = "extra == 'doc'" }, { name = "sphinx-copybutton", marker = "extra == 'doc'" }, { name = "sphinx-design", marker = "extra == 'doc'" }, @@ -1583,6 +1585,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/45/56d99ba9366476cd8548527667f01869279cedb9e66b28eb4dfb27701679/numpydoc-1.8.0-py3-none-any.whl", hash = "sha256:72024c7fd5e17375dec3608a27c03303e8ad00c81292667955c6fea7a3ccf541", size = 64003 }, ] +[[package]] +name = "optype" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/42/543e02c72aba7ebe78adb76bbfbed1bc1314eba633ad453984948e5a5f46/optype-0.8.0.tar.gz", hash = "sha256:8cbfd452d6f06c7c70502048f38a0d5451bc601054d3a577dd09c7d6363950e1", size = 85295 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/ff/604be975eb0e9fd02358cdacf496f4411db97bffc27279ce260e8f50aba4/optype-0.8.0-py3-none-any.whl", hash = "sha256:90a7760177f2e7feae379a60445fceec37b932b75a00c3d96067497573c5e84d", size = 74228 }, +] + [[package]] name = "packaging" version = "24.2" @@ -2627,6 +2641,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/1f/5d46a8d94e9f6d2c913cbb109e57e7eed914de38ea99e2c4d69a9fc93140/scipy-1.15.1-cp313-cp313t-win_amd64.whl", hash = "sha256:bc7136626261ac1ed988dca56cfc4ab5180f75e0ee52e58f1e6aa74b5f3eacd5", size = 43181730 }, ] +[[package]] +name = "scipy-stubs" +version = "1.15.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "optype", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/89/a809e2240f679d75cdee1a4b58b8b52caa1c5e2bf2e703cf9d6c16fc9fe0/scipy_stubs-1.15.1.0.tar.gz", hash = "sha256:25756486635594025ed3249b6fb659c29dc5874c7b55565ad248b13a4ff4fa1c", size = 270415 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/1f/f1db21491a40923251244ee8de4f71ce95dee2ed83c2b49ad479c2ea5189/scipy_stubs-1.15.1.0-py3-none-any.whl", hash = "sha256:f6e8f8dfd2aaa343705c46ac1fdc69556ea8a0a9b0645cb3af4dd652665a452f", size = 454057 }, +] + [[package]] name = "secretstorage" version = "3.3.3" From a79b9345389184889b251878249c41fefb17d6bc Mon Sep 17 00:00:00 2001 From: Dan Redding <125183946+dangotbanned@users.noreply.github.com> Date: Sat, 18 Jan 2025 13:06:07 +0000 Subject: [PATCH 2/3] ci(typing): Disable `mypy` `[annotation-unchecked]` on examples (#3775) --- pyproject.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8922cc9f1..0b36e071e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -320,6 +320,13 @@ module = [ ignore_missing_imports = true disable_error_code = ["import-untyped"] +[[tool.mypy.overrides]] +module = [ + "tests/examples_arguments_syntax.*", + "tests/examples_methods_syntax.*", +] +disable_error_code = ["annotation-unchecked"] + [tool.pyright] enableExperimentalFeatures=true extraPaths=["./tools"] From 82ec2692efab4ad8f47b39623f1769d90e62f6ae Mon Sep 17 00:00:00 2001 From: Dan Redding <125183946+dangotbanned@users.noreply.github.com> Date: Sat, 18 Jan 2025 13:56:41 +0000 Subject: [PATCH 3/3] ci(uv): Remove dynamic version from `uv.lock` (#3776) --- uv.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/uv.lock b/uv.lock index 6ca37e0b0..732957321 100644 --- a/uv.lock +++ b/uv.lock @@ -47,7 +47,6 @@ wheels = [ [[package]] name = "altair" -version = "5.6.0.dev0" source = { editable = "." } dependencies = [ { name = "jinja2" },