diff --git a/pyproject.toml b/pyproject.toml index 87daa69b1..5ac95f190 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] @@ -325,8 +326,17 @@ module = [ "ipykernel.*", "ibis.*", "vegafusion.*", + "scipy.*" ] 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 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"