From acac475c8c7727b09b61f8999f2ce657132e4bce Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:38:09 +0100 Subject: [PATCH 01/35] use dependency groups --- .github/workflows/python_tests.yml | 4 ++-- README.rst | 16 ++++++++++++---- docs/contributing.rst | 4 ++-- docs/faq.rst | 2 +- pyproject.toml | 4 ++++ 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/python_tests.yml b/.github/workflows/python_tests.yml index eba3c3ec..50454db8 100644 --- a/.github/workflows/python_tests.yml +++ b/.github/workflows/python_tests.yml @@ -30,7 +30,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install .[test] "scikit-learn<1.8.0" + pip install --group=test . "scikit-learn<1.8.0" - name: Test with pytest run: | @@ -39,7 +39,7 @@ jobs: - name: Build docs if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == 3.10 }} run: | - pip install .[docs] + pip install --group=docs . make -C docs clean make -C docs html diff --git a/README.rst b/README.rst index 5a246f4f..5cc82831 100644 --- a/README.rst +++ b/README.rst @@ -122,10 +122,18 @@ To build and install from source, clone this repository or download the source a .. code-block:: shell cd pingouin - python -m build # optional, build a wheel and sdist - pip install . # install the package - pip install --editable . # or editable install - pytest # test the package + + # optional, build a wheel and sdist + python -m build + + # install the package + pip install . + + # or editable install with dev dependencies + pip install --group test --group docs --editable . + + # test the package + pytest Quick start ============ diff --git a/docs/contributing.rst b/docs/contributing.rst index 83081e4c..a778c7d9 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -45,11 +45,11 @@ All changes to the codebase must be properly documented. To ensure that document Build locally ^^^^^^^^^^^^^ -If you want to test the documentation locally, you will need to install additional dependencies. They can be installed with the docs extra: +If you want to test the documentation locally, you will need to install development dependencies. They can be installed with the `docs` dependency group: .. code-block:: bash - $ pip install --upgrade pingouin[docs] + $ pip install --upgrade pingouin and then within the ``pingouin/docs`` directory do: diff --git a/docs/faq.rst b/docs/faq.rst index 093480ee..389d0afe 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -15,7 +15,7 @@ To install Pingouin, open a command prompt (or Terminal or Anaconda Prompt) and .. code-block:: bash - pip install pingouin --upgrade + pip install --upgrade pingouin You should now be able to use Pingouin. To try it, you need to open an interactive Python console (either `IPython `_ or `Jupyter `_). For example, type the following command in a command prompt: diff --git a/pyproject.toml b/pyproject.toml index feef0a7c..9b059256 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,8 @@ dependencies = [ extras = [ "mpmath", ] + +[dependency-groups] test = [ "pytest>=6", "pytest-cov", @@ -62,6 +64,8 @@ docs = [ [project.urls] Homepage = "https://pingouin-stats.org/index.html" Downloads = "https://github.com/raphaelvallat/pingouin/" +Issues = "https://github.com/raphaelvallat/pingouin/issues" +Changelog = "https://pingouin-stats.org/build/html/changelog.html" [tool.setuptools] py-modules = ["pingouin"] From bb54aabfcf0bc6086b5516214fd3f40de2254178 Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:14:47 +0100 Subject: [PATCH 02/35] update ruff config --- .github/workflows/ruff.yml | 9 ++- docs/conf.py | 1 + docs/contributing.rst | 8 ++- pyproject.toml | 25 ++++++++- src/pingouin/bayesian.py | 11 ++-- src/pingouin/circular.py | 14 ++--- src/pingouin/contingency.py | 12 ++-- src/pingouin/correlation.py | 84 ++++++++++++++++------------ src/pingouin/distribution.py | 88 +++++++++++++++-------------- src/pingouin/effsize.py | 65 +++++++++++++--------- src/pingouin/equivalence.py | 2 +- src/pingouin/multicomp.py | 22 ++++---- src/pingouin/multivariate.py | 34 ++++++------ src/pingouin/nonparametric.py | 59 +++++++++++++------- src/pingouin/pairwise.py | 80 ++++++++++++++------------ src/pingouin/parametric.py | 89 +++++++++++++++-------------- src/pingouin/plotting.py | 102 +++++++++++++++++++++------------- src/pingouin/power.py | 61 ++++++++++---------- src/pingouin/regression.py | 79 +++++++++++++++----------- src/pingouin/reliability.py | 18 +++--- src/pingouin/utils.py | 12 ++-- tests/test_bayesian.py | 11 ++-- tests/test_circular.py | 9 ++- tests/test_config.py | 3 +- tests/test_contingency.py | 8 ++- tests/test_correlation.py | 8 ++- tests/test_distribution.py | 12 ++-- tests/test_effsize.py | 7 ++- tests/test_equivalence.py | 4 +- tests/test_multicomp.py | 8 ++- tests/test_multivariate.py | 6 +- tests/test_nonparametric.py | 16 +++--- tests/test_pairwise.py | 13 +++-- tests/test_pandas.py | 4 +- tests/test_parametric.py | 8 +-- tests/test_plotting.py | 18 +++--- tests/test_power.py | 14 +++-- tests/test_regression.py | 15 +++-- tests/test_reliability.py | 8 ++- tests/test_utils.py | 20 +++---- 40 files changed, 611 insertions(+), 456 deletions(-) diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 7823a768..bb28efa5 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -4,10 +4,9 @@ jobs: ruff: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + - uses: astral-sh/ruff-action@v3 - name: "Linting" - uses: astral-sh/ruff-action@v3 + run: ruff check - name: "Formatting" - uses: astral-sh/ruff-action@v3 - with: - args: "format --check" + run: ruff format --check diff --git a/docs/conf.py b/docs/conf.py index 7868ed58..0e68e412 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,6 +10,7 @@ import os import sys import time + import pingouin sys.path.insert(0, os.path.abspath("sphinxext")) diff --git a/docs/contributing.rst b/docs/contributing.rst index a778c7d9..9d97456e 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -12,11 +12,13 @@ Code guidelines *Before starting new code*, we highly recommend opening an issue on `GitHub `_ to discuss potential changes. -* Please use standard `pep8 `_ and `flake8 `_ Python style guidelines. Pingouin uses `ruff `_ for code formatting. Before submitting a PR, please make sure to run the following command in the root folder of Pingouin: +* Please use standard `pep8 `_ and `flake8 `_ Python style guidelines. Pingouin uses `ruff `_ for code formatting. Before submitting a PR, please make sure to run the following command in the root folder of Pingouin to sort all imports and format afterwards: - .. code-block:: bash + .. code-block:: bash + + $ ruff check --select I --fix - $ ruff format --line-length=100 + $ ruff format * Use `NumPy style `_ for docstrings. Follow existing examples for simplest guidance. diff --git a/pyproject.toml b/pyproject.toml index 9b059256..877afb92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,9 @@ extras = [ ] [dependency-groups] +dev = [ + "ruff>=0.15.0", +] test = [ "pytest>=6", "pytest-cov", @@ -61,6 +64,7 @@ docs = [ "sphinx-notfound-page", ] + [project.urls] Homepage = "https://pingouin-stats.org/index.html" Downloads = "https://github.com/raphaelvallat/pingouin/" @@ -119,11 +123,30 @@ exclude = [ "notebooks", # Skip jupyter notebook examples ] +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint.flake8-import-conventions.aliases] +"matplotlib.pyplot" = "plt" +numpy = "np" +"numpy.typing" = "npt" +pandas = "pd" +seaborn = "sns" +scipy = "sp" + [tool.ruff.lint] select = [ "E4", # Subset of pycodestyle rules "E7", # Subset of pycodestyle rules "E9", # Subset of pycodestyle rules "F", # All Pyflakes rules - "NPY201", + "NPY", # numpy + "W", + "I", + #"PD", # pandas imports + #"UP" # Upgrade pythonv versions ] + +ignore = [ + "NPY002" # exlude numpy random +] \ No newline at end of file diff --git a/src/pingouin/bayesian.py b/src/pingouin/bayesian.py index 9635012a..6b775134 100644 --- a/src/pingouin/bayesian.py +++ b/src/pingouin/bayesian.py @@ -1,9 +1,10 @@ """Bayesian functions.""" import warnings +from math import exp, lgamma, log, pi + import numpy as np from scipy.integrate import quad -from math import pi, exp, log, lgamma __all__ = ["bayesfactor_ttest", "bayesfactor_pearson", "bayesfactor_binom"] @@ -237,18 +238,18 @@ def bayesfactor_pearson(r, n, alternative="two-sided", method="ly", kappa=1.0): Compare to Wetzels method: - >>> bf = bayesfactor_pearson(r, n, method='wetzels') + >>> bf = bayesfactor_pearson(r, n, method="wetzels") >>> print("Bayes Factor: %.3f" % bf) Bayes Factor: 8.221 One-sided test - >>> bf10pos = bayesfactor_pearson(r, n, alternative='greater') - >>> bf10neg = bayesfactor_pearson(r, n, alternative='less') + >>> bf10pos = bayesfactor_pearson(r, n, alternative="greater") + >>> bf10neg = bayesfactor_pearson(r, n, alternative="less") >>> print("BF-pos: %.3f, BF-neg: %.3f" % (bf10pos, bf10neg)) BF-pos: 21.185, BF-neg: 0.082 """ - from scipy.special import gamma, betaln, hyp2f1 + from scipy.special import betaln, gamma, hyp2f1 assert method.lower() in ["ly", "wetzels"], "Method not recognized." assert alternative in [ diff --git a/src/pingouin/circular.py b/src/pingouin/circular.py index ac301610..ce10ac07 100644 --- a/src/pingouin/circular.py +++ b/src/pingouin/circular.py @@ -108,7 +108,7 @@ def convert_angles(angles, low=0, high=360, positive=False): >>> import numpy as np >>> rad = [0.1, 3.14, 5, 2, 6] - >>> convert_angles(rad, low=0, high=2*np.pi) + >>> convert_angles(rad, low=0, high=2 * np.pi) array([ 0.1 , 3.14 , -1.28318531, 2. , -0.28318531]) 4. Convert degrees from a 2-D array @@ -168,8 +168,8 @@ def circ_axial(angles, n): >>> import numpy as np >>> from pingouin import read_dataset >>> from pingouin.circular import circ_axial - >>> df = read_dataset('circular') - >>> angles = df['Orientation'].to_numpy() + >>> df = read_dataset("circular") + >>> angles = df["Orientation"].to_numpy() >>> angles = circ_axial(np.deg2rad(angles), 2) """ angles = np.asarray(angles) @@ -258,7 +258,7 @@ def circ_mean(angles, w=None, axis=0): >>> from scipy.stats import circmean >>> import numpy as np - >>> round(circmean(angles, low=0, high=2*np.pi), 4) + >>> round(circmean(angles, low=0, high=2 * np.pi), 4) 1.013 2. Using a 2-D array of angles in degrees @@ -590,7 +590,7 @@ def circ_corrcl(x, y): >>> print(round(r, 3), round(pval, 3)) 0.109 0.971 """ - from scipy.stats import pearsonr, chi2 + from scipy.stats import chi2, pearsonr x = np.asarray(x) y = np.asarray(y) @@ -662,7 +662,7 @@ def circ_rayleigh(angles, w=None, d=None): 2. Specifying w and d - >>> z, pval = circ_rayleigh(x, w=[.1, .2, .3, .4, .5], d=0.2) + >>> z, pval = circ_rayleigh(x, w=[0.1, 0.2, 0.3, 0.4, 0.5], d=0.2) >>> print(round(z, 3), round(pval, 6)) 0.278 0.806997 """ @@ -741,7 +741,7 @@ def circ_vtest(angles, dir=0.0, w=None, d=None): 2. Specifying w and d - >>> v, pval = circ_vtest(x, dir=0.5, w=[.1, .2, .3, .4, .5], d=0.2) + >>> v, pval = circ_vtest(x, dir=0.5, w=[0.1, 0.2, 0.3, 0.4, 0.5], d=0.2) >>> print(round(v, 3), round(pval, 5)) 0.637 0.23086 """ diff --git a/src/pingouin/contingency.py b/src/pingouin/contingency.py index db636e1a..428fa7f9 100644 --- a/src/pingouin/contingency.py +++ b/src/pingouin/contingency.py @@ -1,13 +1,13 @@ # Date: May 2019 import warnings + import numpy as np import pandas as pd - +from scipy.stats import binom, power_divergence +from scipy.stats import chi2 as sp_chi2 from scipy.stats.contingency import expected_freq -from scipy.stats import power_divergence, binom, chi2 as sp_chi2 - -from pingouin import power_chi2, _postprocess_dataframe +from pingouin import _postprocess_dataframe, power_chi2 __all__ = ["chi2_independence", "chi2_mcnemar", "dichotomous_crosstab"] @@ -292,8 +292,8 @@ def chi2_mcnemar(data, x, y, correction=True): Examples -------- >>> import pingouin as pg - >>> data = pg.read_dataset('chi2_mcnemar') - >>> observed, stats = pg.chi2_mcnemar(data, 'treatment_X', 'treatment_Y') + >>> data = pg.read_dataset("chi2_mcnemar") + >>> observed, stats = pg.chi2_mcnemar(data, "treatment_X", "treatment_Y") >>> observed treatment_Y 0 1 treatment_X diff --git a/src/pingouin/correlation.py b/src/pingouin/correlation.py index c40b9135..481cd2d8 100644 --- a/src/pingouin/correlation.py +++ b/src/pingouin/correlation.py @@ -1,18 +1,18 @@ # Author: Raphael Vallat import warnings + import numpy as np import pandas as pd import pandas_flavor as pf from scipy.spatial.distance import pdist, squareform -from scipy.stats import pearsonr, spearmanr, kendalltau +from scipy.stats import kendalltau, pearsonr, spearmanr +from pingouin.bayesian import bayesfactor_pearson from pingouin.config import options -from pingouin.power import power_corr -from pingouin.multicomp import multicomp from pingouin.effsize import compute_esci -from pingouin.utils import remove_na, _perm_pval, _postprocess_dataframe -from pingouin.bayesian import bayesfactor_pearson - +from pingouin.multicomp import multicomp +from pingouin.power import power_corr +from pingouin.utils import _perm_pval, _postprocess_dataframe, remove_na __all__ = ["corr", "partial_corr", "pcorr", "rcorr", "rm_corr", "distance_corr"] @@ -517,7 +517,7 @@ def corr(x, y, alternative="two-sided", method="pearson", **kwargs): >>> import pingouin as pg >>> # Generate random correlated samples >>> np.random.seed(123) - >>> mean, cov = [4, 6], [(1, .5), (.5, 1)] + >>> mean, cov = [4, 6], [(1, 0.5), (0.5, 1)] >>> x, y = np.random.multivariate_normal(mean, cov, 30).T >>> # Compute Pearson correlation >>> pg.corr(x, y).round(3) @@ -545,29 +545,29 @@ def corr(x, y, alternative="two-sided", method="pearson", **kwargs): 5. Percentage bend correlation (robust) - >>> pg.corr(x, y, method='percbend').round(3) + >>> pg.corr(x, y, method="percbend").round(3) n r CI95 p_val power percbend 30 0.389 [0.03, 0.66] 0.034 0.581 6. Shepherd's pi correlation (robust) - >>> pg.corr(x, y, method='shepherd').round(3) + >>> pg.corr(x, y, method="shepherd").round(3) n outliers r CI95 p_val power shepherd 30 2 0.437 [0.08, 0.7] 0.02 0.662 7. Skipped spearman correlation (robust) - >>> pg.corr(x, y, method='skipped').round(3) + >>> pg.corr(x, y, method="skipped").round(3) n outliers r CI95 p_val power skipped 30 2 0.437 [0.08, 0.7] 0.02 0.662 8. One-tailed Pearson correlation - >>> pg.corr(x, y, alternative="greater", method='pearson').round(3) + >>> pg.corr(x, y, alternative="greater", method="pearson").round(3) n r CI95 p_val BF10 power pearson 30 0.147 [-0.17, 1.0] 0.22 0.467 0.194 - >>> pg.corr(x, y, alternative="less", method='pearson').round(3) + >>> pg.corr(x, y, alternative="less", method="pearson").round(3) n r CI95 p_val BF10 power pearson 30 0.147 [-1.0, 0.43] 0.78 0.137 0.008 @@ -580,8 +580,8 @@ def corr(x, y, alternative="two-sided", method="pearson", **kwargs): 10. Using columns of a pandas dataframe >>> import pandas as pd - >>> data = pd.DataFrame({'x': x, 'y': y}) - >>> pg.corr(data['x'], data['y']).round(3) + >>> data = pd.DataFrame({"x": x, "y": y}) + >>> pg.corr(data["x"], data["y"]).round(3) n r CI95 p_val BF10 power pearson 30 0.147 [-0.23, 0.48] 0.439 0.302 0.121 """ @@ -776,34 +776,47 @@ def partial_corr( 1. Partial correlation with one covariate >>> import pingouin as pg - >>> df = pg.read_dataset('partial_corr') - >>> pg.partial_corr(data=df, x='x', y='y', covar='cv1').round(3) + >>> df = pg.read_dataset("partial_corr") + >>> pg.partial_corr(data=df, x="x", y="y", covar="cv1").round(3) n r CI95 p_val pearson 30 0.568 [0.25, 0.77] 0.001 2. Spearman partial correlation with several covariates >>> # Partial correlation of x and y controlling for cv1, cv2 and cv3 - >>> pg.partial_corr(data=df, x='x', y='y', covar=['cv1', 'cv2', 'cv3'], - ... method='spearman').round(3) + >>> pg.partial_corr( + ... data=df, x="x", y="y", covar=["cv1", "cv2", "cv3"], method="spearman" + ... ).round(3) n r CI95 p_val spearman 30 0.521 [0.18, 0.75] 0.005 3. Same but one-sided test - >>> pg.partial_corr(data=df, x='x', y='y', covar=['cv1', 'cv2', 'cv3'], - ... alternative="greater", method='spearman').round(3) + >>> pg.partial_corr( + ... data=df, + ... x="x", + ... y="y", + ... covar=["cv1", "cv2", "cv3"], + ... alternative="greater", + ... method="spearman", + ... ).round(3) n r CI95 p_val spearman 30 0.521 [0.24, 1.0] 0.003 - >>> pg.partial_corr(data=df, x='x', y='y', covar=['cv1', 'cv2', 'cv3'], - ... alternative="less", method='spearman').round(3) + >>> pg.partial_corr( + ... data=df, + ... x="x", + ... y="y", + ... covar=["cv1", "cv2", "cv3"], + ... alternative="less", + ... method="spearman", + ... ).round(3) n r CI95 p_val spearman 30 0.521 [-1.0, 0.72] 0.997 4. As a pandas method - >>> df.partial_corr(x='x', y='y', covar=['cv1'], method='spearman').round(3) + >>> df.partial_corr(x="x", y="y", covar=["cv1"], method="spearman").round(3) n r CI95 p_val spearman 30 0.578 [0.27, 0.78] 0.001 @@ -819,7 +832,7 @@ def partial_corr( 6. Semi-partial correlation on x - >>> pg.partial_corr(data=df, x='x', y='y', x_covar=['cv1', 'cv2', 'cv3']).round(3) + >>> pg.partial_corr(data=df, x="x", y="y", x_covar=["cv1", "cv2", "cv3"]).round(3) n r CI95 p_val pearson 30 0.463 [0.1, 0.72] 0.015 """ @@ -940,7 +953,7 @@ def pcorr(self): Examples -------- >>> import pingouin as pg - >>> data = pg.read_dataset('mediation') + >>> data = pg.read_dataset("mediation") >>> data.pcorr().round(3) X M Y Mbin Ybin W1 W2 X 1.000 0.359 0.074 -0.019 -0.147 -0.148 -0.067 @@ -953,7 +966,7 @@ def pcorr(self): On a subset of columns - >>> data[['X', 'Y', 'M']].pcorr() + >>> data[["X", "Y", "M"]].pcorr() X Y M X 1.000000 0.036649 0.412804 Y 0.036649 1.000000 0.540140 @@ -1026,7 +1039,7 @@ def rcorr( >>> import pandas as pd >>> import pingouin as pg >>> # Load an example dataset of personality dimensions - >>> df = pg.read_dataset('pairwise_corr').iloc[:, 1:] + >>> df = pg.read_dataset("pairwise_corr").iloc[:, 1:] >>> # Add some missing values >>> df.iloc[[2, 5, 20], 2] = np.nan >>> df.iloc[[1, 4, 10], 3] = np.nan @@ -1047,7 +1060,7 @@ def rcorr( Agreeableness -0.134 0.054 0.161 - >>> # Spearman correlation and Holm adjustement for multiple comparisons - >>> df.iloc[:, 0:4].rcorr(method='spearman', padjust='holm') + >>> df.iloc[:, 0:4].rcorr(method="spearman", padjust="holm") Neuroticism Extraversion Openness Agreeableness Neuroticism - *** ** Extraversion -0.325 - *** @@ -1055,9 +1068,8 @@ def rcorr( Agreeableness -0.15 0.06 0.173 - >>> # Compare with the pg.pairwise_corr function - >>> pairwise = df.iloc[:, 0:4].pairwise_corr(method='spearman', - ... padjust='holm') - >>> pairwise[['X', 'Y', 'r', 'p_corr']].round(3) # Do not show all columns + >>> pairwise = df.iloc[:, 0:4].pairwise_corr(method="spearman", padjust="holm") + >>> pairwise[["X", "Y", "r", "p_corr"]].round(3) # Do not show all columns X Y r p_corr 0 Neuroticism Extraversion -0.325 0.000 1 Neuroticism Openness -0.027 0.543 @@ -1074,7 +1086,7 @@ def rcorr( Agreeableness -0.134 0.0539 - >>> # With the sample size on the upper triangle instead of the p-values - >>> df.iloc[:, [0, 1, 2]].rcorr(upper='n') + >>> df.iloc[:, [0, 1, 2]].rcorr(upper="n") Neuroticism Extraversion Openness Neuroticism - 500 497 Extraversion -0.35 - 497 @@ -1182,8 +1194,8 @@ def rm_corr(data=None, x=None, y=None, subject=None): Examples -------- >>> import pingouin as pg - >>> df = pg.read_dataset('rm_corr') - >>> pg.rm_corr(data=df, x='pH', y='PacO2', subject='Subject') + >>> df = pg.read_dataset("rm_corr") + >>> pg.rm_corr(data=df, x="pH", y="PacO2", subject="Subject") r dof pval CI95 power rm_corr -0.50677 38 0.000847 [-0.71, -0.23] 0.929579 @@ -1192,8 +1204,8 @@ def rm_corr(data=None, x=None, y=None, subject=None): .. plot:: >>> import pingouin as pg - >>> df = pg.read_dataset('rm_corr') - >>> g = pg.plot_rm_corr(data=df, x='pH', y='PacO2', subject='Subject') + >>> df = pg.read_dataset("rm_corr") + >>> g = pg.plot_rm_corr(data=df, x="pH", y="PacO2", subject="Subject") """ from pingouin import ancova, power_corr diff --git a/src/pingouin/distribution.py b/src/pingouin/distribution.py index 32209e33..55d1e44d 100644 --- a/src/pingouin/distribution.py +++ b/src/pingouin/distribution.py @@ -1,11 +1,12 @@ import warnings -import scipy.stats +from collections import namedtuple + import numpy as np import pandas as pd -from collections import namedtuple -from pingouin.utils import _flatten_list as _fl -from pingouin.utils import remove_na, _postprocess_dataframe +import scipy.stats +from pingouin.utils import _flatten_list as _fl +from pingouin.utils import _postprocess_dataframe, remove_na __all__ = ["gzscore", "normality", "homoscedasticity", "anderson", "epsilon", "sphericity"] @@ -172,9 +173,9 @@ def normality(data, dv=None, group=None, method="shapiro", alpha=0.05): 2. Omnibus test on a wide-format dataframe with missing values - >>> data = pg.read_dataset('mediation') - >>> data.loc[1, 'X'] = np.nan - >>> pg.normality(data, method='normaltest').round(3) + >>> data = pg.read_dataset("mediation") + >>> data.loc[1, "X"] = np.nan + >>> pg.normality(data, method="normaltest").round(3) W pval normal X 1.792 0.408 True M 0.492 0.782 True @@ -186,14 +187,14 @@ def normality(data, dv=None, group=None, method="shapiro", alpha=0.05): 3. Pandas Series - >>> pg.normality(data['X'], method='normaltest') + >>> pg.normality(data["X"], method="normaltest") W pval normal X 1.791839 0.408232 True 4. Long-format dataframe - >>> data = pg.read_dataset('rm_anova2') - >>> pg.normality(data, dv='Performance', group='Time') + >>> data = pg.read_dataset("rm_anova2") + >>> pg.normality(data, dv="Performance", group="Time") W pval normal Time Pre 0.967718 0.478773 True @@ -201,7 +202,7 @@ def normality(data, dv=None, group=None, method="shapiro", alpha=0.05): 5. Same but using the Jarque-Bera test - >>> pg.normality(data, dv='Performance', group='Time', method="jarque_bera") + >>> pg.normality(data, dv="Performance", group="Time", method="jarque_bera") W pval normal Time Pre 0.304021 0.858979 True @@ -346,14 +347,14 @@ def homoscedasticity(data, dv=None, group=None, method="levene", alpha=0.05, **k >>> import numpy as np >>> import pingouin as pg - >>> data = pg.read_dataset('mediation') - >>> pg.homoscedasticity(data[['X', 'Y', 'M']]) + >>> data = pg.read_dataset("mediation") + >>> pg.homoscedasticity(data[["X", "Y", "M"]]) W pval equal_var levene 1.173518 0.310707 True 2. Same data but using a long-format dataframe - >>> data_long = data[['X', 'Y', 'M']].melt() + >>> data_long = data[["X", "Y", "M"]].melt() >>> pg.homoscedasticity(data_long, dv="value", group="variable") W pval equal_var levene 1.173518 0.310707 True @@ -367,7 +368,7 @@ def homoscedasticity(data, dv=None, group=None, method="levene", alpha=0.05, **k 4. Bartlett test using a list of iterables >>> data = [[4, 8, 9, 20, 14], np.array([5, 8, 15, 45, 12])] - >>> pg.homoscedasticity(data, method="bartlett", alpha=.05) + >>> pg.homoscedasticity(data, method="bartlett", alpha=0.05) T pval equal_var bartlett 2.873569 0.090045 True """ @@ -629,18 +630,22 @@ def epsilon(data, dv=None, within=None, subject=None, correction="gg"): >>> import pandas as pd >>> import pingouin as pg - >>> data = pd.DataFrame({'A': [2.2, 3.1, 4.3, 4.1, 7.2], - ... 'B': [1.1, 2.5, 4.1, 5.2, 6.4], - ... 'C': [8.2, 4.5, 3.4, 6.2, 7.2]}) - >>> gg = pg.epsilon(data, correction='gg') - >>> hf = pg.epsilon(data, correction='hf') - >>> lb = pg.epsilon(data, correction='lb') + >>> data = pd.DataFrame( + ... { + ... "A": [2.2, 3.1, 4.3, 4.1, 7.2], + ... "B": [1.1, 2.5, 4.1, 5.2, 6.4], + ... "C": [8.2, 4.5, 3.4, 6.2, 7.2], + ... } + ... ) + >>> gg = pg.epsilon(data, correction="gg") + >>> hf = pg.epsilon(data, correction="hf") + >>> lb = pg.epsilon(data, correction="lb") >>> print("%.2f %.2f %.2f" % (lb, gg, hf)) 0.50 0.56 0.62 Now using a long-format dataframe - >>> data = pg.read_dataset('rm_anova2') + >>> data = pg.read_dataset("rm_anova2") >>> data.head() Subject Time Metric Performance 0 1 Pre Product 13 @@ -651,8 +656,7 @@ def epsilon(data, dv=None, within=None, subject=None, correction="gg"): Let's first calculate the epsilon of the *Time* within-subject factor - >>> pg.epsilon(data, dv='Performance', subject='Subject', - ... within='Time') + >>> pg.epsilon(data, dv="Performance", subject="Subject", within="Time") 1.0 Since *Time* has only two levels (Pre and Post), the sphericity assumption @@ -660,8 +664,7 @@ def epsilon(data, dv=None, within=None, subject=None, correction="gg"): The *Metric* factor, however, has three levels: - >>> round(pg.epsilon(data, dv='Performance', subject='Subject', - ... within=['Metric']), 3) + >>> round(pg.epsilon(data, dv="Performance", subject="Subject", within=["Metric"]), 3) 0.969 The epsilon value is very close to 1, meaning that there is no major @@ -670,15 +673,14 @@ def epsilon(data, dv=None, within=None, subject=None, correction="gg"): Now, let's calculate the epsilon for the interaction between the two repeated measures factor: - >>> round(pg.epsilon(data, dv='Performance', subject='Subject', - ... within=['Time', 'Metric']), 3) + >>> round(pg.epsilon(data, dv="Performance", subject="Subject", within=["Time", "Metric"]), 3) 0.727 Alternatively, we could use a wide-format dataframe with two column levels: >>> # Pivot from long-format to wide-format - >>> piv = data.pivot(index='Subject', columns=['Time', 'Metric'], values='Performance') + >>> piv = data.pivot(index="Subject", columns=["Time", "Metric"], values="Performance") >>> piv.head() Time Pre Post Metric Product Client Action Product Client Action @@ -872,21 +874,25 @@ def sphericity(data, dv=None, within=None, subject=None, method="mauchly", alpha >>> import pandas as pd >>> import pingouin as pg - >>> data = pd.DataFrame({'A': [2.2, 3.1, 4.3, 4.1, 7.2], - ... 'B': [1.1, 2.5, 4.1, 5.2, 6.4], - ... 'C': [8.2, 4.5, 3.4, 6.2, 7.2]}) + >>> data = pd.DataFrame( + ... { + ... "A": [2.2, 3.1, 4.3, 4.1, 7.2], + ... "B": [1.1, 2.5, 4.1, 5.2, 6.4], + ... "C": [8.2, 4.5, 3.4, 6.2, 7.2], + ... } + ... ) >>> spher, W, chisq, dof, pval = pg.sphericity(data) >>> print(spher, round(W, 3), round(chisq, 3), dof, round(pval, 3)) True 0.21 4.677 2 0.096 John, Nagao and Sugiura (JNS) test - >>> round(pg.sphericity(data, method='jns')[-1], 3) # P-value only + >>> round(pg.sphericity(data, method="jns")[-1], 3) # P-value only 0.046 Now using a long-format dataframe - >>> data = pg.read_dataset('rm_anova2') + >>> data = pg.read_dataset("rm_anova2") >>> data.head() Subject Time Metric Performance 0 1 Pre Product 13 @@ -897,8 +903,7 @@ def sphericity(data, dv=None, within=None, subject=None, method="mauchly", alpha Let's first test sphericity for the *Time* within-subject factor - >>> pg.sphericity(data, dv='Performance', subject='Subject', - ... within='Time') + >>> pg.sphericity(data, dv="Performance", subject="Subject", within="Time") (True, nan, nan, 1, 1.0) Since *Time* has only two levels (Pre and Post), the sphericity assumption @@ -906,8 +911,7 @@ def sphericity(data, dv=None, within=None, subject=None, method="mauchly", alpha The *Metric* factor, however, has three levels: - >>> round(pg.sphericity(data, dv='Performance', subject='Subject', - ... within=['Metric'])[-1], 3) + >>> round(pg.sphericity(data, dv="Performance", subject="Subject", within=["Metric"])[-1], 3) 0.878 The p-value value is very large, and the test therefore indicates that @@ -918,9 +922,9 @@ def sphericity(data, dv=None, within=None, subject=None, method="mauchly", alpha if at least one of the two within-subject factors has no more than two levels. - >>> spher, _, chisq, dof, pval = pg.sphericity(data, dv='Performance', - ... subject='Subject', - ... within=['Time', 'Metric']) + >>> spher, _, chisq, dof, pval = pg.sphericity( + ... data, dv="Performance", subject="Subject", within=["Time", "Metric"] + ... ) >>> print(spher, round(chisq, 3), dof, round(pval, 3)) True 3.763 2 0.152 @@ -931,7 +935,7 @@ def sphericity(data, dv=None, within=None, subject=None, method="mauchly", alpha levels: >>> # Pivot from long-format to wide-format - >>> piv = data.pivot(index='Subject', columns=['Time', 'Metric'], values='Performance') + >>> piv = data.pivot(index="Subject", columns=["Time", "Metric"], values="Performance") >>> piv.head() Time Pre Post Metric Product Client Action Product Client Action diff --git a/src/pingouin/effsize.py b/src/pingouin/effsize.py index 1e26457e..317af5f2 100644 --- a/src/pingouin/effsize.py +++ b/src/pingouin/effsize.py @@ -1,8 +1,10 @@ # Author: Raphael Vallat # Date: April 2018 import warnings + import numpy as np from scipy.stats import pearsonr + from pingouin.utils import _check_eftype, remove_na # from pingouin.distribution import homoscedasticity @@ -124,15 +126,15 @@ def compute_esci( >>> x = [3, 4, 6, 7, 5, 6, 7, 3, 5, 4, 2] >>> y = [4, 6, 6, 7, 6, 5, 5, 2, 3, 4, 1] >>> nx, ny = len(x), len(y) - >>> stat = pg.compute_effsize(x, y, eftype='r') - >>> ci = pg.compute_esci(stat=stat, nx=nx, ny=ny, eftype='r') + >>> stat = pg.compute_effsize(x, y, eftype="r") + >>> ci = pg.compute_esci(stat=stat, nx=nx, ny=ny, eftype="r") >>> print(round(stat, 4), ci) 0.7468 [0.27 0.93] 2. Confidence interval of a Cohen d - >>> stat = pg.compute_effsize(x, y, eftype='cohen') - >>> ci = pg.compute_esci(stat, nx=nx, ny=ny, eftype='cohen', decimals=3) + >>> stat = pg.compute_effsize(x, y, eftype="cohen") + >>> ci = pg.compute_esci(stat, nx=nx, ny=ny, eftype="cohen", decimals=3) >>> print(round(stat, 4), ci) 0.1538 [-0.737 1.045] """ @@ -288,7 +290,7 @@ def compute_bootci( >>> x = rng.normal(loc=4, scale=2, size=100) >>> y = rng.normal(loc=3, scale=1, size=100) >>> stat = np.corrcoef(x, y)[0][1] - >>> ci = pg.compute_bootci(x, y, func='pearson', paired=True, seed=42, decimals=4) + >>> ci = pg.compute_bootci(x, y, func="pearson", paired=True, seed=42, decimals=4) >>> print(round(stat, 4), ci) 0.0945 [-0.098 0.2738] @@ -296,15 +298,21 @@ def compute_bootci( >>> from scipy.stats import bootstrap >>> bt_scipy = bootstrap( - ... data=(x, y), statistic=lambda x, y: np.corrcoef(x, y)[0][1], - ... method="basic", vectorized=False, n_resamples=2000, paired=True, random_state=42) + ... data=(x, y), + ... statistic=lambda x, y: np.corrcoef(x, y)[0][1], + ... method="basic", + ... vectorized=False, + ... n_resamples=2000, + ... paired=True, + ... random_state=42, + ... ) >>> np.round(bt_scipy.confidence_interval, 4) array([-0.0952, 0.2883]) 2. Bootstrapped 95% confidence interval of a Cohen d - >>> stat = pg.compute_effsize(x, y, eftype='cohen') - >>> ci = pg.compute_bootci(x, y, func='cohen', seed=42, decimals=3) + >>> stat = pg.compute_effsize(x, y, eftype="cohen") + >>> ci = pg.compute_bootci(x, y, func="cohen", seed=42, decimals=3) >>> print(round(stat, 4), ci) 0.7009 [0.403 1.009] @@ -312,7 +320,7 @@ def compute_bootci( >>> import numpy as np >>> stat = np.std(x, ddof=1) - >>> ci = pg.compute_bootci(x, func='std', seed=123) + >>> ci = pg.compute_bootci(x, func="std", seed=123) >>> print(round(stat, 4), ci) 1.5534 [1.38 1.8 ] @@ -321,16 +329,16 @@ def compute_bootci( >>> def std(x, axis): ... return np.std(x, ddof=1, axis=axis) - >>> bt_scipy = bootstrap(data=(x, ), statistic=std, n_resamples=2000, random_state=123) + >>> bt_scipy = bootstrap(data=(x,), statistic=std, n_resamples=2000, random_state=123) >>> np.round(bt_scipy.confidence_interval, 2) array([1.39, 1.81]) Changing the confidence intervals type in Pingouin - >>> pg.compute_bootci(x, func='std', seed=123, method="norm") + >>> pg.compute_bootci(x, func="std", seed=123, method="norm") array([1.37, 1.76]) - >>> pg.compute_bootci(x, func='std', seed=123, method="percentile") + >>> pg.compute_bootci(x, func="std", seed=123, method="percentile") array([1.35, 1.75]) 4. Bootstrapped confidence interval using a custom univariate function @@ -352,11 +360,14 @@ def compute_bootci( We can also get the bootstrapped distribution >>> ci, bt = pg.compute_bootci(x, y2, func=mean_diff, n_boot=10000, return_dist=True, seed=9) - >>> print(f"The bootstrap distribution has {bt.size} samples. The mean and standard " - ... f"{bt.mean():.4f} ± {bt.std():.4f}") + >>> print( + ... f"The bootstrap distribution has {bt.size} samples. The mean and standard " + ... f"{bt.mean():.4f} ± {bt.std():.4f}" + ... ) The bootstrap distribution has 10000 samples. The mean and standard 0.8807 ± 0.1704 """ from inspect import isfunction, isroutine + from scipy.stats import norm # Check other arguments @@ -569,27 +580,27 @@ def convert_effsize(ef, input_type, output_type, nx=None, ny=None): 1. Convert from Cohen d to eta-square >>> import pingouin as pg - >>> d = .45 - >>> eta = pg.convert_effsize(d, 'cohen', 'eta_square') + >>> d = 0.45 + >>> eta = pg.convert_effsize(d, "cohen", "eta_square") >>> print(eta) 0.048185603807257595 2. Convert from Cohen d to Hegdes g (requires the sample sizes of each group) - >>> pg.convert_effsize(.45, 'cohen', 'hedges', nx=10, ny=10) + >>> pg.convert_effsize(0.45, "cohen", "hedges", nx=10, ny=10) 0.4309859154929578 3. Convert a point-biserial correlation to Cohen d >>> rpb = 0.40 - >>> d = pg.convert_effsize(rpb, 'pointbiserialr', 'cohen') + >>> d = pg.convert_effsize(rpb, "pointbiserialr", "cohen") >>> print(d) 0.8728715609439696 4. Reverse operation: convert Cohen d to a point-biserial correlation - >>> pg.convert_effsize(d, 'cohen', 'pointbiserialr') + >>> pg.convert_effsize(d, "cohen", "pointbiserialr") 0.4000000000000001 """ it = input_type.lower() @@ -738,32 +749,32 @@ def compute_effsize(x, y, paired=False, eftype="cohen"): >>> import pingouin as pg >>> x = [1, 2, 3, 4] >>> y = [3, 4, 5, 6, 7] - >>> pg.compute_effsize(x, y, paired=False, eftype='cohen') + >>> pg.compute_effsize(x, y, paired=False, eftype="cohen") -1.707825127659933 The sign of the Cohen d will be opposite if we reverse the order of ``x`` and ``y``: - >>> pg.compute_effsize(y, x, paired=False, eftype='cohen') + >>> pg.compute_effsize(y, x, paired=False, eftype="cohen") 1.707825127659933 2. Hedges g from two paired samples. >>> x = [1, 2, 3, 4, 5, 6, 7] >>> y = [1, 3, 5, 7, 9, 11, 13] - >>> pg.compute_effsize(x, y, paired=True, eftype='hedges') + >>> pg.compute_effsize(x, y, paired=True, eftype="hedges") -0.8222477210374874 3. Common Language Effect Size. - >>> pg.compute_effsize(x, y, eftype='cles') + >>> pg.compute_effsize(x, y, eftype="cles") 0.2857142857142857 In other words, there are ~29% of pairs where ``x`` is higher than ``y``, which means that there are ~71% of pairs where ``x`` is *lower* than ``y``. This can be easily verified by changing the order of ``x`` and ``y``: - >>> pg.compute_effsize(y, x, eftype='cles') + >>> pg.compute_effsize(y, x, eftype="cles") 0.7142857142857143 """ # Check arguments @@ -850,14 +861,14 @@ def compute_effsize_from_t(tval, nx=None, ny=None, N=None, eftype="cohen"): >>> from pingouin import compute_effsize_from_t >>> tval, nx, ny = 2.90, 35, 25 - >>> d = compute_effsize_from_t(tval, nx=nx, ny=ny, eftype='cohen') + >>> d = compute_effsize_from_t(tval, nx=nx, ny=ny, eftype="cohen") >>> print(d) 0.7593982580212534 2. Compute effect size when only total sample size is known (nx+ny) >>> tval, N = 2.90, 60 - >>> d = compute_effsize_from_t(tval, N=N, eftype='cohen') + >>> d = compute_effsize_from_t(tval, N=N, eftype="cohen") >>> print(d) 0.7487767802667672 """ diff --git a/src/pingouin/equivalence.py b/src/pingouin/equivalence.py index 70f5ee58..b2460c6d 100644 --- a/src/pingouin/equivalence.py +++ b/src/pingouin/equivalence.py @@ -2,10 +2,10 @@ # Date: July 2019 import numpy as np import pandas as pd + from pingouin.parametric import ttest from pingouin.utils import _postprocess_dataframe - __all__ = ["tost"] diff --git a/src/pingouin/multicomp.py b/src/pingouin/multicomp.py index 04415287..4b63bb42 100644 --- a/src/pingouin/multicomp.py +++ b/src/pingouin/multicomp.py @@ -80,8 +80,8 @@ def fdr(pvals, alpha=0.05, method="fdr_bh"): FDR correction of an array of p-values >>> import pingouin as pg - >>> pvals = [.50, .003, .32, .054, .0003] - >>> reject, pvals_corr = pg.multicomp(pvals, method='fdr_bh', alpha=.05) + >>> pvals = [0.50, 0.003, 0.32, 0.054, 0.0003] + >>> reject, pvals_corr = pg.multicomp(pvals, method="fdr_bh", alpha=0.05) >>> print(reject, pvals_corr) [False True False False True] [0.5 0.0075 0.4 0.09 0.0015] """ @@ -178,8 +178,8 @@ def bonf(pvals, alpha=0.05): Examples -------- >>> import pingouin as pg - >>> pvals = [.50, .003, .32, .054, .0003] - >>> reject, pvals_corr = pg.multicomp(pvals, method='bonf', alpha=.05) + >>> pvals = [0.50, 0.003, 0.32, 0.054, 0.0003] + >>> reject, pvals_corr = pg.multicomp(pvals, method="bonf", alpha=0.05) >>> print(reject, pvals_corr) [False True False False True] [1. 0.015 1. 0.27 0.0015] """ @@ -251,8 +251,8 @@ def holm(pvals, alpha=0.05): Examples -------- >>> import pingouin as pg - >>> pvals = [.50, .003, .32, .054, .0003] - >>> reject, pvals_corr = pg.multicomp(pvals, method='holm', alpha=.05) + >>> pvals = [0.50, 0.003, 0.32, 0.054, 0.0003] + >>> reject, pvals_corr = pg.multicomp(pvals, method="holm", alpha=0.05) >>> print(reject, pvals_corr) [False True False False True] [0.64 0.012 0.64 0.162 0.0015] """ @@ -327,8 +327,8 @@ def sidak(pvals, alpha=0.05): -------- >>> import numpy as np >>> import pingouin as pg - >>> pvals = [.50, .003, .32, .054, .0003] - >>> reject, pvals_corr = pg.multicomp(pvals, method='sidak', alpha=.05) + >>> pvals = [0.50, 0.003, 0.32, 0.054, 0.0003] + >>> reject, pvals_corr = pg.multicomp(pvals, method="sidak", alpha=0.05) >>> print(reject, np.round(pvals_corr, 4)) [False True False False True] [0.9688 0.0149 0.8546 0.2424 0.0015] """ @@ -459,8 +459,8 @@ def multicomp(pvals, alpha=0.05, method="holm"): FDR correction of an array of p-values >>> import pingouin as pg - >>> pvals = [.50, .003, .32, .054, .0003] - >>> reject, pvals_corr = pg.multicomp(pvals, method='fdr_bh') + >>> pvals = [0.50, 0.003, 0.32, 0.054, 0.0003] + >>> reject, pvals_corr = pg.multicomp(pvals, method="fdr_bh") >>> print(reject, pvals_corr) [False True False False True] [0.5 0.0075 0.4 0.09 0.0015] @@ -468,7 +468,7 @@ def multicomp(pvals, alpha=0.05, method="holm"): >>> import numpy as np >>> pvals[2] = np.nan - >>> reject, pvals_corr = pg.multicomp(pvals, method='holm') + >>> reject, pvals_corr = pg.multicomp(pvals, method="holm") >>> print(reject, pvals_corr) [False True False False True] [0.5 0.009 nan 0.108 0.0012] """ diff --git a/src/pingouin/multivariate.py b/src/pingouin/multivariate.py index f2ec6da0..f0ec4c99 100644 --- a/src/pingouin/multivariate.py +++ b/src/pingouin/multivariate.py @@ -1,7 +1,9 @@ +from collections import namedtuple + import numpy as np import pandas as pd -from collections import namedtuple -from pingouin.utils import remove_na, _postprocess_dataframe + +from pingouin.utils import _postprocess_dataframe, remove_na __all__ = ["multivariate_normality", "multivariate_ttest", "box_m"] @@ -55,9 +57,9 @@ def multivariate_normality(X, alpha=0.05): Examples -------- >>> import pingouin as pg - >>> data = pg.read_dataset('multivariate') - >>> X = data[['Fever', 'Pressure', 'Aches']] - >>> pg.multivariate_normality(X, alpha=.05) + >>> data = pg.read_dataset("multivariate") + >>> X = data[["Fever", "Pressure", "Aches"]] + >>> pg.multivariate_normality(X, alpha=0.05) HZResults(hz=0.540086101851555, pval=0.7173686509622386, normal=True) """ from scipy.stats import lognorm @@ -175,10 +177,10 @@ def multivariate_ttest(X, Y=None, paired=False): Two-sample independent Hotelling T-squared test >>> import pingouin as pg - >>> data = pg.read_dataset('multivariate') - >>> dvs = ['Fever', 'Pressure', 'Aches'] - >>> X = data[data['Condition'] == 'Drug'][dvs] - >>> Y = data[data['Condition'] == 'Placebo'][dvs] + >>> data = pg.read_dataset("multivariate") + >>> dvs = ["Fever", "Pressure", "Aches"] + >>> X = data[data["Condition"] == "Drug"][dvs] + >>> Y = data[data["Condition"] == "Placebo"][dvs] >>> pg.multivariate_ttest(X, Y) T2 F df1 df2 pval hotelling 4.228679 1.326644 3 32 0.282898 @@ -319,9 +321,8 @@ def box_m(data, dvs, group, alpha=0.001): >>> import pandas as pd >>> import pingouin as pg >>> from scipy.stats import multivariate_normal as mvn - >>> data = pd.DataFrame(mvn.rvs(size=(100, 3), random_state=42), - ... columns=['A', 'B', 'C']) - >>> data['group'] = [1] * 25 + [2] * 25 + [3] * 25 + [4] * 25 + >>> data = pd.DataFrame(mvn.rvs(size=(100, 3), random_state=42), columns=["A", "B", "C"]) + >>> data["group"] = [1] * 25 + [2] * 25 + [3] * 25 + [4] * 25 >>> data.head() A B C group 0 0.496714 -0.138264 0.647689 1 @@ -330,16 +331,15 @@ def box_m(data, dvs, group, alpha=0.001): 3 0.542560 -0.463418 -0.465730 1 4 0.241962 -1.913280 -1.724918 1 - >>> pg.box_m(data, dvs=['A', 'B', 'C'], group='group') + >>> pg.box_m(data, dvs=["A", "B", "C"], group="group") Chi2 df pval equal_cov box 11.634185 18.0 0.865537 True 2. Box M test with 3 dependent variables of 2 groups (unequal sample size) - >>> data = pd.DataFrame(mvn.rvs(size=(30, 2), random_state=42), - ... columns=['A', 'B']) - >>> data['group'] = [1] * 20 + [2] * 10 - >>> pg.box_m(data, dvs=['A', 'B'], group='group') + >>> data = pd.DataFrame(mvn.rvs(size=(30, 2), random_state=42), columns=["A", "B"]) + >>> data["group"] = [1] * 20 + [2] * 10 + >>> pg.box_m(data, dvs=["A", "B"], group="group") Chi2 df pval equal_cov box 0.706709 3.0 0.871625 True """ diff --git a/src/pingouin/nonparametric.py b/src/pingouin/nonparametric.py index 348485ad..909b428c 100644 --- a/src/pingouin/nonparametric.py +++ b/src/pingouin/nonparametric.py @@ -1,9 +1,10 @@ # Author: Raphael Vallat # Date: May 2018 -import scipy import numpy as np import pandas as pd -from pingouin import remove_na, _check_dataframe, _postprocess_dataframe +import scipy + +from pingouin import _check_dataframe, _postprocess_dataframe, remove_na __all__ = [ "mad", @@ -84,7 +85,7 @@ def mad(a, normalize=True, axis=0): Compare with Scipy >= 1.3 >>> from scipy.stats import median_abs_deviation - >>> median_abs_deviation(w, scale='normal', axis=None, nan_policy='omit') + >>> median_abs_deviation(w, scale="normal", axis=None, nan_policy="omit") 1.1607762457644006 """ a = np.asarray(a) @@ -142,7 +143,7 @@ def madmedianrule(a): Examples -------- >>> import pingouin as pg - >>> a = [-1.09, 1., 0.28, -1.51, -0.58, 6.61, -2.43, -0.43] + >>> a = [-1.09, 1.0, 0.28, -1.51, -0.58, 6.61, -2.43, -0.43] >>> pg.madmedianrule(a) array([False, False, False, False, False, True, False, False]) """ @@ -238,29 +239,29 @@ def mwu(x, y, alternative="two-sided", **kwargs): >>> np.random.seed(123) >>> x = np.random.uniform(low=0, high=1, size=20) >>> y = np.random.uniform(low=0.2, high=1.2, size=20) - >>> pg.mwu(x, y, alternative='two-sided') + >>> pg.mwu(x, y, alternative="two-sided") U_val alternative p_val RBC CLES MWU 97.0 two-sided 0.00556 -0.515 0.2425 Compare with SciPy >>> import scipy - >>> scipy.stats.mannwhitneyu(x, y, use_continuity=True, alternative='two-sided') + >>> scipy.stats.mannwhitneyu(x, y, use_continuity=True, alternative="two-sided") MannwhitneyuResult(statistic=97.0, pvalue=0.0055604599321374135) One-sided test - >>> pg.mwu(x, y, alternative='greater') + >>> pg.mwu(x, y, alternative="greater") U_val alternative p_val RBC CLES MWU 97.0 greater 0.997442 -0.515 0.2425 - >>> pg.mwu(x, y, alternative='less') + >>> pg.mwu(x, y, alternative="less") U_val alternative p_val RBC CLES MWU 97.0 less 0.00278 -0.515 0.7575 Passing keyword arguments to :py:func:`scipy.stats.mannwhitneyu`: - >>> pg.mwu(x, y, alternative='two-sided', method='exact') + >>> pg.mwu(x, y, alternative="two-sided", method="exact") U_val alternative p_val RBC CLES MWU 97.0 two-sided 0.004681 -0.515 0.2425 @@ -408,7 +409,7 @@ def wilcoxon(x, y=None, alternative="two-sided", **kwargs): >>> import pingouin as pg >>> x = np.array([20, 22, 19, 20, 22, 18, 24, 20, 19, 24, 26, 13]) >>> y = np.array([38, 37, 33, 29, 14, 12, 20, 22, 17, 25, 26, 16]) - >>> pg.wilcoxon(x, y, alternative='two-sided') + >>> pg.wilcoxon(x, y, alternative="two-sided") W_val alternative p_val RBC CLES Wilcoxon 20.5 two-sided 0.288086 -0.378788 0.395833 @@ -428,17 +429,17 @@ def wilcoxon(x, y=None, alternative="two-sided", **kwargs): The p-value is not exactly similar to Pingouin. This is because Pingouin automatically applies a continuity correction. Disabling it gives the same p-value as scipy: - >>> pg.wilcoxon(x, y, alternative='two-sided', correction=False) + >>> pg.wilcoxon(x, y, alternative="two-sided", correction=False) W_val alternative p_val RBC CLES Wilcoxon 20.5 two-sided 0.288086 -0.378788 0.395833 One-sided test - >>> pg.wilcoxon(x, y, alternative='greater') + >>> pg.wilcoxon(x, y, alternative="greater") W_val alternative p_val RBC CLES Wilcoxon 20.5 greater 0.865723 -0.378788 0.395833 - >>> pg.wilcoxon(x, y, alternative='less') + >>> pg.wilcoxon(x, y, alternative="less") W_val alternative p_val RBC CLES Wilcoxon 20.5 less 0.144043 0.378788 0.604167 """ @@ -540,8 +541,8 @@ def kruskal(data=None, dv=None, between=None, detailed=False): Compute the Kruskal-Wallis H-test for independent samples. >>> from pingouin import kruskal, read_dataset - >>> df = read_dataset('anova') - >>> kruskal(data=df, dv='Pain threshold', between='Hair color') + >>> df = read_dataset("anova") + >>> kruskal(data=df, dv="Pain threshold", between="Hair color") Source ddof1 H p_unc Kruskal Hair color 3 10.58863 0.014172 """ @@ -658,10 +659,26 @@ def friedman(data=None, dv=None, within=None, subject=None, method="chisq"): >>> import pandas as pd >>> import pingouin as pg - >>> df = pd.DataFrame({ - ... 'white': {0: 10, 1: 8, 2: 7, 3: 9, 4: 7, 5: 4, 6: 5, 7: 6, 8: 5, 9: 10, 10: 4, 11: 7}, - ... 'red': {0: 7, 1: 5, 2: 8, 3: 6, 4: 5, 5: 7, 6: 9, 7: 6, 8: 4, 9: 6, 10: 7, 11: 3}, - ... 'rose': {0: 8, 1: 5, 2: 6, 3: 4, 4: 7, 5: 5, 6: 3, 7: 7, 8: 6, 9: 4, 10: 4, 11: 3}}) + >>> df = pd.DataFrame( + ... { + ... "white": { + ... 0: 10, + ... 1: 8, + ... 2: 7, + ... 3: 9, + ... 4: 7, + ... 5: 4, + ... 6: 5, + ... 7: 6, + ... 8: 5, + ... 9: 10, + ... 10: 4, + ... 11: 7, + ... }, + ... "red": {0: 7, 1: 5, 2: 8, 3: 6, 4: 5, 5: 7, 6: 9, 7: 6, 8: 4, 9: 6, 10: 7, 11: 3}, + ... "rose": {0: 8, 1: 5, 2: 6, 3: 4, 4: 7, 5: 5, 6: 3, 7: 7, 8: 6, 9: 4, 10: 4, 11: 3}, + ... } + ... ) >>> pg.friedman(df) Source W ddof1 Q p_unc Friedman Within 0.083333 2 2.0 0.367879 @@ -808,8 +825,8 @@ def cochran(data=None, dv=None, within=None, subject=None): Compute the Cochran Q test for repeated measurements. >>> from pingouin import cochran, read_dataset - >>> df = read_dataset('cochran') - >>> cochran(data=df, dv='Energetic', within='Time', subject='Subject') + >>> df = read_dataset("cochran") + >>> cochran(data=df, dv="Energetic", within="Time", subject="Subject") Source dof Q p_unc cochran Time 2 6.705882 0.034981 diff --git a/src/pingouin/pairwise.py b/src/pingouin/pairwise.py index 6a32f83f..e8fc4c1d 100644 --- a/src/pingouin/pairwise.py +++ b/src/pingouin/pairwise.py @@ -1,16 +1,18 @@ # Author: Raphael Vallat # Date: April 2018 +import warnings +from itertools import combinations, product + import numpy as np import pandas as pd import pandas_flavor as pf -from itertools import combinations, product +from scipy.stats import studentized_range + from pingouin.config import options -from pingouin.parametric import anova -from pingouin.multicomp import multicomp from pingouin.effsize import compute_effsize +from pingouin.multicomp import multicomp +from pingouin.parametric import anova from pingouin.utils import _check_dataframe, _flatten_list, _postprocess_dataframe -from scipy.stats import studentized_range -import warnings __all__ = [ "pairwise_ttests", @@ -203,16 +205,16 @@ def pairwise_tests( >>> import pandas as pd >>> import pingouin as pg - >>> pd.set_option('display.expand_frame_repr', False) - >>> pd.set_option('display.max_columns', 20) - >>> df = pg.read_dataset('mixed_anova.csv') - >>> pg.pairwise_tests(dv='Scores', between='Group', data=df).round(3) + >>> pd.set_option("display.expand_frame_repr", False) + >>> pd.set_option("display.max_columns", 20) + >>> df = pg.read_dataset("mixed_anova.csv") + >>> pg.pairwise_tests(dv="Scores", between="Group", data=df).round(3) Contrast A B Paired Parametric T dof alternative p_unc BF10 hedges 0 Group Control Meditation False True -2.29 178.0 two-sided 0.023 1.813 -0.34 2. One within-subject factor - >>> post_hocs = pg.pairwise_tests(dv='Scores', within='Time', subject='Subject', data=df) + >>> post_hocs = pg.pairwise_tests(dv="Scores", within="Time", subject="Subject", data=df) >>> post_hocs.round(3) Contrast A B Paired Parametric T dof alternative p_unc BF10 hedges 0 Time August January True True -1.740 59.0 two-sided 0.087 0.582 -0.328 @@ -221,8 +223,9 @@ def pairwise_tests( 3. Non-parametric pairwise paired test (wilcoxon) - >>> pg.pairwise_tests(dv='Scores', within='Time', subject='Subject', - ... data=df, parametric=False).round(3) + >>> pg.pairwise_tests( + ... dv="Scores", within="Time", subject="Subject", data=df, parametric=False + ... ).round(3) Contrast A B Paired Parametric W_val alternative p_unc hedges 0 Time August January True False 716.0 two-sided 0.144 -0.328 1 Time August June True False 564.0 two-sided 0.010 -0.483 @@ -230,8 +233,9 @@ def pairwise_tests( 4. Mixed design (within and between) with bonferroni-corrected p-values - >>> posthocs = pg.pairwise_tests(dv='Scores', within='Time', subject='Subject', - ... between='Group', padjust='bonf', data=df) + >>> posthocs = pg.pairwise_tests( + ... dv="Scores", within="Time", subject="Subject", between="Group", padjust="bonf", data=df + ... ) >>> posthocs.round(3) Contrast Time A B Paired Parametric T dof alternative p_unc p_corr p_adjust BF10 hedges 0 Time - August January True True -1.740 59.0 two-sided 0.087 0.261 bonf 0.582 -0.328 @@ -244,7 +248,7 @@ def pairwise_tests( 5. Two between-subject factors. The order of the ``between`` factors matters! - >>> pg.pairwise_tests(dv='Scores', between=['Group', 'Time'], data=df).round(3) + >>> pg.pairwise_tests(dv="Scores", between=["Group", "Time"], data=df).round(3) Contrast Group A B Paired Parametric T dof alternative p_unc BF10 hedges 0 Group - Control Meditation False True -2.290 178.0 two-sided 0.023 1.813 -0.340 1 Time - August January False True -1.806 118.0 two-sided 0.074 0.839 -0.328 @@ -259,16 +263,17 @@ def pairwise_tests( 6. Same but without the interaction, and using a directional test - >>> df.pairwise_tests(dv='Scores', between=['Group', 'Time'], alternative="less", - ... interaction=False).round(3) + >>> df.pairwise_tests( + ... dv="Scores", between=["Group", "Time"], alternative="less", interaction=False + ... ).round(3) Contrast A B Paired Parametric T dof alternative p_unc hedges 0 Group Control Meditation False True -2.290 178.0 less 0.012 -0.340 1 Time August January False True -1.806 118.0 less 0.037 -0.328 2 Time August June False True -2.660 118.0 less 0.004 -0.483 3 Time January June False True -0.934 118.0 less 0.176 -0.170 """ + from .nonparametric import mwu, wilcoxon from .parametric import ttest - from .nonparametric import wilcoxon, mwu # Safety checks data = _check_dataframe( @@ -660,8 +665,8 @@ def ptests( >>> import pandas as pd >>> import pingouin as pg >>> # Load an example dataset of personality dimensions - >>> df = pg.read_dataset('pairwise_corr').iloc[:30, 1:] - >>> df.columns = ["N", "E", "O", 'A', "C"] + >>> df = pg.read_dataset("pairwise_corr").iloc[:30, 1:] + >>> df.columns = ["N", "E", "O", "A", "C"] >>> # Add some missing values >>> df.iloc[[2, 5, 20], 2] = np.nan >>> df.iloc[[1, 4, 10], 3] = np.nan @@ -720,6 +725,7 @@ def ptests( C -4.251 3.595 3.785 3.765 - """ from itertools import combinations + from numpy import format_float_positional as ffp from scipy.stats import ttest_ind, ttest_rel @@ -873,8 +879,8 @@ def pairwise_tukey(data=None, dv=None, between=None, effsize="hedges"): Pairwise Tukey post-hocs on the Penguins dataset. >>> import pingouin as pg - >>> df = pg.read_dataset('penguins') - >>> df.pairwise_tukey(dv='body_mass_g', between='species').round(3) + >>> df = pg.read_dataset("penguins") + >>> df.pairwise_tukey(dv="body_mass_g", between="species").round(3) A B mean(A) mean(B) diff se T p_tukey hedges 0 Adelie Chinstrap 3700.662 3733.088 -32.426 67.512 -0.480 0.881 -0.074 1 Adelie Gentoo 3700.662 5076.016 -1375.354 56.148 -24.495 0.000 -2.860 @@ -1038,9 +1044,8 @@ def pairwise_gameshowell(data=None, dv=None, between=None, effsize="hedges"): Pairwise Games-Howell post-hocs on the Penguins dataset. >>> import pingouin as pg - >>> df = pg.read_dataset('penguins') - >>> pg.pairwise_gameshowell(data=df, dv='body_mass_g', - ... between='species').round(3) + >>> df = pg.read_dataset("penguins") + >>> pg.pairwise_gameshowell(data=df, dv="body_mass_g", between="species").round(3) A B mean(A) mean(B) diff se T df pval hedges 0 Adelie Chinstrap 3700.662 3733.088 -32.426 59.706 -0.543 152.455 0.85 -0.074 1 Adelie Gentoo 3700.662 5076.016 -1375.354 58.811 -23.386 249.643 0.00 -2.860 @@ -1242,10 +1247,10 @@ def pairwise_corr( >>> import pandas as pd >>> import pingouin as pg - >>> pd.set_option('display.expand_frame_repr', False) - >>> pd.set_option('display.max_columns', 20) - >>> data = pg.read_dataset('pairwise_corr').iloc[:, 1:] - >>> pg.pairwise_corr(data, method='spearman', alternative='greater', padjust='bonf').round(3) + >>> pd.set_option("display.expand_frame_repr", False) + >>> pd.set_option("display.max_columns", 20) + >>> data = pg.read_dataset("pairwise_corr").iloc[:, 1:] + >>> pg.pairwise_corr(data, method="spearman", alternative="greater", padjust="bonf").round(3) X Y method alternative n r CI95 p_unc p_corr p_adjust power 0 Neuroticism Extraversion spearman greater 500 -0.325 [-0.39, 1.0] 1.000 1.000 bonf 0.000 1 Neuroticism Openness spearman greater 500 -0.028 [-0.1, 1.0] 0.735 1.000 bonf 0.012 @@ -1260,8 +1265,9 @@ def pairwise_corr( 2. Robust two-sided biweight midcorrelation with uncorrected p-values - >>> pcor = pg.pairwise_corr(data, columns=['Openness', 'Extraversion', - ... 'Neuroticism'], method='bicor') + >>> pcor = pg.pairwise_corr( + ... data, columns=["Openness", "Extraversion", "Neuroticism"], method="bicor" + ... ) >>> pcor.round(3) X Y method alternative n r CI95 p_unc power 0 Openness Extraversion bicor two-sided 500 0.247 [0.16, 0.33] 0.000 1.000 @@ -1270,7 +1276,7 @@ def pairwise_corr( 3. One-versus-all pairwise correlations - >>> pg.pairwise_corr(data, columns=['Neuroticism']).round(3) + >>> pg.pairwise_corr(data, columns=["Neuroticism"]).round(3) X Y method alternative n r CI95 p_unc BF10 power 0 Neuroticism Extraversion pearson two-sided 500 -0.350 [-0.42, -0.27] 0.000 6.765e+12 1.000 1 Neuroticism Openness pearson two-sided 500 -0.010 [-0.1, 0.08] 0.817 0.058 0.056 @@ -1279,7 +1285,7 @@ def pairwise_corr( 4. Pairwise correlations between two lists of columns (cartesian product) - >>> columns = [['Neuroticism', 'Extraversion'], ['Openness']] + >>> columns = [["Neuroticism", "Extraversion"], ["Openness"]] >>> pg.pairwise_corr(data, columns).round(3) X Y method alternative n r CI95 p_unc BF10 power 0 Neuroticism Openness pearson two-sided 500 -0.010 [-0.1, 0.08] 0.817 0.058 0.056 @@ -1287,11 +1293,11 @@ def pairwise_corr( 5. As a Pandas method - >>> pcor = data.pairwise_corr(covar='Neuroticism', method='spearman') + >>> pcor = data.pairwise_corr(covar="Neuroticism", method="spearman") 6. Pairwise partial correlation - >>> pg.pairwise_corr(data, covar=['Neuroticism', 'Openness']) + >>> pg.pairwise_corr(data, covar=["Neuroticism", "Openness"]) X Y method covar alternative n r CI95 p_unc 0 Extraversion Agreeableness pearson ['Neuroticism', 'Openness'] two-sided 500 -0.038737 [-0.13, 0.05] 0.388361 1 Extraversion Conscientiousness pearson ['Neuroticism', 'Openness'] two-sided 500 -0.071427 [-0.16, 0.02] 0.111389 @@ -1299,7 +1305,7 @@ def pairwise_corr( 7. Pairwise partial correlation matrix using :py:func:`pingouin.pcorr` - >>> data[['Neuroticism', 'Openness', 'Extraversion']].pcorr().round(3) + >>> data[["Neuroticism", "Openness", "Extraversion"]].pcorr().round(3) Neuroticism Openness Extraversion Neuroticism 1.000 0.092 -0.360 Openness 0.092 1.000 0.281 @@ -1307,7 +1313,7 @@ def pairwise_corr( 8. Correlation matrix with p-values using :py:func:`pingouin.rcorr` - >>> data[['Neuroticism', 'Openness', 'Extraversion']].rcorr() + >>> data[["Neuroticism", "Openness", "Extraversion"]].rcorr() Neuroticism Openness Extraversion Neuroticism - *** Openness -0.01 - *** diff --git a/src/pingouin/parametric.py b/src/pingouin/parametric.py index 0329771c..4369b78c 100644 --- a/src/pingouin/parametric.py +++ b/src/pingouin/parametric.py @@ -1,18 +1,20 @@ # Author: Raphael Vallat import warnings from collections.abc import Iterable + import numpy as np import pandas as pd -from scipy.stats import f import pandas_flavor as pf +from scipy.stats import f + from pingouin import ( _check_dataframe, - remove_na, _flatten_list, + _postprocess_dataframe, bayesfactor_ttest, epsilon, + remove_na, sphericity, - _postprocess_dataframe, ) __all__ = ["ttest", "rm_anova", "anova", "welch_anova", "mixed_anova", "ancova"] @@ -150,14 +152,14 @@ def ttest(x, y, paired=False, alternative="two-sided", correction="auto", r=0.70 2. One sided paired T-test. >>> pre = [5.5, 2.4, 6.8, 9.6, 4.2] - >>> post = [6.4, 3.4, 6.4, 11., 4.8] - >>> ttest(pre, post, paired=True, alternative='less').round(2) + >>> post = [6.4, 3.4, 6.4, 11.0, 4.8] + >>> ttest(pre, post, paired=True, alternative="less").round(2) T dof alternative p_val CI95 cohen_d power T_test -2.31 4 less 0.04 [-inf, -0.05] 0.25 0.12 Now testing the opposite alternative hypothesis - >>> ttest(pre, post, paired=True, alternative='greater').round(2) + >>> ttest(pre, post, paired=True, alternative="greater").round(2) T dof alternative p_val CI95 cohen_d power T_test -2.31 4 greater 0.96 [-1.35, inf] 0.25 0.02 @@ -165,7 +167,7 @@ def ttest(x, y, paired=False, alternative="two-sided", correction="auto", r=0.70 >>> import numpy as np >>> pre = [5.5, 2.4, np.nan, 9.6, 4.2] - >>> post = [6.4, 3.4, 6.4, 11., 4.8] + >>> post = [6.4, 3.4, 6.4, 11.0, 4.8] >>> ttest(pre, post, paired=True).round(3) T dof alternative p_val CI95 cohen_d BF10 power T_test -5.902 3 two-sided 0.01 [-1.5, -0.45] 0.306 7.169 0.073 @@ -205,14 +207,14 @@ def ttest(x, y, paired=False, alternative="two-sided", correction="auto", r=0.70 >>> np.round(ttest_ind(x, y, equal_var=True), 6) # T value and p-value array([1.971859, 0.057056]) """ - from scipy.stats import t, ttest_rel, ttest_ind, ttest_1samp + from scipy.stats import t, ttest_1samp, ttest_ind, ttest_rel try: # pragma: no cover - from scipy.stats._stats_py import _unequal_var_ttest_denom, _equal_var_ttest_denom + from scipy.stats._stats_py import _equal_var_ttest_denom, _unequal_var_ttest_denom except ImportError: # pragma: no cover # Fallback for scipy<1.8.0 - from scipy.stats.stats import _unequal_var_ttest_denom, _equal_var_ttest_denom - from pingouin import power_ttest, power_ttest2n, compute_effsize + from scipy.stats.stats import _equal_var_ttest_denom, _unequal_var_ttest_denom + from pingouin import compute_effsize, power_ttest, power_ttest2n # Check arguments assert alternative in [ @@ -474,7 +476,7 @@ def rm_anova( 1. One-way repeated measures ANOVA using a wide-format dataset >>> import pingouin as pg - >>> data = pg.read_dataset('rm_anova_wide') + >>> data = pg.read_dataset("rm_anova_wide") >>> pg.rm_anova(data) Source ddof1 ddof2 F p_unc ng2 eps 0 Within 3 24 5.200652 0.006557 0.346392 0.694329 @@ -486,9 +488,15 @@ def rm_anova( means that we want to get the partial eta-squared effect size instead of the default (generalized) eta-squared. - >>> df = pg.read_dataset('rm_anova') - >>> aov = pg.rm_anova(dv='DesireToKill', within='Disgustingness', - ... subject='Subject', data=df, detailed=True, effsize="np2") + >>> df = pg.read_dataset("rm_anova") + >>> aov = pg.rm_anova( + ... dv="DesireToKill", + ... within="Disgustingness", + ... subject="Subject", + ... data=df, + ... detailed=True, + ... effsize="np2", + ... ) >>> aov.round(3) Source SS DF MS F p_unc np2 eps 0 Disgustingness 27.485 1 27.485 12.044 0.001 0.116 1.0 @@ -496,12 +504,16 @@ def rm_anova( 3. Two-way repeated-measures ANOVA - >>> aov = pg.rm_anova(dv='DesireToKill', within=['Disgustingness', 'Frighteningness'], - ... subject='Subject', data=df) + >>> aov = pg.rm_anova( + ... dv="DesireToKill", + ... within=["Disgustingness", "Frighteningness"], + ... subject="Subject", + ... data=df, + ... ) 4. As a :py:class:`pandas.DataFrame` method - >>> df.rm_anova(dv='DesireToKill', within='Disgustingness', subject='Subject', detailed=False) + >>> df.rm_anova(dv="DesireToKill", within="Disgustingness", subject="Subject", detailed=False) Source ddof1 ddof2 F p_unc ng2 eps 0 Disgustingness 1 92 12.043878 0.000793 0.025784 1.0 """ @@ -908,9 +920,8 @@ def anova(data=None, dv=None, between=None, ss_type=2, detailed=False, effsize=" One-way ANOVA >>> import pingouin as pg - >>> df = pg.read_dataset('anova') - >>> aov = pg.anova(dv='Pain threshold', between='Hair color', data=df, - ... detailed=True) + >>> df = pg.read_dataset("anova") + >>> aov = pg.anova(dv="Pain threshold", between="Hair color", data=df, detailed=True) >>> aov.round(3) Source SS DF MS F p_unc np2 0 Hair color 1360.726 3 453.575 6.791 0.004 0.576 @@ -921,14 +932,13 @@ def anova(data=None, dv=None, between=None, ss_type=2, detailed=False, effsize=" a method (= built-in function) of our pandas dataframe. In that case, we don't have to specify ``data`` anymore. - >>> df.anova(dv='Pain threshold', between='Hair color', detailed=False, - ... effsize='n2') + >>> df.anova(dv="Pain threshold", between="Hair color", detailed=False, effsize="n2") Source ddof1 ddof2 F p_unc n2 0 Hair color 3 15 6.791407 0.004114 0.575962 Two-way ANOVA with balanced design - >>> data = pg.read_dataset('anova2') + >>> data = pg.read_dataset("anova2") >>> data.anova(dv="Yield", between=["Blend", "Crop"]).round(3) Source SS DF MS F p_unc np2 0 Blend 2.042 1 2.042 0.004 0.952 0.000 @@ -938,9 +948,8 @@ def anova(data=None, dv=None, between=None, ss_type=2, detailed=False, effsize=" Two-way ANOVA with unbalanced design (requires statsmodels) - >>> data = pg.read_dataset('anova2_unbalanced') - >>> data.anova(dv="Scores", between=["Diet", "Exercise"], - ... effsize="n2").round(3) + >>> data = pg.read_dataset("anova2_unbalanced") + >>> data.anova(dv="Scores", between=["Diet", "Exercise"], effsize="n2").round(3) Source SS DF MS F p_unc n2 0 Diet 390.625 1.0 390.625 7.423 0.034 0.433 1 Exercise 180.625 1.0 180.625 3.432 0.113 0.200 @@ -949,9 +958,8 @@ def anova(data=None, dv=None, between=None, ss_type=2, detailed=False, effsize=" Three-way ANOVA, type 3 sums of squares (requires statsmodels) - >>> data = pg.read_dataset('anova3') - >>> data.anova(dv='Cholesterol', between=['Sex', 'Risk', 'Drug'], - ... ss_type=3).round(3) + >>> data = pg.read_dataset("anova3") + >>> data.anova(dv="Cholesterol", between=["Sex", "Risk", "Drug"], ss_type=3).round(3) Source SS DF MS F p_unc np2 0 Sex 2.075 1.0 2.075 2.462 0.123 0.049 1 Risk 11.332 1.0 11.332 13.449 0.001 0.219 @@ -1322,8 +1330,8 @@ def welch_anova(data=None, dv=None, between=None): 1. One-way Welch ANOVA on the pain threshold dataset. >>> from pingouin import welch_anova, read_dataset - >>> df = read_dataset('anova') - >>> aov = welch_anova(dv='Pain threshold', between='Hair color', data=df) + >>> df = read_dataset("anova") + >>> aov = welch_anova(dv="Pain threshold", between="Hair color", data=df) >>> aov Source ddof1 ddof2 F p_unc np2 0 Hair color 3 8.329841 5.890115 0.018813 0.575962 @@ -1446,9 +1454,8 @@ def mixed_anova( Compute a two-way mixed model ANOVA. >>> from pingouin import mixed_anova, read_dataset - >>> df = read_dataset('mixed_anova') - >>> aov = mixed_anova(dv='Scores', between='Group', - ... within='Time', subject='Subject', data=df) + >>> df = read_dataset("mixed_anova") + >>> aov = mixed_anova(dv="Scores", between="Group", within="Time", subject="Subject", data=df) >>> aov.round(3) Source SS DF1 DF2 MS F p_unc np2 eps 0 Group 5.460 1 58 5.460 5.052 0.028 0.080 NaN @@ -1459,8 +1466,9 @@ def mixed_anova( can also apply this function directly as a method of the dataframe, in which case we do not need to specify ``data=df`` anymore. - >>> df.mixed_anova(dv='Scores', between='Group', within='Time', - ... subject='Subject', effsize="ng2").round(3) + >>> df.mixed_anova( + ... dv="Scores", between="Group", within="Time", subject="Subject", effsize="ng2" + ... ).round(3) Source SS DF1 DF2 MS F p_unc ng2 eps 0 Group 5.460 1 58 5.460 5.052 0.028 0.031 NaN 1 Time 7.628 2 116 3.814 4.027 0.020 0.042 0.999 @@ -1664,8 +1672,8 @@ def ancova(data=None, dv=None, between=None, covar=None, effsize="np2"): and family income as a covariate. >>> from pingouin import ancova, read_dataset - >>> df = read_dataset('ancova') - >>> ancova(data=df, dv='Scores', covar='Income', between='Method') + >>> df = read_dataset("ancova") + >>> ancova(data=df, dv="Scores", covar="Income", between="Method") Source SS DF F p_unc np2 0 Method 571.029883 3 3.336482 0.031940 0.244077 1 Income 1678.352687 1 29.419438 0.000006 0.486920 @@ -1674,8 +1682,7 @@ def ancova(data=None, dv=None, between=None, covar=None, effsize="np2"): 2. Evaluate the reading scores of students with different teaching method and family income + BMI as a covariate. - >>> ancova(data=df, dv='Scores', covar=['Income', 'BMI'], between='Method', - ... effsize="n2") + >>> ancova(data=df, dv="Scores", covar=["Income", "BMI"], between="Method", effsize="n2") Source SS DF F p_unc n2 0 Method 552.284043 3 3.232550 0.036113 0.141802 1 Income 1573.952434 1 27.637304 0.000011 0.404121 diff --git a/src/pingouin/plotting.py b/src/pingouin/plotting.py index 910d8763..4f9f19af 100644 --- a/src/pingouin/plotting.py +++ b/src/pingouin/plotting.py @@ -5,12 +5,12 @@ - Nicolas Legrand """ +import matplotlib.pyplot as plt +import matplotlib.transforms as transforms import numpy as np import pandas as pd import seaborn as sns from scipy import stats -import matplotlib.pyplot as plt -import matplotlib.transforms as transforms # Set default Seaborn preferences (disabled Pingouin >= 0.3.4) # See https://github.com/raphaelvallat/pingouin/issues/85 @@ -109,7 +109,7 @@ def plot_blandaltman( >>> import pingouin as pg >>> df = pg.read_dataset("blandaltman") - >>> ax = pg.plot_blandaltman(df['A'], df['B']) + >>> ax = pg.plot_blandaltman(df["A"], df["B"]) >>> plt.tight_layout() """ # Safety check @@ -298,7 +298,7 @@ def qqplot(x, dist="norm", sparams=(), confidence=0.95, square=True, ax=None, ** >>> import pingouin as pg >>> np.random.seed(123) >>> x = np.random.normal(size=50) - >>> ax = pg.qqplot(x, dist='norm') + >>> ax = pg.qqplot(x, dist="norm") Two Q-Q plots using two separate axes: @@ -311,8 +311,8 @@ def qqplot(x, dist="norm", sparams=(), confidence=0.95, square=True, ax=None, ** >>> x = np.random.normal(size=50) >>> x_exp = np.random.exponential(size=50) >>> fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 4)) - >>> ax1 = pg.qqplot(x, dist='norm', ax=ax1, confidence=False) - >>> ax2 = pg.qqplot(x_exp, dist='expon', ax=ax2) + >>> ax1 = pg.qqplot(x, dist="norm", ax=ax1, confidence=False) + >>> ax2 = pg.qqplot(x_exp, dist="expon", ax=ax2) Using custom location / scale parameters as well as another Seaborn style @@ -325,8 +325,8 @@ def qqplot(x, dist="norm", sparams=(), confidence=0.95, square=True, ax=None, ** >>> np.random.seed(123) >>> x = np.random.normal(size=50) >>> mean, std = 0, 0.8 - >>> sns.set_style('darkgrid') - >>> ax = pg.qqplot(x, dist='norm', sparams=(mean, std)) + >>> sns.set_style("darkgrid") + >>> ax = pg.qqplot(x, dist="norm", sparams=(mean, std)) """ # Update default kwargs with specified inputs _scatter_kwargs = {"marker": "o", "color": "blue"} @@ -482,9 +482,9 @@ def plot_paired( .. plot:: >>> import pingouin as pg - >>> df = pg.read_dataset('mixed_anova').query("Time != 'January'") + >>> df = pg.read_dataset("mixed_anova").query("Time != 'January'") >>> df = df.query("Group == 'Meditation' and Subject > 40") - >>> ax = pg.plot_paired(data=df, dv='Scores', within='Time', subject='Subject') + >>> ax = pg.plot_paired(data=df, dv="Scores", within="Time", subject="Subject") Paired plot on an existing axis (no boxplot and uniform color): @@ -492,12 +492,18 @@ def plot_paired( >>> import pingouin as pg >>> import matplotlib.pyplot as plt - >>> df = pg.read_dataset('mixed_anova').query("Time != 'January'") + >>> df = pg.read_dataset("mixed_anova").query("Time != 'January'") >>> df = df.query("Group == 'Meditation' and Subject > 40") >>> fig, ax1 = plt.subplots(1, 1, figsize=(5, 4)) - >>> pg.plot_paired(data=df, dv='Scores', within='Time', - ... subject='Subject', ax=ax1, boxplot=False, - ... colors=['grey', 'grey', 'grey']) # doctest: +SKIP + >>> pg.plot_paired( + ... data=df, + ... dv="Scores", + ... within="Time", + ... subject="Subject", + ... ax=ax1, + ... boxplot=False, + ... colors=["grey", "grey", "grey"], + ... ) # doctest: +SKIP Horizontal paired plot with three unique within-levels: @@ -505,20 +511,22 @@ def plot_paired( >>> import pingouin as pg >>> import matplotlib.pyplot as plt - >>> df = pg.read_dataset('mixed_anova').query("Group == 'Meditation'") + >>> df = pg.read_dataset("mixed_anova").query("Group == 'Meditation'") >>> # df = df.query("Group == 'Meditation' and Subject > 40") - >>> pg.plot_paired(data=df, dv='Scores', within='Time', - ... subject='Subject', orient='h') # doctest: +SKIP + >>> pg.plot_paired( + ... data=df, dv="Scores", within="Time", subject="Subject", orient="h" + ... ) # doctest: +SKIP With the boxplot on the foreground: .. plot:: >>> import pingouin as pg - >>> df = pg.read_dataset('mixed_anova').query("Time != 'January'") + >>> df = pg.read_dataset("mixed_anova").query("Time != 'January'") >>> df = df.query("Group == 'Control'") - >>> ax = pg.plot_paired(data=df, dv='Scores', within='Time', - ... subject='Subject', boxplot_in_front=True) + >>> ax = pg.plot_paired( + ... data=df, dv="Scores", within="Time", subject="Subject", boxplot_in_front=True + ... ) """ from pingouin.utils import _check_dataframe @@ -741,17 +749,24 @@ def plot_shift( >>> import pingouin as pg >>> import matplotlib.pyplot as plt >>> data = pg.read_dataset("pairwise_corr") - >>> fig = pg.plot_shift(data["Neuroticism"], data["Conscientiousness"], paired=True, - ... n_boot=2000, percentiles=[25, 50, 75], show_median=False, seed=456, - ... violin=False) + >>> fig = pg.plot_shift( + ... data["Neuroticism"], + ... data["Conscientiousness"], + ... paired=True, + ... n_boot=2000, + ... percentiles=[25, 50, 75], + ... show_median=False, + ... seed=456, + ... violin=False, + ... ) >>> fig.axes[0].set_xlabel("Groups") >>> fig.axes[0].set_ylabel("Values", size=15) >>> fig.axes[0].set_title("Comparing Neuroticism and Conscientiousness", size=15) >>> fig.axes[1].set_xlabel("Neuroticism quantiles", size=12) >>> plt.tight_layout() """ - from pingouin.regression import _bias_corrected_ci from pingouin.nonparametric import harrelldavis as hd + from pingouin.regression import _bias_corrected_ci # Safety check x = np.asarray(x) @@ -963,8 +978,8 @@ def plot_rm_corr( .. plot:: >>> import pingouin as pg - >>> df = pg.read_dataset('rm_corr') - >>> g = pg.plot_rm_corr(data=df, x='pH', y='PacO2', subject='Subject') + >>> df = pg.read_dataset("rm_corr") + >>> g = pg.plot_rm_corr(data=df, x="pH", y="PacO2", subject="Subject") With some tweakings @@ -972,12 +987,16 @@ def plot_rm_corr( >>> import pingouin as pg >>> import seaborn as sns - >>> df = pg.read_dataset('rm_corr') - >>> sns.set_theme(style='darkgrid', font_scale=1.2) - >>> g = pg.plot_rm_corr(data=df, x='pH', y='PacO2', - ... subject='Subject', legend=True, - ... kwargs_facetgrid=dict(height=4.5, aspect=1.5, - ... palette='Spectral')) + >>> df = pg.read_dataset("rm_corr") + >>> sns.set_theme(style="darkgrid", font_scale=1.2) + >>> g = pg.plot_rm_corr( + ... data=df, + ... x="pH", + ... y="PacO2", + ... subject="Subject", + ... legend=True, + ... kwargs_facetgrid=dict(height=4.5, aspect=1.5, palette="Spectral"), + ... ) """ # Check that stasmodels is installed from pingouin.utils import _is_statsmodels_installed @@ -1080,20 +1099,25 @@ def plot_circmean( >>> import pingouin as pg >>> import matplotlib.pyplot as plt >>> _, ax = plt.subplots(1, 1, figsize=(3, 3)) - >>> ax = pg.plot_circmean([0.05, -0.8, 1.2, 0.8, 0.5, -0.3, 0.3, 0.7], - ... kwargs_markers=dict(color='k', mfc='k'), - ... kwargs_arrow=dict(ec='k', fc='k'), ax=ax) + >>> ax = pg.plot_circmean( + ... [0.05, -0.8, 1.2, 0.8, 0.5, -0.3, 0.3, 0.7], + ... kwargs_markers=dict(color="k", mfc="k"), + ... kwargs_arrow=dict(ec="k", fc="k"), + ... ax=ax, + ... ) .. plot:: >>> import pingouin as pg >>> import seaborn as sns - >>> sns.set_theme(font_scale=1.5, style='white') - >>> ax = pg.plot_circmean([0.8, 1.5, 3.14, 5.2, 6.1, 2.8, 2.6, 3.2], - ... kwargs_markers=dict(marker="None")) + >>> sns.set_theme(font_scale=1.5, style="white") + >>> ax = pg.plot_circmean( + ... [0.8, 1.5, 3.14, 5.2, 6.1, 2.8, 2.6, 3.2], kwargs_markers=dict(marker="None") + ... ) """ from matplotlib.patches import Circle - from .circular import circ_r, circ_mean + + from .circular import circ_mean, circ_r # Sanity checks angles = np.asarray(angles) diff --git a/src/pingouin/power.py b/src/pingouin/power.py index 2a74a2be..bc4e4fc1 100644 --- a/src/pingouin/power.py +++ b/src/pingouin/power.py @@ -1,6 +1,7 @@ # Author: Raphael Vallat # Date: April 2018 import warnings + import numpy as np from scipy import stats from scipy.optimize import brenth @@ -95,31 +96,31 @@ def power_ttest( 1. Compute power of a one-sample T-test given ``d``, ``n`` and ``alpha`` >>> from pingouin import power_ttest - >>> print('power: %.4f' % power_ttest(d=0.5, n=20, contrast='one-sample')) + >>> print("power: %.4f" % power_ttest(d=0.5, n=20, contrast="one-sample")) power: 0.5645 2. Compute required sample size given ``d``, ``power`` and ``alpha`` - >>> print('n: %.4f' % power_ttest(d=0.5, power=0.80, alternative='greater')) + >>> print("n: %.4f" % power_ttest(d=0.5, power=0.80, alternative="greater")) n: 50.1508 3. Compute achieved ``d`` given ``n``, ``power`` and ``alpha`` level - >>> print('d: %.4f' % power_ttest(n=20, power=0.80, alpha=0.05, contrast='paired')) + >>> print("d: %.4f" % power_ttest(n=20, power=0.80, alpha=0.05, contrast="paired")) d: 0.6604 4. Compute achieved alpha level given ``d``, ``n`` and ``power`` - >>> print('alpha: %.4f' % power_ttest(d=0.5, n=20, power=0.80, alpha=None)) + >>> print("alpha: %.4f" % power_ttest(d=0.5, n=20, power=0.80, alpha=None)) alpha: 0.4430 5. One-sided tests >>> from pingouin import power_ttest - >>> print('power: %.4f' % power_ttest(d=0.5, n=20, alternative='greater')) + >>> print("power: %.4f" % power_ttest(d=0.5, n=20, alternative="greater")) power: 0.4634 - >>> print('power: %.4f' % power_ttest(d=0.5, n=20, alternative='less')) + >>> print("power: %.4f" % power_ttest(d=0.5, n=20, alternative="less")) power: 0.0007 """ # Check the number of arguments that are None @@ -278,17 +279,17 @@ def power_ttest2n(nx, ny, d=None, power=None, alpha=0.05, alternative="two-sided 1. Compute achieved power of a T-test given ``d``, ``n`` and ``alpha`` >>> from pingouin import power_ttest2n - >>> print('power: %.4f' % power_ttest2n(nx=20, ny=15, d=0.5, alternative='greater')) + >>> print("power: %.4f" % power_ttest2n(nx=20, ny=15, d=0.5, alternative="greater")) power: 0.4164 2. Compute achieved ``d`` given ``n``, ``power`` and ``alpha`` level - >>> print('d: %.4f' % power_ttest2n(nx=20, ny=15, power=0.80, alpha=0.05)) + >>> print("d: %.4f" % power_ttest2n(nx=20, ny=15, power=0.80, alpha=0.05)) d: 0.9859 3. Compute achieved alpha level given ``d``, ``n`` and ``power`` - >>> print('alpha: %.4f' % power_ttest2n(nx=20, ny=15, d=0.5, power=0.80, alpha=None)) + >>> print("alpha: %.4f" % power_ttest2n(nx=20, ny=15, d=0.5, power=0.80, alpha=None)) alpha: 0.5000 """ # Check the number of arguments that are None @@ -444,27 +445,27 @@ def power_anova(eta_squared=None, k=None, n=None, power=None, alpha=0.05): 1. Compute achieved power >>> from pingouin import power_anova - >>> print('power: %.4f' % power_anova(eta_squared=0.1, k=3, n=20)) + >>> print("power: %.4f" % power_anova(eta_squared=0.1, k=3, n=20)) power: 0.6082 2. Compute required number of groups - >>> print('k: %.4f' % power_anova(eta_squared=0.1, n=20, power=0.80)) + >>> print("k: %.4f" % power_anova(eta_squared=0.1, n=20, power=0.80)) k: 6.0944 3. Compute required sample size - >>> print('n: %.4f' % power_anova(eta_squared=0.1, k=3, power=0.80)) + >>> print("n: %.4f" % power_anova(eta_squared=0.1, k=3, power=0.80)) n: 29.9256 4. Compute achieved effect size - >>> print('eta-squared: %.4f' % power_anova(n=20, k=4, power=0.80, alpha=0.05)) + >>> print("eta-squared: %.4f" % power_anova(n=20, k=4, power=0.80, alpha=0.05)) eta-squared: 0.1255 5. Compute achieved alpha (significance) - >>> print('alpha: %.4f' % power_anova(eta_squared=0.1, n=20, k=4, power=0.80, alpha=None)) + >>> print("alpha: %.4f" % power_anova(eta_squared=0.1, n=20, k=4, power=0.80, alpha=None)) alpha: 0.1085 """ # Check the number of arguments that are None @@ -613,27 +614,27 @@ def power_rm_anova(eta_squared=None, m=None, n=None, power=None, alpha=0.05, cor 1. Compute achieved power >>> from pingouin import power_rm_anova - >>> print('power: %.4f' % power_rm_anova(eta_squared=0.1, m=3, n=20)) + >>> print("power: %.4f" % power_rm_anova(eta_squared=0.1, m=3, n=20)) power: 0.8913 2. Compute required number of groups - >>> print('m: %.4f' % power_rm_anova(eta_squared=0.1, n=20, power=0.90)) + >>> print("m: %.4f" % power_rm_anova(eta_squared=0.1, n=20, power=0.90)) m: 3.1347 3. Compute required sample size - >>> print('n: %.4f' % power_rm_anova(eta_squared=0.1, m=3, power=0.80)) + >>> print("n: %.4f" % power_rm_anova(eta_squared=0.1, m=3, power=0.80)) n: 15.9979 4. Compute achieved effect size - >>> print('eta-squared: %.4f' % power_rm_anova(n=20, m=4, power=0.80, alpha=0.05)) + >>> print("eta-squared: %.4f" % power_rm_anova(n=20, m=4, power=0.80, alpha=0.05)) eta-squared: 0.0680 5. Compute achieved alpha (significance) - >>> print('alpha: %.4f' % power_rm_anova(eta_squared=0.1, n=20, m=4, power=0.80, alpha=None)) + >>> print("alpha: %.4f" % power_rm_anova(eta_squared=0.1, n=20, m=4, power=0.80, alpha=None)) alpha: 0.0081 Let's take a more concrete example. First, we'll load a repeated measures @@ -641,7 +642,7 @@ def power_rm_anova(eta_squared=None, m=None, n=None, power=None, alpha=0.05, cor each column a successive repeated measurements (e.g t=0, t=1, ...). >>> import pingouin as pg - >>> data = pg.read_dataset('rm_anova_wide') + >>> data = pg.read_dataset("rm_anova_wide") >>> data.head() Before 1 week 2 week 3 week 0 4.3 5.3 4.8 6.3 @@ -810,30 +811,30 @@ def power_corr(r=None, n=None, power=None, alpha=0.05, alternative="two-sided"): 1. Compute achieved power given ``r``, ``n`` and ``alpha`` >>> from pingouin import power_corr - >>> print('power: %.4f' % power_corr(r=0.5, n=20)) + >>> print("power: %.4f" % power_corr(r=0.5, n=20)) power: 0.6379 2. Same but one-sided test - >>> print('power: %.4f' % power_corr(r=0.5, n=20, alternative="greater")) + >>> print("power: %.4f" % power_corr(r=0.5, n=20, alternative="greater")) power: 0.7510 - >>> print('power: %.4f' % power_corr(r=0.5, n=20, alternative="less")) + >>> print("power: %.4f" % power_corr(r=0.5, n=20, alternative="less")) power: 0.0000 3. Compute required sample size given ``r``, ``power`` and ``alpha`` - >>> print('n: %.4f' % power_corr(r=0.5, power=0.80)) + >>> print("n: %.4f" % power_corr(r=0.5, power=0.80)) n: 28.2484 4. Compute achieved ``r`` given ``n``, ``power`` and ``alpha`` level - >>> print('r: %.4f' % power_corr(n=20, power=0.80, alpha=0.05)) + >>> print("r: %.4f" % power_corr(n=20, power=0.80, alpha=0.05)) r: 0.5822 5. Compute achieved alpha level given ``r``, ``n`` and ``power`` - >>> print('alpha: %.4f' % power_corr(r=0.5, n=20, power=0.80, alpha=None)) + >>> print("alpha: %.4f" % power_corr(r=0.5, n=20, power=0.80, alpha=None)) alpha: 0.1377 """ # Check the number of arguments that are None @@ -1001,22 +1002,22 @@ def power_chi2(dof, w=None, n=None, power=None, alpha=0.05): 1. Compute achieved power >>> from pingouin import power_chi2 - >>> print('power: %.4f' % power_chi2(dof=1, w=0.3, n=20)) + >>> print("power: %.4f" % power_chi2(dof=1, w=0.3, n=20)) power: 0.2687 2. Compute required sample size - >>> print('n: %.4f' % power_chi2(dof=3, w=0.3, power=0.80)) + >>> print("n: %.4f" % power_chi2(dof=3, w=0.3, power=0.80)) n: 121.1396 3. Compute achieved effect size - >>> print('w: %.4f' % power_chi2(dof=2, n=20, power=0.80, alpha=0.05)) + >>> print("w: %.4f" % power_chi2(dof=2, n=20, power=0.80, alpha=0.05)) w: 0.6941 4. Compute achieved alpha (significance) - >>> print('alpha: %.4f' % power_chi2(dof=1, w=0.5, n=20, power=0.80, alpha=None)) + >>> print("alpha: %.4f" % power_chi2(dof=1, w=0.5, n=20, power=0.80, alpha=None)) alpha: 0.1630 """ assert isinstance(dof, (int, float)) diff --git a/src/pingouin/regression.py b/src/pingouin/regression.py index 3b536f6b..23deef66 100644 --- a/src/pingouin/regression.py +++ b/src/pingouin/regression.py @@ -1,15 +1,16 @@ import itertools import warnings + import numpy as np import pandas as pd import pandas_flavor as pf -from scipy.stats import t, norm -from scipy.linalg import pinvh, lstsq +from scipy.linalg import lstsq, pinvh +from scipy.stats import norm, t from pingouin.config import options -from pingouin.utils import remove_na as rm_na from pingouin.utils import _flatten_list as _fl from pingouin.utils import _postprocess_dataframe +from pingouin.utils import remove_na as rm_na __all__ = ["linear_regression", "logistic_regression", "mediation_analysis"] @@ -695,13 +696,12 @@ def logistic_regression( >>> import numpy as np >>> import pandas as pd >>> import pingouin as pg - >>> df = pg.read_dataset('penguins') + >>> df = pg.read_dataset("penguins") >>> # Let's first convert the target variable from string to boolean: - >>> df['male'] = (df['sex'] == 'male').astype(int) # male: 1, female: 0 + >>> df["male"] = (df["sex"] == "male").astype(int) # male: 1, female: 0 >>> # Since there are missing values in our outcome variable, we need to >>> # set `remove_na=True` otherwise regression will fail. - >>> lom = pg.logistic_regression(df['body_mass_g'], df['male'], - ... remove_na=True) + >>> lom = pg.logistic_regression(df["body_mass_g"], df["male"], remove_na=True) >>> lom.round(2) names coef se z pval CI2.5 CI97.5 0 Intercept -5.16 0.71 -7.24 0.0 -6.56 -3.77 @@ -712,9 +712,8 @@ def logistic_regression( (e.g divide by 1000) in order to get more intuitive coefficients and confidence intervals: - >>> df['body_mass_kg'] = df['body_mass_g'] / 1000 - >>> lom = pg.logistic_regression(df['body_mass_kg'], df['male'], - ... remove_na=True) + >>> df["body_mass_kg"] = df["body_mass_g"] / 1000 + >>> lom = pg.logistic_regression(df["body_mass_kg"], df["male"], remove_na=True) >>> lom.round(2) names coef se z pval CI2.5 CI97.5 0 Intercept -5.16 0.71 -7.24 0.0 -6.56 -3.77 @@ -727,9 +726,9 @@ def logistic_regression( first level of our categorical variable (species = Adelie) which will be used as the reference level: - >>> df = pd.get_dummies(df, columns=['species'], dtype=float, drop_first=True) - >>> X = df[['body_mass_kg', 'species_Chinstrap', 'species_Gentoo']] - >>> y = df['male'] + >>> df = pd.get_dummies(df, columns=["species"], dtype=float, drop_first=True) + >>> X = df[["body_mass_kg", "species_Chinstrap", "species_Gentoo"]] + >>> y = df["male"] >>> lom = pg.logistic_regression(X, y, remove_na=True) >>> lom.round(2) names coef se z pval CI2.5 CI97.5 @@ -740,15 +739,15 @@ def logistic_regression( 3. Using NumPy aray and returning only the coefficients - >>> pg.logistic_regression(X.to_numpy(), y.to_numpy(), coef_only=True, - ... remove_na=True) + >>> pg.logistic_regression(X.to_numpy(), y.to_numpy(), coef_only=True, remove_na=True) array([-26.23906892, 7.09826571, -0.13180626, -9.71718529]) 4. Passing custom parameters to sklearn - >>> lom = pg.logistic_regression(X, y, solver='sag', max_iter=10000, - ... random_state=42, remove_na=True) - >>> print(lom['coef'].to_numpy()) + >>> lom = pg.logistic_regression( + ... X, y, solver="sag", max_iter=10000, random_state=42, remove_na=True + ... ) + >>> print(lom["coef"].to_numpy()) [-25.98248153 7.02881472 -0.13119779 -9.62247569] **How to interpret the log-odds coefficients?** @@ -763,12 +762,32 @@ def logistic_regression( probability of the student passing the exam?* >>> # First, let's create the dataframe - >>> Hours = [0.50, 0.75, 1.00, 1.25, 1.50, 1.75, 1.75, 2.00, 2.25, 2.50, - ... 2.75, 3.00, 3.25, 3.50, 4.00, 4.25, 4.50, 4.75, 5.00, 5.50] + >>> Hours = [ + ... 0.50, + ... 0.75, + ... 1.00, + ... 1.25, + ... 1.50, + ... 1.75, + ... 1.75, + ... 2.00, + ... 2.25, + ... 2.50, + ... 2.75, + ... 3.00, + ... 3.25, + ... 3.50, + ... 4.00, + ... 4.25, + ... 4.50, + ... 4.75, + ... 5.00, + ... 5.50, + ... ] >>> Pass = [0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1] - >>> df = pd.DataFrame({'HoursStudy': Hours, 'PassExam': Pass}) + >>> df = pd.DataFrame({"HoursStudy": Hours, "PassExam": Pass}) >>> # And then run the logistic regression - >>> lr = pg.logistic_regression(df['HoursStudy'], df['PassExam']).round(3) + >>> lr = pg.logistic_regression(df["HoursStudy"], df["PassExam"]).round(3) >>> lr names coef se z pval CI2.5 CI97.5 0 Intercept -4.078 1.761 -2.316 0.021 -7.529 -0.626 @@ -1147,9 +1166,8 @@ def mediation_analysis( 1. Simple mediation analysis >>> from pingouin import mediation_analysis, read_dataset - >>> df = read_dataset('mediation') - >>> mediation_analysis(data=df, x='X', m='M', y='Y', alpha=0.05, - ... seed=42) + >>> df = read_dataset("mediation") + >>> mediation_analysis(data=df, x="X", m="M", y="Y", alpha=0.05, seed=42) path coef se pval CI2.5 CI97.5 sig 0 M ~ X 0.561015 0.094480 4.391362e-08 0.373522 0.748509 Yes 1 Y ~ M 0.654173 0.085831 1.612674e-11 0.483844 0.824501 Yes @@ -1159,14 +1177,13 @@ def mediation_analysis( 2. Return the indirect bootstrapped beta coefficients - >>> stats, dist = mediation_analysis(data=df, x='X', m='M', y='Y', - ... return_dist=True) + >>> stats, dist = mediation_analysis(data=df, x="X", m="M", y="Y", return_dist=True) >>> print(dist.shape) (500,) 3. Mediation analysis with a binary mediator variable - >>> mediation_analysis(data=df, x='X', m='Mbin', y='Y', seed=42).round(3) + >>> mediation_analysis(data=df, x="X", m="Mbin", y="Y", seed=42).round(3) path coef se pval CI2.5 CI97.5 sig 0 Mbin ~ X -0.021 0.116 0.857 -0.248 0.206 No 1 Y ~ Mbin -0.135 0.412 0.743 -0.952 0.682 No @@ -1176,8 +1193,7 @@ def mediation_analysis( 4. Mediation analysis with covariates - >>> mediation_analysis(data=df, x='X', m='M', y='Y', - ... covar=['Mbin', 'Ybin'], seed=42).round(3) + >>> mediation_analysis(data=df, x="X", m="M", y="Y", covar=["Mbin", "Ybin"], seed=42).round(3) path coef se pval CI2.5 CI97.5 sig 0 M ~ X 0.559 0.097 0.000 0.367 0.752 Yes 1 Y ~ M 0.666 0.086 0.000 0.495 0.837 Yes @@ -1187,8 +1203,7 @@ def mediation_analysis( 5. Mediation analysis with multiple parallel mediators - >>> mediation_analysis(data=df, x='X', m=['M', 'Mbin'], y='Y', - ... seed=42).round(3) + >>> mediation_analysis(data=df, x="X", m=["M", "Mbin"], y="Y", seed=42).round(3) path coef se pval CI2.5 CI97.5 sig 0 M ~ X 0.561 0.094 0.000 0.374 0.749 Yes 1 Mbin ~ X -0.005 0.029 0.859 -0.063 0.052 No diff --git a/src/pingouin/reliability.py b/src/pingouin/reliability.py index 33614e88..fc93ffc3 100644 --- a/src/pingouin/reliability.py +++ b/src/pingouin/reliability.py @@ -1,10 +1,10 @@ import numpy as np import pandas as pd from scipy.stats import f + from pingouin.config import options from pingouin.utils import _postprocess_dataframe - __all__ = ["cronbach_alpha", "intraclass_corr"] @@ -98,7 +98,7 @@ def cronbach_alpha( Binary wide-format dataframe (with missing values) >>> import pingouin as pg - >>> data = pg.read_dataset('cronbach_wide_missing') + >>> data = pg.read_dataset("cronbach_wide_missing") >>> # In R: psych:alpha(data, use="pairwise") >>> pg.cronbach_alpha(data=data) (0.732660835214447, array([0.435, 0.909])) @@ -106,7 +106,7 @@ def cronbach_alpha( After listwise deletion of missing values (remove the entire rows) >>> # In R: psych:alpha(data, use="complete.obs") - >>> pg.cronbach_alpha(data=data, nan_policy='listwise') + >>> pg.cronbach_alpha(data=data, nan_policy="listwise") (0.8016949152542373, array([0.581, 0.933])) After imputing the missing values with the median of each column @@ -116,9 +116,8 @@ def cronbach_alpha( Likert-type long-format dataframe - >>> data = pg.read_dataset('cronbach_alpha') - >>> pg.cronbach_alpha(data=data, items='Items', scores='Scores', - ... subject='Subj') + >>> data = pg.read_dataset("cronbach_alpha") + >>> pg.cronbach_alpha(data=data, items="Items", scores="Scores", subject="Subj") (0.5917188485995826, array([0.195, 0.84 ])) """ # Safety check @@ -243,9 +242,10 @@ def intraclass_corr(data=None, targets=None, raters=None, ratings=None, nan_poli ICCs of wine quality assessed by 4 judges. >>> import pingouin as pg - >>> data = pg.read_dataset('icc') - >>> icc = pg.intraclass_corr(data=data, targets='Wine', raters='Judge', - ... ratings='Scores').round(3) + >>> data = pg.read_dataset("icc") + >>> icc = pg.intraclass_corr(data=data, targets="Wine", raters="Judge", ratings="Scores").round( + ... 3 + ... ) >>> icc.set_index("Type") Description ICC F df1 df2 pval CI95 Type diff --git a/src/pingouin/utils.py b/src/pingouin/utils.py index 95bd80d3..632f521c 100644 --- a/src/pingouin/utils.py +++ b/src/pingouin/utils.py @@ -1,11 +1,13 @@ """Helper functions.""" +import collections.abc +import itertools as it import numbers + import numpy as np import pandas as pd -import itertools as it -import collections.abc from tabulate import tabulate + from .config import options __all__ = [ @@ -281,15 +283,15 @@ def _flatten_list(x, include_tuple=False): Examples -------- >>> from pingouin.utils import _flatten_list - >>> x = ['X1', ['M1', 'M2'], 'Y1', ['Y2']] + >>> x = ["X1", ["M1", "M2"], "Y1", ["Y2"]] >>> _flatten_list(x) ['X1', 'M1', 'M2', 'Y1', 'Y2'] - >>> x = ['Xaa', 'Xbb', 'Xcc'] + >>> x = ["Xaa", "Xbb", "Xcc"] >>> _flatten_list(x) ['Xaa', 'Xbb', 'Xcc'] - >>> x = ['Xaa', ('Xbb', 'Xcc'), (1, 2), (1)] + >>> x = ["Xaa", ("Xbb", "Xcc"), (1, 2), (1)] >>> _flatten_list(x) ['Xaa', ('Xbb', 'Xcc'), (1, 2), 1] diff --git a/tests/test_bayesian.py b/tests/test_bayesian.py index 030b8ba4..5e6760eb 100644 --- a/tests/test_bayesian.py +++ b/tests/test_bayesian.py @@ -1,11 +1,12 @@ -import numpy as np from unittest import TestCase -from scipy.stats import pearsonr -from pingouin.parametric import ttest -from pingouin.bayesian import bayesfactor_ttest, bayesfactor_binom -from pingouin.bayesian import bayesfactor_pearson as bfp +import numpy as np from pytest import approx +from scipy.stats import pearsonr + +from pingouin.bayesian import bayesfactor_binom, bayesfactor_ttest +from pingouin.bayesian import bayesfactor_pearson as bfp +from pingouin.parametric import ttest np.random.seed(1234) x = np.random.normal(size=100) diff --git a/tests/test_circular.py b/tests/test_circular.py index daa437a2..8dc061a0 100644 --- a/tests/test_circular.py +++ b/tests/test_circular.py @@ -1,10 +1,12 @@ -import pytest -import numpy as np from unittest import TestCase + +import numpy as np +import pytest from scipy.stats import circmean + from pingouin import read_dataset -from pingouin.circular import convert_angles, _checkangles from pingouin.circular import ( + _checkangles, circ_axial, circ_corrcc, circ_corrcl, @@ -12,6 +14,7 @@ circ_r, circ_rayleigh, circ_vtest, + convert_angles, ) np.random.seed(123) diff --git a/tests/test_config.py b/tests/test_config.py index a585ce33..c833e254 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ -import pingouin from unittest import TestCase + +import pingouin from pingouin.config import set_default_options expected_default_options = pingouin.options.copy() diff --git a/tests/test_contingency.py b/tests/test_contingency.py index 09fde40e..85c95c3f 100644 --- a/tests/test_contingency.py +++ b/tests/test_contingency.py @@ -1,10 +1,12 @@ -import pytest +from unittest import TestCase + import numpy as np import pandas as pd -import pingouin as pg -from unittest import TestCase +import pytest from scipy.stats import chi2_contingency +import pingouin as pg + df_ind = pg.read_dataset("chi2_independence") df_mcnemar = pg.read_dataset("chi2_mcnemar") diff --git a/tests/test_correlation.py b/tests/test_correlation.py index 838ff5af..59080009 100644 --- a/tests/test_correlation.py +++ b/tests/test_correlation.py @@ -1,8 +1,10 @@ -import pytest -import numpy as np from unittest import TestCase -from pingouin.correlation import corr, rm_corr, partial_corr, skipped, distance_corr, bicor + +import numpy as np +import pytest + from pingouin import read_dataset +from pingouin.correlation import bicor, corr, distance_corr, partial_corr, rm_corr, skipped class TestCorrelation(TestCase): diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 6706f579..58287ed8 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -1,16 +1,18 @@ -import pytest +from unittest import TestCase + import numpy as np import pandas as pd -from unittest import TestCase +import pytest + +from pingouin import read_dataset from pingouin.distribution import ( - gzscore, - normality, anderson, epsilon, + gzscore, homoscedasticity, + normality, sphericity, ) -from pingouin import read_dataset # Generate random dataframe df = read_dataset("mixed_anova.csv") diff --git a/tests/test_effsize.py b/tests/test_effsize.py index 38742b30..43bef5d0 100644 --- a/tests/test_effsize.py +++ b/tests/test_effsize.py @@ -1,10 +1,11 @@ -import pytest +from unittest import TestCase + import numpy as np import pandas as pd -from unittest import TestCase +import pytest from scipy.stats import pearsonr, pointbiserialr -from pingouin.effsize import compute_esci, compute_effsize, compute_effsize_from_t, compute_bootci +from pingouin.effsize import compute_bootci, compute_effsize, compute_effsize_from_t, compute_esci from pingouin.effsize import convert_effsize as cef # Dataset diff --git a/tests/test_equivalence.py b/tests/test_equivalence.py index 6272ccb5..59ddcbbe 100644 --- a/tests/test_equivalence.py +++ b/tests/test_equivalence.py @@ -1,7 +1,9 @@ # Author: Antoine Weill--Duflos # Date July 2019 -import numpy as np from unittest import TestCase + +import numpy as np + from pingouin.equivalence import tost diff --git a/tests/test_multicomp.py b/tests/test_multicomp.py index 3efa1018..db563228 100644 --- a/tests/test_multicomp.py +++ b/tests/test_multicomp.py @@ -1,8 +1,10 @@ -import pytest +from unittest import TestCase + import numpy as np +import pytest from numpy.testing import assert_array_almost_equal, assert_array_equal -from unittest import TestCase -from pingouin.multicomp import fdr, bonf, holm, sidak, multicomp + +from pingouin.multicomp import bonf, fdr, holm, multicomp, sidak pvals = [0.52, 0.12, 0.0001, 0.03, 0.14] pvals2 = [0.52, 0.12, 0.10, 0.30, 0.14] diff --git a/tests/test_multivariate.py b/tests/test_multivariate.py index aaa5ac73..f6ec0c42 100644 --- a/tests/test_multivariate.py +++ b/tests/test_multivariate.py @@ -1,9 +1,11 @@ +from unittest import TestCase + import numpy as np import pandas as pd from sklearn import datasets -from unittest import TestCase + from pingouin import read_dataset -from pingouin.multivariate import multivariate_normality, multivariate_ttest, box_m +from pingouin.multivariate import box_m, multivariate_normality, multivariate_ttest data = read_dataset("multivariate") dvs = ["Fever", "Pressure", "Aches"] diff --git a/tests/test_nonparametric.py b/tests/test_nonparametric.py index de4da3cc..6e1d2ba1 100644 --- a/tests/test_nonparametric.py +++ b/tests/test_nonparametric.py @@ -1,17 +1,19 @@ -import pytest -import scipy +from unittest import TestCase + import numpy as np import pandas as pd -from unittest import TestCase +import pytest +import scipy + from pingouin.nonparametric import ( + cochran, + friedman, + harrelldavis, + kruskal, mad, madmedianrule, mwu, wilcoxon, - kruskal, - friedman, - cochran, - harrelldavis, ) np.random.seed(1234) diff --git a/tests/test_pairwise.py b/tests/test_pairwise.py index c795f319..8c89196b 100644 --- a/tests/test_pairwise.py +++ b/tests/test_pairwise.py @@ -1,14 +1,16 @@ -import pytest +from unittest import TestCase + import numpy as np import pandas as pd -from unittest import TestCase +import pytest + from pingouin import read_dataset from pingouin.pairwise import ( - pairwise_ttests, - pairwise_tests, pairwise_corr, - pairwise_tukey, pairwise_gameshowell, + pairwise_tests, + pairwise_ttests, + pairwise_tukey, ) @@ -485,6 +487,7 @@ def test_pairwise_tests(self): def test_ptests(self): """Test function ptests.""" from itertools import combinations + from scipy.stats import ttest_ind, ttest_rel # Load BFI dataset diff --git a/tests/test_pandas.py b/tests/test_pandas.py index fce8b5aa..e1141446 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -5,9 +5,11 @@ - Raphael Vallat """ +from unittest import TestCase + import numpy as np + import pingouin as pg -from unittest import TestCase df = pg.read_dataset("mixed_anova") df_aov3 = pg.read_dataset("anova3_unbalanced") diff --git a/tests/test_parametric.py b/tests/test_parametric.py index 41baa884..96ddb43f 100644 --- a/tests/test_parametric.py +++ b/tests/test_parametric.py @@ -1,11 +1,11 @@ -import pytest -import numpy as np from unittest import TestCase + +import numpy as np +import pytest from numpy.testing import assert_array_equal as array_equal from pingouin import read_dataset -from pingouin.parametric import ttest, anova, rm_anova, mixed_anova, ancova, welch_anova - +from pingouin.parametric import ancova, anova, mixed_anova, rm_anova, ttest, welch_anova # Generate random data for ANOVA df = read_dataset("mixed_anova.csv") diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 064f6830..7a3bc53c 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1,19 +1,21 @@ -import pytest +from unittest import TestCase + import matplotlib +import matplotlib.pyplot as plt import numpy as np -from scipy import stats +import pytest import seaborn as sns -import matplotlib.pyplot as plt -from unittest import TestCase +from scipy import stats + from pingouin import read_dataset from pingouin.plotting import ( - plot_blandaltman, _ppoints, - qqplot, + plot_blandaltman, + plot_circmean, plot_paired, - plot_shift, plot_rm_corr, - plot_circmean, + plot_shift, + qqplot, ) # Disable open figure warning diff --git a/tests/test_power.py b/tests/test_power.py index f16fbe31..d4bdea0b 100644 --- a/tests/test_power.py +++ b/tests/test_power.py @@ -1,13 +1,15 @@ -import pytest -import numpy as np from unittest import TestCase + +import numpy as np +import pytest + from pingouin.power import ( - power_ttest, - power_ttest2n, power_anova, - power_rm_anova, - power_corr, power_chi2, + power_corr, + power_rm_anova, + power_ttest, + power_ttest2n, ) diff --git a/tests/test_regression.py b/tests/test_regression.py index 56f853d8..dc4df9d4 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -1,21 +1,20 @@ -import pytest -import numpy as np -import pandas as pd from unittest import TestCase -from scipy.stats import linregress, zscore -from sklearn.linear_model import LinearRegression +import numpy as np +import pandas as pd +import pytest import statsmodels.api as sm - -from pandas.testing import assert_frame_equal from numpy.testing import assert_almost_equal, assert_equal +from pandas.testing import assert_frame_equal +from scipy.stats import linregress, zscore +from sklearn.linear_model import LinearRegression from pingouin import read_dataset from pingouin.regression import ( + _pval_from_bootci, linear_regression, logistic_regression, mediation_analysis, - _pval_from_bootci, ) # 1st dataset: mediation diff --git a/tests/test_reliability.py b/tests/test_reliability.py index 291ef02b..51c0b68e 100644 --- a/tests/test_reliability.py +++ b/tests/test_reliability.py @@ -1,9 +1,11 @@ -import pytest +from unittest import TestCase + import numpy as np import pandas as pd -from unittest import TestCase -from pingouin.reliability import cronbach_alpha, intraclass_corr +import pytest + from pingouin import read_dataset +from pingouin.reliability import cronbach_alpha, intraclass_corr class TestReliability(TestCase): diff --git a/tests/test_utils.py b/tests/test_utils.py index afa44829..4072841c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,23 +1,23 @@ -import pandas as pd +from unittest import TestCase + import numpy as np +import pandas as pd import pytest import pingouin - -from unittest import TestCase from pingouin.utils import ( - print_table, - _postprocess_dataframe, - _get_round_setting_for, - _perm_pval, - _check_eftype, _check_dataframe, - remove_na, + _check_eftype, _flatten_list, + _get_round_setting_for, + _is_mpmath_installed, _is_sklearn_installed, _is_sklearn_version_compatible, _is_statsmodels_installed, - _is_mpmath_installed, + _perm_pval, + _postprocess_dataframe, + print_table, + remove_na, ) # Dataset From e8984a82a83b2fd42be70004930adf7572cfd8c8 Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:15:12 +0100 Subject: [PATCH 03/35] upgrade minimal setuptools --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 877afb92..03643d30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=80.0", "wheel"] build-backend = "setuptools.build_meta" [project] From f7734e3be0a0ea3d74cf70cceaf7312a4dfe5570 Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:39:53 +0100 Subject: [PATCH 04/35] use sphinx linkcode --- docs/conf.py | 62 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0e68e412..2b9bfca9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,12 +7,20 @@ # -- Path setup -------------------------------------------------------------- +import inspect import os import sys import time +from pathlib import Path import pingouin +# Configure for source links +GITHUB_USER = "raphaelvallat" +GITHUB_REPO = "pingouin" +GITHUB_BRANCH = "main" +REPO_ROOT = Path(__file__).resolve().parents[1] + sys.path.insert(0, os.path.abspath("sphinxext")) @@ -37,7 +45,7 @@ extensions = [ "sphinx.ext.mathjax", "sphinx.ext.doctest", - "sphinx.ext.viewcode", + "sphinx.ext.linkcode", "sphinx.ext.githubpages", "sphinx.ext.autosummary", "sphinx.ext.autodoc", @@ -109,7 +117,7 @@ "icon": "fa-brands fa-github", }, ], - "use_edit_page_button": True, + "use_edit_page_button": False, "pygments_light_style": "vs", "pygments_dark_style": "monokai", } @@ -123,12 +131,50 @@ "index": [], } -html_context = { - "github_user": "raphaelvallat", - "github_repo": "pingouin", - "github_version": "main", - "doc_path": "docs", -} +# -- Linkcode ------------------------------------------------ + + +def linkcode_resolve(domain, info): + """ + Resolve source code links to GitHub for Python objects. + + Returns a GitHub URL including line number references when available. + """ + if domain != "py": + return None + + module_name = info.get("module") + full_name = info.get("fullname") + + if not module_name or not full_name: + return None + + module = sys.modules.get(module_name) + if module is None: + return None + + # Resolve the object + obj = module + for part in full_name.split("."): + try: + obj = inspect.getattr_static(obj, part) + except AttributeError: + return None + + # Unwrap decorators (important for @wraps, dataclasses, etc.) + obj = inspect.unwrap(obj) + source_file = inspect.getsourcefile(obj) or inspect.getfile(obj) + source_lines, start_line = inspect.getsourcelines(obj) + source_path = Path(source_file).resolve() + relative_path = source_path.relative_to(REPO_ROOT) + + end_line = start_line + len(source_lines) - 1 + + return ( + f"https://github.com/{GITHUB_USER}/{GITHUB_REPO}" + f"/blob/{GITHUB_BRANCH}/{relative_path.as_posix()}" + f"#L{start_line}-L{end_line}" + ) # -- Intersphinx ------------------------------------------------ From c7b3072d653037843c40cbda06c6fb64cc6021ef Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:16:07 +0100 Subject: [PATCH 05/35] seperate github actions --- .github/workflows/coverage.yml | 38 ++++++++++++++++++++++++++++++++++ .github/workflows/doc.yml | 30 +++++++++++++++++++++++++++ .github/workflows/test.yml | 34 ++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/doc.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..b7716238 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,38 @@ +name: Coverage +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + coverage: + runs-on: ubuntu-latest + env: + FORCE_COLOR: true + + steps: + - uses: actions/checkout@v6 + + - name: "Set up Python" + uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: | + uv pip install --group=test . "scikit-learn<1.8.0" + + - name: Run tests with coverage + run: | + pytest --cov --cov-report=xml --verbose + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.xml + fail_ci_if_error: true diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml new file mode 100644 index 00000000..62b34b1b --- /dev/null +++ b/.github/workflows/doc.yml @@ -0,0 +1,30 @@ +name: Documentation +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up uv (Python 3.13) + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.13" + enable-cache: true + + - name: Install dependencies + run: | + uv pip install --group=docs . + + - name: Build documentation + run: | + make -C docs clean + make -C docs html + + - name: Upload documentation artifacts + uses: actions/upload-artifact@v diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..b048eb2a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Tests +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13"] + runs-on: ${{ matrix.platform }} + env: + FORCE_COLOR: true + steps: + - uses: actions/checkout@v6 + + - name: "Set up Python" + uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: | + uv pip install --group=test . "scikit-learn<1.8.0" + + - name: Test with pytest + run: uv run pytest --verbose \ No newline at end of file From 6fc1b4375c4a3ffe33ccb98b2fd64c3c0c12be4e Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:41:26 +0100 Subject: [PATCH 06/35] use system python in ci --- .github/workflows/coverage.yml | 2 +- .github/workflows/doc.yml | 5 ++- .github/workflows/python_tests.yml | 58 ------------------------------ .github/workflows/test.yml | 1 + 4 files changed, 6 insertions(+), 60 deletions(-) delete mode 100644 .github/workflows/python_tests.yml diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b7716238..c371ba6d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest env: FORCE_COLOR: true - + UV_SYSTEM_PYTHON: 1 steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 62b34b1b..81fbd16d 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -8,6 +8,9 @@ on: jobs: docs: runs-on: ubuntu-latest + env: + FORCE_COLOR: true + UV_SYSTEM_PYTHON: 1 steps: - uses: actions/checkout@v4 @@ -27,4 +30,4 @@ jobs: make -C docs html - name: Upload documentation artifacts - uses: actions/upload-artifact@v + uses: actions/upload-artifact@v6 diff --git a/.github/workflows/python_tests.yml b/.github/workflows/python_tests.yml deleted file mode 100644 index 50454db8..00000000 --- a/.github/workflows/python_tests.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Python tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - build: - strategy: - fail-fast: false - matrix: - platform: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - - runs-on: ${{ matrix.platform }} - - env: - FORCE_COLOR: true - - steps: - - uses: actions/checkout@v6 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install --group=test . "scikit-learn<1.8.0" - - - name: Test with pytest - run: | - pytest --cov --cov-report=xml --verbose - - - name: Build docs - if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == 3.10 }} - run: | - pip install --group=docs . - make -C docs clean - make -C docs html - - - name: Upload doc build artifacts - if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == 3.10 }} - uses: actions/upload-artifact@v4 - with: - name: docs-artifact - path: docs/build/html - - - name: Upload coverage report - if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == 3.10 }} - uses: codecov/codecov-action@v4 - with: - token: c6ed6ca6-a040-4f23-9ebf-8c474c998097 - file: ./coverage.xml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b048eb2a..07ca9b3c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,7 @@ jobs: runs-on: ${{ matrix.platform }} env: FORCE_COLOR: true + UV_SYSTEM_PYTHON: 1 steps: - uses: actions/checkout@v6 From 77f3f50ca3927dedf980511b62378298bd85f8f4 Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:46:53 +0100 Subject: [PATCH 07/35] Fix setup python --- .github/workflows/coverage.yml | 2 +- .github/workflows/doc.yml | 10 ++++++---- .github/workflows/test.yml | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index c371ba6d..9b67ba73 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v6 - - name: "Set up Python" + - name: Set up Python uses: actions/setup-python@v6 with: python-version-file: "pyproject.toml" diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 81fbd16d..da9a99b1 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -14,11 +14,13 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up uv (Python 3.13) - uses: astral-sh/setup-uv@v7 + - name: Set up Python + uses: actions/setup-python@v6 with: - python-version: "3.13" - enable-cache: true + python-version-file: "pyproject.toml" + + - name: Set up uv + uses: astral-sh/setup-uv@v7 - name: Install dependencies run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 07ca9b3c..628b1b22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v6 - - name: "Set up Python" + - name: Set up Python uses: actions/setup-python@v6 with: python-version-file: "pyproject.toml" From 30d64b39a228cb999a3b46bd4f8fa815cd9bf43b Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:38:49 +0100 Subject: [PATCH 08/35] add pre-release test suite --- .github/workflows/pytest_prerelease.yml | 71 +++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/pytest_prerelease.yml diff --git a/.github/workflows/pytest_prerelease.yml b/.github/workflows/pytest_prerelease.yml new file mode 100644 index 00000000..165f69bb --- /dev/null +++ b/.github/workflows/pytest_prerelease.yml @@ -0,0 +1,71 @@ +name: PyTest Pre-Release + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 3 * * SUN' + +env: + FORCE_COLOR: 1 + UV_SYSTEM_PYTHON: 1 + +jobs: + test-prerelease: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.12", "3.14"] + runs-on: ${{ matrix.platform }} + continue-on-error: true + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: | + uv pip install --group=test . + + - name: Install scipy prerelease + run: | + uv pip uninstall scipy + uv pip install -U --pre scipy + + - name: Install numpy prerelease + run: | + uv pip uninstall numpy + uv pip install -U --pre numpy + + - name: Install pandas prerelease + run: | + uv pip uninstall pandas + uv pip install -U --pre pandas + + - name: Install statsmodels prerelease + run: | + uv pip uninstall statsmodels + uv pip install -U --pre statsmodels + + - name: Install scikit-learn prerelease + run: | + uv pip uninstall scikit-learn + uv pip install -U --pre scikit-learn + + - name: Install seaborn prerelease + run: | + uv pip uninstall seaborn + uv pip install -U --pre seaborn + + - name: Test with pytest + run: uv run pytest --verbose \ No newline at end of file From 7d734879a605ac89f73188f788bfb84481e68f72 Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:18:23 +0100 Subject: [PATCH 09/35] add pytext matrix --- .github/workflows/pytest.yml | 114 +++++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 35 ----------- 2 files changed, 114 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/pytest.yml delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 00000000..ec666dcd --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,114 @@ +name: PyTest + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + FORCE_COLOR: 1 + UV_SYSTEM_PYTHON: 1 + +jobs: + test--core: + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + runs-on: ${{ matrix.platform }} + env: + FORCE_COLOR: true + UV_SYSTEM_PYTHON: 1 + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: | + uv pip install --group=test . "scikit-learn<1.8.0" + + - name: Test with pytest + run: uv run pytest --verbose + + + # Test against different dependency versions + test-dependencies: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # Minimal supported versions and increasing gradually + - python-version: "3.10" + scipy: "scipy==1.7.3" + numpy: "numpy=1.19.5" + pandas: "pandas=1.5" + statsmodels: "statsmodels==0.11.1" + scikit-learn: "scikit-learn==1.2.2" + label: "really-old-versions" + + - python-version: "3.11" + scipy: "scipy==1.9.3" + numpy: "numpy=1.26.4" + pandas: "pandas=2.0.3" + statsmodels: "statsmodels==0.12.1" + scikit-learn: "scikit-learn==1.4.2" + label: "old-versions" + + - python-version: "3.12" + scipy: "scipy==1.14.1" + numpy: "numpy=2.1.3" + pandas: "pandas=2.3.3" + statsmodels: "statsmodels==0.13.5" + scikit-learn: "scikit-learn==1.5.2" + label: "mid-versions" + + - python-version: "3.13" + scipy: "scipy==1.17.0" + numpy: "numpy=2.4.0" + pandas: "pandas=3.0.1" + statsmodels: "statsmodels==0.14.6" + scikit-learn: "scikit-learn==1.7.2" + label: "recent-versions" + + - python-version: "3.14" + scipy: "scipy" + numpy: "numpy" + pandas: "pandas" + statsmodels: "statsmodels" + scikit-learn: "scikit-learn==1.7.2" + label: "latest-versions" + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies (${{ matrix.label }}) + run: | + uv pip install --group=test . \ + "${{ matrix.statsmodels }}" \ + "${{ matrix.scikit-learn }}" + + - name: Display installed versions + run: | + python -c "import statsmodels; print(f'statsmodels: {statsmodels.__version__}')" + python -c "import sklearn; print(f'scikit-learn: {sklearn.__version__}')" + + - name: Test with pytest + run: pytest --verbose \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 628b1b22..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Tests -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - strategy: - fail-fast: false - matrix: - platform: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] - runs-on: ${{ matrix.platform }} - env: - FORCE_COLOR: true - UV_SYSTEM_PYTHON: 1 - steps: - - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version-file: "pyproject.toml" - - - name: Set up uv - uses: astral-sh/setup-uv@v7 - - - name: Install dependencies - run: | - uv pip install --group=test . "scikit-learn<1.8.0" - - - name: Test with pytest - run: uv run pytest --verbose \ No newline at end of file From 0ef1c88d04aa493e3bc130092b09d8c32ef11628 Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:33:39 +0100 Subject: [PATCH 10/35] make docs editable --- .github/workflows/doc.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index da9a99b1..69af5309 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -24,7 +24,7 @@ jobs: - name: Install dependencies run: | - uv pip install --group=docs . + uv pip install --group=docs --editable . - name: Build documentation run: | From 97d3832ebc7726a99a46372a78097ac3ef959232 Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:34:04 +0100 Subject: [PATCH 11/35] add pytest --- .github/workflows/pytest.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index ec666dcd..b9616f2e 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -102,11 +102,17 @@ jobs: - name: Install dependencies (${{ matrix.label }}) run: | uv pip install --group=test . \ + "${{ matrix.scipy }}" \ + "${{ matrix.numpy }}" \ + "${{ matrix.pandas }}" \ "${{ matrix.statsmodels }}" \ "${{ matrix.scikit-learn }}" - name: Display installed versions run: | + python -c "import scipy; print(f'scipy: {scipy.__version__}')" + python -c "import numpy; print(f'numpy: {numpy.__version__}')" + python -c "import pandas; print(f'pandas: {pandas.__version__}')" python -c "import statsmodels; print(f'statsmodels: {statsmodels.__version__}')" python -c "import sklearn; print(f'scikit-learn: {sklearn.__version__}')" From efcdc24142f96ab1d470756f2e9744c6ecee2bec Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:39:07 +0100 Subject: [PATCH 12/35] fix numpy equal --- .github/workflows/pytest.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index b9616f2e..d5ad91c4 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -50,7 +50,7 @@ jobs: # Minimal supported versions and increasing gradually - python-version: "3.10" scipy: "scipy==1.7.3" - numpy: "numpy=1.19.5" + numpy: "numpy==1.19.5" pandas: "pandas=1.5" statsmodels: "statsmodels==0.11.1" scikit-learn: "scikit-learn==1.2.2" @@ -58,7 +58,7 @@ jobs: - python-version: "3.11" scipy: "scipy==1.9.3" - numpy: "numpy=1.26.4" + numpy: "numpy==1.26.4" pandas: "pandas=2.0.3" statsmodels: "statsmodels==0.12.1" scikit-learn: "scikit-learn==1.4.2" @@ -66,7 +66,7 @@ jobs: - python-version: "3.12" scipy: "scipy==1.14.1" - numpy: "numpy=2.1.3" + numpy: "numpy==2.1.3" pandas: "pandas=2.3.3" statsmodels: "statsmodels==0.13.5" scikit-learn: "scikit-learn==1.5.2" @@ -74,7 +74,7 @@ jobs: - python-version: "3.13" scipy: "scipy==1.17.0" - numpy: "numpy=2.4.0" + numpy: "numpy==2.4.0" pandas: "pandas=3.0.1" statsmodels: "statsmodels==0.14.6" scikit-learn: "scikit-learn==1.7.2" From 271aa6ca454038c08fe43b4f773998bf3dad07ef Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:43:36 +0100 Subject: [PATCH 13/35] update pandas deps --- .github/workflows/pytest.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index d5ad91c4..661c8f29 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -51,7 +51,7 @@ jobs: - python-version: "3.10" scipy: "scipy==1.7.3" numpy: "numpy==1.19.5" - pandas: "pandas=1.5" + pandas: "pandas==1.5" statsmodels: "statsmodels==0.11.1" scikit-learn: "scikit-learn==1.2.2" label: "really-old-versions" @@ -59,7 +59,7 @@ jobs: - python-version: "3.11" scipy: "scipy==1.9.3" numpy: "numpy==1.26.4" - pandas: "pandas=2.0.3" + pandas: "pandas==2.0.3" statsmodels: "statsmodels==0.12.1" scikit-learn: "scikit-learn==1.4.2" label: "old-versions" @@ -67,7 +67,7 @@ jobs: - python-version: "3.12" scipy: "scipy==1.14.1" numpy: "numpy==2.1.3" - pandas: "pandas=2.3.3" + pandas: "pandas==2.3.3" statsmodels: "statsmodels==0.13.5" scikit-learn: "scikit-learn==1.5.2" label: "mid-versions" @@ -75,7 +75,7 @@ jobs: - python-version: "3.13" scipy: "scipy==1.17.0" numpy: "numpy==2.4.0" - pandas: "pandas=3.0.1" + pandas: "pandas==3.0.1" statsmodels: "statsmodels==0.14.6" scikit-learn: "scikit-learn==1.7.2" label: "recent-versions" From 3eec57e8680125e6d45ffb3748796d6aeb15c53a Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:52:56 +0100 Subject: [PATCH 14/35] resolve dep problems --- .github/workflows/pytest.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 661c8f29..b21f3b0c 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -50,7 +50,7 @@ jobs: # Minimal supported versions and increasing gradually - python-version: "3.10" scipy: "scipy==1.7.3" - numpy: "numpy==1.19.5" + numpy: "numpy==1.21.6" pandas: "pandas==1.5" statsmodels: "statsmodels==0.11.1" scikit-learn: "scikit-learn==1.2.2" @@ -58,7 +58,7 @@ jobs: - python-version: "3.11" scipy: "scipy==1.9.3" - numpy: "numpy==1.26.4" + numpy: "numpy==1.25.2 " pandas: "pandas==2.0.3" statsmodels: "statsmodels==0.12.1" scikit-learn: "scikit-learn==1.4.2" @@ -68,14 +68,14 @@ jobs: scipy: "scipy==1.14.1" numpy: "numpy==2.1.3" pandas: "pandas==2.3.3" - statsmodels: "statsmodels==0.13.5" + statsmodels: "statsmodels==0.13.4" scikit-learn: "scikit-learn==1.5.2" label: "mid-versions" - python-version: "3.13" scipy: "scipy==1.17.0" numpy: "numpy==2.4.0" - pandas: "pandas==3.0.1" + pandas: "pandas==3.0.0" statsmodels: "statsmodels==0.14.6" scikit-learn: "scikit-learn==1.7.2" label: "recent-versions" From 5871776fa039598d58b4988e9c448b3e722e1126 Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:55:41 +0100 Subject: [PATCH 15/35] fix statsmodels errors --- .github/workflows/pytest.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index b21f3b0c..ec2810d8 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -52,7 +52,7 @@ jobs: scipy: "scipy==1.7.3" numpy: "numpy==1.21.6" pandas: "pandas==1.5" - statsmodels: "statsmodels==0.11.1" + statsmodels: "statsmodels==0.14.1" scikit-learn: "scikit-learn==1.2.2" label: "really-old-versions" @@ -60,7 +60,7 @@ jobs: scipy: "scipy==1.9.3" numpy: "numpy==1.25.2 " pandas: "pandas==2.0.3" - statsmodels: "statsmodels==0.12.1" + statsmodels: "statsmodels==0.14.3" scikit-learn: "scikit-learn==1.4.2" label: "old-versions" @@ -68,7 +68,7 @@ jobs: scipy: "scipy==1.14.1" numpy: "numpy==2.1.3" pandas: "pandas==2.3.3" - statsmodels: "statsmodels==0.13.4" + statsmodels: "statsmodels==0.14.4" scikit-learn: "scikit-learn==1.5.2" label: "mid-versions" From 248b7c9ed782ad4433e7a2f2faaf9540132ce1e7 Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:03:00 +0100 Subject: [PATCH 16/35] use matrix to test dependency combinations --- .github/workflows/pytest.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index ec2810d8..af1f21b2 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -41,11 +41,12 @@ jobs: # Test against different dependency versions - test-dependencies: - runs-on: ubuntu-latest + test-dependency-combinations: + runs-on: ${{ matrix.platform }} strategy: fail-fast: false matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] include: # Minimal supported versions and increasing gradually - python-version: "3.10" From 221b26dd240076943cab76e467942e6249df628e Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:07:34 +0100 Subject: [PATCH 17/35] fix doc artifacts --- .github/workflows/doc.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 69af5309..3a1e33e1 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -33,3 +33,6 @@ jobs: - name: Upload documentation artifacts uses: actions/upload-artifact@v6 + with: + name: docs-artifact + path: docs/build/html From 6b19ed93088d89bd3de2c9669f536c33fac29937 Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:09:52 +0100 Subject: [PATCH 18/35] reduce test-core python versions --- .github/workflows/pytest.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index af1f21b2..85a84ce4 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -11,12 +11,12 @@ env: UV_SYSTEM_PYTHON: 1 jobs: - test--core: + test-core: strategy: fail-fast: false matrix: platform: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.12", "3.14"] runs-on: ${{ matrix.platform }} env: FORCE_COLOR: true @@ -42,7 +42,6 @@ jobs: # Test against different dependency versions test-dependency-combinations: - runs-on: ${{ matrix.platform }} strategy: fail-fast: false matrix: @@ -88,7 +87,8 @@ jobs: statsmodels: "statsmodels" scikit-learn: "scikit-learn==1.7.2" label: "latest-versions" - + + runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v6 From 3ff3d40849ca94e59d520a878156daaa137e8730 Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:16:05 +0100 Subject: [PATCH 19/35] fix matrix --- .github/workflows/pytest.yml | 104 +++++++++++++++++------------------ 1 file changed, 50 insertions(+), 54 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 85a84ce4..63e96c60 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -18,27 +18,23 @@ jobs: platform: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.10", "3.12", "3.14"] runs-on: ${{ matrix.platform }} - env: - FORCE_COLOR: true - UV_SYSTEM_PYTHON: 1 steps: - - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Set up uv - uses: astral-sh/setup-uv@v7 - - - name: Install dependencies - run: | - uv pip install --group=test . "scikit-learn<1.8.0" - - - name: Test with pytest - run: uv run pytest --verbose + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: | + uv pip install --group=test . "scikit-learn<1.8.0" + + - name: Test with pytest + run: uv run pytest --verbose # Test against different dependency versions test-dependency-combinations: @@ -46,7 +42,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, macos-latest, windows-latest] - include: + deps: # Minimal supported versions and increasing gradually - python-version: "3.10" scipy: "scipy==1.7.3" @@ -55,15 +51,15 @@ jobs: statsmodels: "statsmodels==0.14.1" scikit-learn: "scikit-learn==1.2.2" label: "really-old-versions" - + - python-version: "3.11" scipy: "scipy==1.9.3" - numpy: "numpy==1.25.2 " + numpy: "numpy==1.25.2" pandas: "pandas==2.0.3" statsmodels: "statsmodels==0.14.3" scikit-learn: "scikit-learn==1.4.2" label: "old-versions" - + - python-version: "3.12" scipy: "scipy==1.14.1" numpy: "numpy==2.1.3" @@ -71,7 +67,7 @@ jobs: statsmodels: "statsmodels==0.14.4" scikit-learn: "scikit-learn==1.5.2" label: "mid-versions" - + - python-version: "3.13" scipy: "scipy==1.17.0" numpy: "numpy==2.4.0" @@ -87,35 +83,35 @@ jobs: statsmodels: "statsmodels" scikit-learn: "scikit-learn==1.7.2" label: "latest-versions" - + runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v6 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Set up uv - uses: astral-sh/setup-uv@v7 - - - name: Install dependencies (${{ matrix.label }}) - run: | - uv pip install --group=test . \ - "${{ matrix.scipy }}" \ - "${{ matrix.numpy }}" \ - "${{ matrix.pandas }}" \ - "${{ matrix.statsmodels }}" \ - "${{ matrix.scikit-learn }}" - - - name: Display installed versions - run: | - python -c "import scipy; print(f'scipy: {scipy.__version__}')" - python -c "import numpy; print(f'numpy: {numpy.__version__}')" - python -c "import pandas; print(f'pandas: {pandas.__version__}')" - python -c "import statsmodels; print(f'statsmodels: {statsmodels.__version__}')" - python -c "import sklearn; print(f'scikit-learn: {sklearn.__version__}')" - - - name: Test with pytest - run: pytest --verbose \ No newline at end of file + - uses: actions/checkout@v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.deps.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies (${{ matrix.deps.label }}) + run: | + uv pip install --group=test . \ + "${{ matrix.scipy }}" \ + "${{ matrix.numpy }}" \ + "${{ matrix.pandas }}" \ + "${{ matrix.statsmodels }}" \ + "${{ matrix.scikit-learn }}" + + - name: Display installed versions + run: | + python -c "import scipy; print(f'scipy: {scipy.__version__}')" + python -c "import numpy; print(f'numpy: {numpy.__version__}')" + python -c "import pandas; print(f'pandas: {pandas.__version__}')" + python -c "import statsmodels; print(f'statsmodels: {statsmodels.__version__}')" + python -c "import sklearn; print(f'scikit-learn: {sklearn.__version__}')" + + - name: Test with pytest + run: pytest --verbose From 1bcd6434fbfac751d14670c5cdd0fc1c1ef80b7f Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:18:07 +0100 Subject: [PATCH 20/35] fix matrix again --- .github/workflows/pytest.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 63e96c60..6d66603d 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -99,11 +99,11 @@ jobs: - name: Install dependencies (${{ matrix.deps.label }}) run: | uv pip install --group=test . \ - "${{ matrix.scipy }}" \ - "${{ matrix.numpy }}" \ - "${{ matrix.pandas }}" \ - "${{ matrix.statsmodels }}" \ - "${{ matrix.scikit-learn }}" + "${{ matrix.deps.scipy }}" \ + "${{ matrix.deps.numpy }}" \ + "${{ matrix.deps.pandas }}" \ + "${{ matrix.deps.statsmodels }}" \ + "${{ matrix.deps.scikit-learn }}" - name: Display installed versions run: | From 90b6b4082f4eea85736760ebf6a142edc4c491a8 Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:20:40 +0100 Subject: [PATCH 21/35] fix again --- .github/workflows/pytest.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 6d66603d..0e194935 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -99,11 +99,11 @@ jobs: - name: Install dependencies (${{ matrix.deps.label }}) run: | uv pip install --group=test . \ - "${{ matrix.deps.scipy }}" \ - "${{ matrix.deps.numpy }}" \ - "${{ matrix.deps.pandas }}" \ - "${{ matrix.deps.statsmodels }}" \ - "${{ matrix.deps.scikit-learn }}" + "${{ matrix.deps.scipy }}" \ + "${{ matrix.deps.numpy }}" \ + "${{ matrix.deps.pandas }}" \ + "${{ matrix.deps.statsmodels }}" \ + "${{ matrix.deps.scikit-learn }}" - name: Display installed versions run: | From 592846bdf6c267871b9304b86eefaebe550126af Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:27:26 +0100 Subject: [PATCH 22/35] fix windows multiline error --- .github/workflows/pytest.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 0e194935..266564f7 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -97,13 +97,13 @@ jobs: uses: astral-sh/setup-uv@v7 - name: Install dependencies (${{ matrix.deps.label }}) - run: | - uv pip install --group=test . \ - "${{ matrix.deps.scipy }}" \ - "${{ matrix.deps.numpy }}" \ - "${{ matrix.deps.pandas }}" \ - "${{ matrix.deps.statsmodels }}" \ - "${{ matrix.deps.scikit-learn }}" + run: >- + uv pip install --group=test . + "${{ matrix.deps.scipy }}" + "${{ matrix.deps.numpy }}" + "${{ matrix.deps.pandas }}" + "${{ matrix.deps.statsmodels }}" + "${{ matrix.deps.scikit-learn }}" - name: Display installed versions run: | From a07bf0dc14be3067eaa4419b665095dbce64e81f Mon Sep 17 00:00:00 2001 From: Yann1cks <50637827+yann1cks@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:35:12 +0100 Subject: [PATCH 23/35] downgrade statsmodels --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 266564f7..6c824f9a 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -48,7 +48,7 @@ jobs: scipy: "scipy==1.7.3" numpy: "numpy==1.21.6" pandas: "pandas==1.5" - statsmodels: "statsmodels==0.14.1" + statsmodels: "statsmodels==0.14.0" scikit-learn: "scikit-learn==1.2.2" label: "really-old-versions" From a1c6386f721301249e5567c3f95dff0c546d080b Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Feb 2026 09:05:36 +0100 Subject: [PATCH 24/35] Bump to pandas 2.1.0 in really-old-versions + rmeove macos-latest --- .github/workflows/pytest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 6c824f9a..bd9e1d11 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -41,13 +41,13 @@ jobs: strategy: fail-fast: false matrix: - platform: [ubuntu-latest, macos-latest, windows-latest] + platform: [ubuntu-latest, windows-latest] deps: # Minimal supported versions and increasing gradually - python-version: "3.10" scipy: "scipy==1.7.3" numpy: "numpy==1.21.6" - pandas: "pandas==1.5" + pandas: "pandas==2.1.0" statsmodels: "statsmodels==0.14.0" scikit-learn: "scikit-learn==1.2.2" label: "really-old-versions" From a471fea3f97f7bbe24c24271b188fcf02b3f2d9d Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Feb 2026 09:13:18 +0100 Subject: [PATCH 25/35] Bump really old versions and update dependency doc --- .github/workflows/pytest.yml | 4 ++-- README.rst | 17 +++++++++-------- docs/index.rst | 17 +++++++++-------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index bd9e1d11..535c133c 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -45,8 +45,8 @@ jobs: deps: # Minimal supported versions and increasing gradually - python-version: "3.10" - scipy: "scipy==1.7.3" - numpy: "numpy==1.21.6" + scipy: "scipy==1.8.0" + numpy: "numpy==1.22.4" pandas: "pandas==2.1.0" statsmodels: "statsmodels==0.14.0" scikit-learn: "scikit-learn==1.2.2" diff --git a/README.rst b/README.rst index 5cc82831..342aaf57 100644 --- a/README.rst +++ b/README.rst @@ -76,22 +76,23 @@ Installation Dependencies ------------ -The main dependencies of Pingouin are : +The main dependencies of Pingouin are: -* `NumPy `_ -* `SciPy `_ -* `Pandas `_ +* `NumPy `_ >= 1.22.4 +* `SciPy `_ >= 1.8.0 +* `Pandas `_ >= 2.1.0 * `Pandas-flavor `_ -* `Statsmodels `_ +* `Statsmodels `_ >= 0.14.0 * `Matplotlib `_ * `Seaborn `_ +* `Scikit-learn `_ >= 1.2.2 +* `Tabulate `_ -In addition, some functions require : +Some functions additionally require: -* `Scikit-learn `_ * `Mpmath `_ -Pingouin is a Python 3 package and is currently tested for Python 3.8-3.11. +Pingouin is a Python 3 package and is currently tested for Python 3.10+. User installation ----------------- diff --git a/docs/index.rst b/docs/index.rst index 63df0995..d7ef07d4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -63,21 +63,22 @@ the :code:`ttest` function of Pingouin returns the T-value, the p-value, the deg Installation ============ -Pingouin is a Python 3 package and is currently tested for Python 3.8-3.11. +Pingouin is a Python 3 package and is currently tested for Python 3.10+. -The main dependencies of Pingouin are : +The main dependencies of Pingouin are: -* `NumPy `_ -* `SciPy `_ -* `Pandas `_ +* `NumPy `_ >= 1.22.4 +* `SciPy `_ >= 1.8.0 +* `Pandas `_ >= 2.1.0 * `Pandas-flavor `_ -* `Statsmodels `_ +* `Statsmodels `_ >= 0.14.0 * `Matplotlib `_ * `Seaborn `_ +* `Scikit-learn `_ >= 1.2.2 +* `Tabulate `_ -In addition, some functions require : +Some functions additionally require: -* `Scikit-learn `_ * `Mpmath `_ Pingouin can be easily installed using pip From 7a69d59aba3056ca4f5a47376b5d4aa5a4a3f6a3 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Feb 2026 09:19:40 +0100 Subject: [PATCH 26/35] Fix Tcl tk error --- tests/conftest.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..290cc21f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +import matplotlib + +matplotlib.use("Agg") From 72964a5aceb5022b55043cab0e6a1804e7ada613 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Feb 2026 09:26:06 +0100 Subject: [PATCH 27/35] fix violinplot --- src/pingouin/plotting.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pingouin/plotting.py b/src/pingouin/plotting.py index 4f9f19af..f136d6d1 100644 --- a/src/pingouin/plotting.py +++ b/src/pingouin/plotting.py @@ -846,7 +846,11 @@ def adjacent_values(vals, q1, q3): ) if violin: - vl = plt.violinplot([y, x], showextrema=False, orientation="horizontal", widths=1) + import matplotlib as _mpl + + _mpl_ver = tuple(int(v) for v in _mpl.__version__.split(".")[:2]) + _orient_kw = {"orientation": "horizontal"} if _mpl_ver >= (3, 10) else {"vert": False} + vl = plt.violinplot([y, x], showextrema=False, widths=1, **_orient_kw) # Upper plot paths = vl["bodies"][0].get_paths()[0] From 2fc3708cac3dd113e3fde8a0bacf2fa5ce29b669 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Feb 2026 09:27:36 +0100 Subject: [PATCH 28/35] fix CI error LogReg --- tests/test_regression.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_regression.py b/tests/test_regression.py index dc4df9d4..30a728db 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -262,7 +262,7 @@ def test_logistic_regression(self): # summary(glm(Ybin ~ X, data=df, family=binomial)) assert_equal(np.round(lom["coef"], 3), [1.319, -0.199]) assert_equal(np.round(lom["se"], 3), [0.758, 0.121]) - assert_equal(np.round(lom["z"], 3), [1.74, -1.647]) + assert_almost_equal(lom["z"], [1.74, -1.647], decimal=2) assert_equal(np.round(lom["pval"], 3), [0.082, 0.099]) assert_equal(np.round(lom["CI2.5"], 3), [-0.167, -0.437]) assert_equal(np.round(lom["CI97.5"], 3), [2.805, 0.038]) From 70762dd6f8454b810869c078e5bb0491c2500c27 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Feb 2026 09:30:57 +0100 Subject: [PATCH 29/35] Add workflow_dispatch --- .github/workflows/pytest_prerelease.yml | 1 + tests/test_regression.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest_prerelease.yml b/.github/workflows/pytest_prerelease.yml index 165f69bb..b1093f6a 100644 --- a/.github/workflows/pytest_prerelease.yml +++ b/.github/workflows/pytest_prerelease.yml @@ -7,6 +7,7 @@ on: branches: [main] schedule: - cron: '0 3 * * SUN' + workflow_dispatch: env: FORCE_COLOR: 1 diff --git a/tests/test_regression.py b/tests/test_regression.py index 30a728db..2c1cbd1d 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -275,7 +275,7 @@ def test_logistic_regression(self): # summary(glm(Ybin ~ X+M, data=df, family=binomial)) assert_equal(lom["coef"].to_numpy(), [1.327, -0.196, -0.006]) assert_equal(lom["se"].to_numpy(), [0.778, 0.141, 0.125]) - assert_equal(lom["z"].to_numpy(), [1.705, -1.392, -0.048]) + assert_almost_equal(lom["z"], [1.705, -1.392, -0.048], decimal=2) assert_equal(lom["pval"].to_numpy(), [0.088, 0.164, 0.962]) assert_equal(lom["CI2.5"].to_numpy(), [-0.198, -0.472, -0.252]) assert_equal(lom["CI97.5"].to_numpy(), [2.853, 0.08, 0.24]) From 497cdd745c735c7a8e61323e7f03efe74e02968b Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Feb 2026 09:34:20 +0100 Subject: [PATCH 30/35] Update pandas to 2.2.0 in old-versions --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 535c133c..fecbed09 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -55,7 +55,7 @@ jobs: - python-version: "3.11" scipy: "scipy==1.9.3" numpy: "numpy==1.25.2" - pandas: "pandas==2.0.3" + pandas: "pandas==2.2.0" statsmodels: "statsmodels==0.14.3" scikit-learn: "scikit-learn==1.4.2" label: "old-versions" From da6110057109c819017437c33fb68715ba6a8eed Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Feb 2026 09:44:31 +0100 Subject: [PATCH 31/35] Install numpy first to fix statsmodels issue --- .github/workflows/pytest.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index fecbed09..b33c7ebe 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -96,11 +96,13 @@ jobs: - name: Set up uv uses: astral-sh/setup-uv@v7 + - name: Install numpy (${{ matrix.deps.label }}) + run: uv pip install "${{ matrix.deps.numpy }}" + - name: Install dependencies (${{ matrix.deps.label }}) run: >- uv pip install --group=test . "${{ matrix.deps.scipy }}" - "${{ matrix.deps.numpy }}" "${{ matrix.deps.pandas }}" "${{ matrix.deps.statsmodels }}" "${{ matrix.deps.scikit-learn }}" From a0ee7125b646b7b03ab8ae1c9ba681d6186e4e89 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Feb 2026 09:52:17 +0100 Subject: [PATCH 32/35] bump minimal version to statsmodels 0.14.1 (attempt to fix windows) --- .github/workflows/pytest.yml | 2 +- README.rst | 2 +- docs/index.rst | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index b33c7ebe..9a78e770 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -48,7 +48,7 @@ jobs: scipy: "scipy==1.8.0" numpy: "numpy==1.22.4" pandas: "pandas==2.1.0" - statsmodels: "statsmodels==0.14.0" + statsmodels: "statsmodels==0.14.1" scikit-learn: "scikit-learn==1.2.2" label: "really-old-versions" diff --git a/README.rst b/README.rst index 342aaf57..7f1ea889 100644 --- a/README.rst +++ b/README.rst @@ -82,7 +82,7 @@ The main dependencies of Pingouin are: * `SciPy `_ >= 1.8.0 * `Pandas `_ >= 2.1.0 * `Pandas-flavor `_ -* `Statsmodels `_ >= 0.14.0 +* `Statsmodels `_ >= 0.14.1 * `Matplotlib `_ * `Seaborn `_ * `Scikit-learn `_ >= 1.2.2 diff --git a/docs/index.rst b/docs/index.rst index d7ef07d4..ed0a8c78 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -71,7 +71,7 @@ The main dependencies of Pingouin are: * `SciPy `_ >= 1.8.0 * `Pandas `_ >= 2.1.0 * `Pandas-flavor `_ -* `Statsmodels `_ >= 0.14.0 +* `Statsmodels `_ >= 0.14.1 * `Matplotlib `_ * `Seaborn `_ * `Scikit-learn `_ >= 1.2.2 From 3bfdb7dbb0c6b2faa6716edc206dc91916baa2c1 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Feb 2026 09:56:51 +0100 Subject: [PATCH 33/35] fix statsmodels compat with pandas 2.1.0, bump minimal version to 2.1.1 --- .github/workflows/pytest.yml | 2 +- README.rst | 14 +++++++------- docs/index.rst | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 9a78e770..1e756904 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -47,7 +47,7 @@ jobs: - python-version: "3.10" scipy: "scipy==1.8.0" numpy: "numpy==1.22.4" - pandas: "pandas==2.1.0" + pandas: "pandas==2.1.1" statsmodels: "statsmodels==0.14.1" scikit-learn: "scikit-learn==1.2.2" label: "really-old-versions" diff --git a/README.rst b/README.rst index 7f1ea889..375bd9b8 100644 --- a/README.rst +++ b/README.rst @@ -80,7 +80,7 @@ The main dependencies of Pingouin are: * `NumPy `_ >= 1.22.4 * `SciPy `_ >= 1.8.0 -* `Pandas `_ >= 2.1.0 +* `Pandas `_ >= 2.1.1 * `Pandas-flavor `_ * `Statsmodels `_ >= 0.14.1 * `Matplotlib `_ @@ -125,16 +125,16 @@ To build and install from source, clone this repository or download the source a cd pingouin # optional, build a wheel and sdist - python -m build + python -m build # install the package - pip install . + pip install . - # or editable install with dev dependencies - pip install --group test --group docs --editable . + # or editable install with dev dependencies + pip install --group test --group docs --editable . - # test the package - pytest + # test the package + pytest Quick start ============ diff --git a/docs/index.rst b/docs/index.rst index ed0a8c78..04057469 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -69,7 +69,7 @@ The main dependencies of Pingouin are: * `NumPy `_ >= 1.22.4 * `SciPy `_ >= 1.8.0 -* `Pandas `_ >= 2.1.0 +* `Pandas `_ >= 2.1.1 * `Pandas-flavor `_ * `Statsmodels `_ >= 0.14.1 * `Matplotlib `_ @@ -550,7 +550,7 @@ Several functions of Pingouin were inspired from R or Matlab toolboxes, includin Functions Guidelines - FAQ + FAQ Changelog Contribute Cite \ No newline at end of file From 054a805c2734e7557ee202a6e6bd7c01cab81c21 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Feb 2026 14:30:59 +0100 Subject: [PATCH 34/35] Update contributing.rst file --- docs/contributing.rst | 71 ++++++++++++++--------- docs/pictures/github_build_artifacts.png | Bin 8479 -> 0 bytes docs/pictures/github_checks.png | Bin 31858 -> 0 bytes 3 files changed, 42 insertions(+), 29 deletions(-) delete mode 100644 docs/pictures/github_build_artifacts.png delete mode 100644 docs/pictures/github_checks.png diff --git a/docs/contributing.rst b/docs/contributing.rst index 9d97456e..1b15772e 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -12,9 +12,9 @@ Code guidelines *Before starting new code*, we highly recommend opening an issue on `GitHub `_ to discuss potential changes. -* Please use standard `pep8 `_ and `flake8 `_ Python style guidelines. Pingouin uses `ruff `_ for code formatting. Before submitting a PR, please make sure to run the following command in the root folder of Pingouin to sort all imports and format afterwards: +* Please follow `PEP 8 `_ Python style guidelines. Pingouin uses `Ruff `_ for linting and formatting. Before submitting a PR, please run the following commands from the root folder of Pingouin to sort imports and format code: - .. code-block:: bash + .. code-block:: bash $ ruff check --select I --fix @@ -32,28 +32,56 @@ Code guidelines .. code-block:: bash - $ pytest --doctest-modules + $ pytest --verbose + +Setting up a development environment +------------------------------------- + +Pingouin uses `uv `_ for fast dependency management. To set up a local development environment, first clone the repository and then install the package in editable mode with the test dependencies: + +.. code-block:: bash + + $ git clone https://github.com/raphaelvallat/pingouin.git + $ cd pingouin + $ uv pip install --group=test --editable . + +To also install the development tools (Ruff), add the ``dev`` group: + +.. code-block:: bash + + $ uv pip install --group=dev --group=test --editable . + +Continuous Integration +----------------------- + +Pingouin uses `GitHub Actions `_ for continuous integration. The following workflows run automatically on every push and pull request to the ``main`` branch: + +* **PyTest** — runs the test suite on Ubuntu, macOS and Windows across Python 3.10, 3.12 and 3.14, as well as against a range of historical dependency versions (from minimum supported to latest). +* **Coverage** — measures test coverage and uploads the report to `Codecov `_. +* **Ruff** — checks code style and formatting. +* **Documentation** — builds the Sphinx documentation and uploads the result as a downloadable artifact. + +A separate **PyTest (pre-release)** workflow runs weekly against pre-release versions of all major dependencies to catch compatibility issues early. Checking and building documentation ------------------------------------ +------------------------------------ -Pingouin's documentation (including docstring in code) uses ReStructuredText format, +Pingouin's documentation (including docstrings in code) uses ReStructuredText format, see `Sphinx documentation `_ to learn more about editing them. The code follows the `NumPy docstring standard `_. - All changes to the codebase must be properly documented. To ensure that documentation is rendered correctly, the best bet is to follow the existing examples for function docstrings. Build locally ^^^^^^^^^^^^^ -If you want to test the documentation locally, you will need to install development dependencies. They can be installed with the `docs` dependency group: +If you want to test the documentation locally, install the package with the ``docs`` dependency group: .. code-block:: bash - $ pip install --upgrade pingouin + $ uv pip install --group=docs --editable . -and then within the ``pingouin/docs`` directory do: +Then, within the ``pingouin/docs`` directory, run: .. code-block:: bash @@ -70,28 +98,13 @@ and then come back after executing the ``html`` recipe. Inspect on GitHub ^^^^^^^^^^^^^^^^^ -Thanks to the `GitHub Actions `_ continuous integration service, -the documentation is also built on GitHub servers after every commit you make as part of a Pull Request. -To inspect these build artifacts, follow these steps: +The documentation is also built automatically on GitHub after every commit you make as part of a Pull Request. +To inspect the rendered documentation, follow these steps: * Click on the "Show all checks" dropdown menu at the end of the Pull Request user interface - -.. figure:: /pictures/github_checks.png - :align: center - :alt: GitHub checks dropdown menu - - Screenshot of the GitHub checks dropdown menu - -* Click on the check that starts with ``Python tests / build (ubuntu-latest, 3.9)`` -* Now in the top right corner of the opening window, you will see a small dropdown menu called "Artifacts" - -.. figure:: /pictures/github_build_artifacts.png - :align: center - :alt: GitHub build artifacts dropdown menu - - Screenshot of the GitHub build artifacts dropdown menu - -* Click on that drowndown menu and download the ``docs-artifact`` zip file +* Click on the check named **Documentation / docs** +* In the top-right corner of the opening window, click the **Artifacts** dropdown menu +* Download the ``docs-artifact`` zip file You can then unpack that zip file on your computer, enter the directory, and open the ``index.html`` file that you will find there. That should open the Pingouin documentation based on the changes from your Pull Request. diff --git a/docs/pictures/github_build_artifacts.png b/docs/pictures/github_build_artifacts.png deleted file mode 100644 index 123cdcb1a99b7d21df6d62d7064813a186e141eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8479 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 rRMDRCMMJlH7cM7j*PY7G_=bcsO{ zUfp|N)%(pqJ#(gJroYo)PoL`Z{ZLnxdy7eiiG+moRzY4y6A1|giiCt*_6Fr2B5|#G z_zyr*SJsk!d3m|My}y5W+}b^;si{etE#RHfWhqglV`6*t>ebcFot~*}cXu}(lfd=O z)zb3n_1(_#@$Zf8U%zheNADkAUM`*#zh0@1_5eb8-3C!VWz1@a!+me$p8`5-k7n_>0xF>UJQYXy=KM zN7k$HKs|T+_SfJ!qHS>V@?jrh*BNf~ z>1<<4Q~&DiMU#iCqnnO&|4)}7Un7sa$(NgI7N72TsWEYpAvnR)uM`q=iu#gZjP4|Sjm1rf7n%0FS zNZ`@=UzghNAzq%Ut_6vm+ffP0u;=IVgLSY?B7Njh#^|AW^Hqf1cP#@wB@LBN&YQIx z9XIpQUqERp5bSh+XK~OY1#ujP}~T8Cavyw6)3$h*gFKT>wzrH2xh&ES+8q2R?6(BV+`)~b9=ktX*t z9t=96gbJzun@#`ku_+|mY#@4+@NvQy$upzS-_&{ZVymaoJN@;h>MS=d^IppSy(tcI z&G(uztr`Li>up-3Ox=>!Fnj;DiFTx~X8Kw~BL`zfNKITP!vHTpz!0Cx5lRcP-j&)CBKp38=*9E5ns)b&yyKGU>cqjN%~cFkq-G$G+6YO5uwh^bA>@1t`K; zLS0;7h{6&f(Qo+VY|rph|GV~;;dp_EBT${C#wW-yk*u|(ZK30G`s?4=DziP=Un>*;Aob8WqoY~%0PS_0N{+!p@GW3H@cJ`GKXzKD+jl`ZPG8qd92 zE>u&b7uIA9y_@fS&TlOJB1imbv zHUIJL50RiXMLfCqi1Rq7>vlh$+qIV znGXH@9w!hW+t6~2o=CX%ainY@sfsGB+MW%+tCt=aM4YjUf+1Tf5u(#JB(idadwd_e zK3WC3ySlr$t%mlN3c6 z+5NFd4)W)>pXc}{&y76bnxABEDeSMTA;S-`RYg_SHpS>B&tQys^a)FQ_Y0}+N3ta|O9>h`ic8fkaKK^Tsq0WU9 z2d{3Vb^{l9>Z?pAl~tD>mj0v zce2IVyNr8P3Fx?M@At^fH_zuFr+Fv*HKWB`|1oGwH?0wz!F3~CZLdT1r=O_NvR2%` z+g}6mwmQ+3;T3OO?;hcgTl^=j!2I4~yrGh4=|oSu_riw}Sw*a!70BZS*i~FNnDVHm zuN}J6oYJ6tS=_-W;ZVzz1EbI|MHN@c*}4LhKpVx#O#U%{j>CJir{`t*ehgWmqk%

xuJ$VN@yebv?di%urN2ii>cttRjf-wQBxPQdw zmz3u#wkO1w-c1=pSdEqS8G@iOGzW@4I8Nf@jlJ{>>Ky{$2ssgLxvaXQgH#zHZqWYI=iXbK~$8R>T z7nW4P!F%Bfw+hLRKLRoae6bw|ffLXP|Hcwqk=(HG8Ysal5SOXk(E)Wb5wr!zMC>r=5xY?iL>SUDMO=X}`SWhLkl=_@ZEp@E!u5RPC^}l|}fbJUY+q1(I4QtRA z*oC@|;hpPPy{|NYi!J^EJG*9*z^vPfIKaC%F|hfNw*c6AzoPc1oZlZh)*e;^Ai7Ph z4m*Ed|MESGTmq;faEQRg$jHUPA6o6~O7oyJxze_guYWJp_sfv}hO$^K`mUJU>W^XK z+~u8G6G#X8di0ZC4fqJQ4h-;7?QKTt{bG&yU?Y$MBFz!$-N)LHHu~Xat^38MCTqjH zGrf$ZllWwg@0I;F8rM-|%bi4GD4I0vkc#Tr?B#pawoxK4@t)-Ts(b(Z5zm@+=t z!C#j&_@SPy+7;#zvq`JL{5Uw$4gEr#x{m_oB!(X^8&mPB&Zb)dv)(dnF+|W1wLwW3 z?2r%dB4T;b6Z7>*FIsIka&Y^C-1-honmjCTW%)$)V>U=WD^?{`tuG}r6QXoYhON05 zx55wIt##*;e=np=($IVfi7{l&gJS*_ZtFEQYfFn|%UMb$ap_Il8+_K!=q$$Tu_lD` zv}HoIb{c)$KM`OZ%8-6|SA-Aa`8r2f!qQ7Tf4-^N+R*+LJYJOppAj?NpWo>?P7w0i zXk?Oy_vr4oPw9yiJwOk@gMHr-?~Y@<(C0xwUEaYo`*Hcfe7$b>7?w~8o772Ch46(} z4SoeNlN9MWE38N36zz4Qv#-r0De{Gc8JKPW*RRla!nG#NyB(mjCfz#Iy5vL1;nUb* zkLL!!Oyf^*(@VgoF7Oibo{qPe?4Y~lP!e4G{sFpvp#(ZnR@$)y7+bhvW6vhjd^yrN ztuFN)Y>tZbF-1oUzL0aTIzNT9_lR`Fv9^!?3eC-3*~7>}}x3GTC3 zW@j^Wn>+?VwMjSbcfcIM-SMeVJnCOs``VvZda%R3-J!#Syc#l&8woi{uhspfQeA_mR>uI8_t9uGcm{4(t_&mn(ByXP?8g|+r*+GR47(+P#Ve6pSQ zL{c|PE#D-LAnQc_w>Z6E;b!j=28=_-wGd)!*H^c)pcPIK=?iJyJlOIkP75Kq2E>57 zlJl#ziBY6fqL9o>_gAfoUgVH1L-oaXDZ0jX+EZ~nu1O*s7+Tn_7{acyOC`h%FyNoX zHbi&BM%qqSE`G!^Y^9j1ya|MVUK`UI7c~kv1ekE{Mt%jO>T)A7nxlfi<-s?7G0sQA z%;57w7^%h%Ph6E~BFV$KPwU%q{BOAIj(xT1)&#a5U<7!#F6sM&PTD6cvDp!qPj1#<(I5TKHQT7$`N>NQ<-a^+7kB*0nh-jzW^5)AZlV*uTU) zwYi@<(~y~rn_TwFY;O0AS?il--SONZ!RNFYXsM5Th9yikLM(<-j|y3u|J?TZuAqX* zV{qLVPliHQQ+~An1W3g-<)(0mmH(LmVD&5~A5|ErhL0H!GkjV5v+}_CJ5RgaNzJ;0 z+d}DT8mxwS;@x5LZ?WRA0=6}*l_|;MJ2@B!EddC$zQvU^YOk*Ov1Gvqa$LrtD5Y3% zN8wp)2{+|8f%b^n^6}+jMp$y%H`3^{lf_y^6iE`e95Z5b#KY)^6cQww;6J_-s4_V0oQBH-*cU+ ziyiiv)xU)G12*aax%M5X`z>&H6XVP~)D%d)t}pQyenOOdOhF*hE@lKRPy($5nR^Hj zF9{9Ac15*_XQ2)@d_}s8IYj-RZ;|*y36Y+M7Tws8Zc+Cew*?)4vf%OV_e<|$zK;11 zPY>;qwNMoxpW~MA9@TB{Hk<8X-dZ3|%rjuUg8p&$gIp&|u6+&P>t?vur4FqB>#O!$&I7xzK zrNd_xHhlwYdxkv_tR&{XMlfBRwCj{#mm1okgL{e26IVyuVFmhDE(9 z+EQ;}(G^>iD3y!~Ck`61@&hTJdI>iN)11c;k*tc9{zj&r**-8b>|w4jhV4@Ehs*v) z>V!QlvQanSz!6ek$9k_XUGYXN@=vprvo~Yn{k?&Fca!CtNq>_W&yP)g7%;9tw6_b( zZ#SiTQGtE+QKT#48Pmsd!^=RP$<6N%FtRA|n%d0ZLx2CWkFd^%>%5U&ztZErhYS;{ z$KrsJ<0wc2r^@;#=-i|LG)sr+K45wpMG*$0Yfb07pqrw$wY54mnWAo=^K^DDPT3Ke z9{d8BcWch>0HG~jLUUD>U@4I4plNew;C7jE{?%ZKx$$__4^B};e<6m#(g!A;e?zfV zek$dz7v%ZRdE&W**5GLw^S3ps>Hn{d5YG9r`C;OW_wT*2P0i zxLygC))j<6X?gM@$mp96RHLmc;taM}v+ha2P7mM5YVH})T!ETOZv1eY^%k_O=bhmL zrVpz&_G3EpVmZ{+>>`(%@49X$c-Mbr0$)6X@zqX5L9# zqqbLCw^w)+&sfP|unq)er%{b~P-$b8vsyuf+3Zs)9#k^xtUj03RS4C6ZT_w@p<-fS z=P*I!cHY!whRnib*Jaj#i~Gearg}v;h=m~SPZl}w%-Q^B3|}_Azn_Z=(DRdRB1L&- ziKxFG%do$3_Q00_ZXZ;2mrz#mAay0(4+$}MO60~8elA`EUyt?)fj{n6tF#`U=t#4^ zgWIaDMh#&Dw>8nd=-*)gwhCI@H^f0a{G|zxu91Cj;)w4u_i9l(6JIp1k#H zE6uYBflza4N2gh;&GP)vV-(xaK+4#3;>`tJG+kUny`(;<#K)rS3=?ZEikSghdO4hm z8Lvu$)4P1HOmeyhTbIbpn9HUL75oZFK-`06Xb@4a{_q4~A6ShqsPa?9h3!l98Fu<& zc=&4tw0EV*P?!9Vq2xfSEy1Rw{i(_Km+PguDaL*F%9M%PrIz3#k{p6o@AWp@5t$>k zDAS5&GF=r|Af&`AD3X(mK=vO~9(n#9&i(k?wl{lEgpfNnoqCMkHQ0S!G*h2b&h`=X z?9JC*rXc+JOzJ&loe8?Y13SKIhKv^G4{Hz5rwKfbvM=(gI2`^WTK>`jTv}^q z;coF)upp&o`RIyGunIF$u-mG0{kr2o>>evyA7-}ovPZ4wxE3?u_xSBJLW?}kcj3zO_?acE`_@)uT zs!9W0MRza+R>%!Fi*1|f#!I#+-qpPUdU^nMF!iIQW#dl8+*ICE#ag&n>&$_ix<3Yj9mDY8W~(^xHhdP7gx!)+skoWNG8TkNRg^=$$&nAPaT%kATH2)P< za^An7BnX&0+VhXhIOlzKBGuy&Lb`9LI^|vH+m8_y@r-JQ*5$lzv)ZxjUypDqpdRxT zyxpcJ6Rh9t|0oB`$g#UM25vKV?{UT$6WBiIC4evS@F9J@&~@!tt)G+t7eIKc3%x(@ z01A*IMg99^r(szov?}@~%spP7qHV{M|5r;*0`?*ANfbRPWjiDbXHw?r+g9ZD zo)gqGu>yPzHKBelYM<(3@W;;wkrq1dR@LgHwQps#?CEh=|D%eB+_D(`qiWg2vY*fM zQr2htwJ`-Z_Uu(1cqs|_(po91VRo&*A^JV7oYPtmhwHkn;@k~?BL<98^Ywe90W278 z>%z1&5pWk^dy0#|O@rpm%lUFPQoip+x(wwo+!5y5LQ2hBSS^4_{RNm>EfOLwLMwkj zYGgCWy&WNJGY5ibZeI!Be^wk~We)tl4l}*d=C4zh^yx9{uvgjvi7Gei>1VumfRgI7 z;{rK(MKIfXn4o=e12C8DS!&ysFRp4_`I_kw0~SBguZTZWDRz~N($&d}hm7qJxYZf* z@?=rd_}j1gi#vzTST(kMd)wl1gE-=Di|ltPr9)|BZMO=`2j5k6onYy8u?;G0cE(D6 zoWMTQoJ!w1nDvT}qoH{?XZY?Fy{Tya1jF%4?^~A{s}wlhP3-loG4{)8xY^y3Muu`u z-)w8QYJPk7R$yC}9co&sT+Vac?Dt)AD?Bl^#DZzvnwolU?{NE?$LwrOR_dj`&Lu zm4}UYXbLOYxMn~EEVTp^BAHSn1%(iq@LU@Q@nPH@0`XGD{?_dNY-O?8g%{3G9Tp z`{|wt@4c#R`98ffSJolE4uUE@md<@6j>7|Ww7jmqij@{of|T;GOx2F-Jr_B-3+;h_ zPlZIF*P?|i!k*V22p@rYQR%H!$gC~V{^<);%d^(`Pq%g+pMu#3zwuC_qNSMQ1RA=d zieZID-dUm+)<$y&)T-lpBpqZd`u=$DRHocK4Bz@B?1XDO{%j5Zz7|_a(bZ{%j$H{o zxra_Y?qBGFWW`$jc~8ZVi-K8}_e^*+N=Ad|l~fNRD@){SQU{(y;4|^sm}8Y0=Cfgr zEzi#JWKYcR^VkZH7rDL?7seqZvIs!df0xaI0kinB6r0}nKA$bq58&W_Pj?lzFMBo- zEd9R}yJ@gS-jJ@tdxXf=bO~P&?trV_x$jTr;9^Mn%LmYw?H6rC!nmCVn$^|c&I-uc z(P4h6bt1Dl-Rl?Gfo6$H=%HsjXwpyklo^pc4G>@XqqZ0Fa9kl;m=F#%$n|@Ug*LvX z`4plF!W*fD2Yt#w*N&>R@tI3Sj{dO03w`j|Y8-_DOpIaT^8;t!bdJ&Mp>cvr9YAS=6J zr7d5kjhRsqx#KZ(o0R^BSM%_-onH z^xQ_V%|?V;LF~R_S400>3x^;jp@J5z^&W5KGx&qB0jP!vhWgS zvfRH$F^p!oh8D$YTiM;g9gYHI#PHO`l$gPziW)<|S(5eKf?}JV30Yy$N?k-663uZJ z2eJp_vaeJwT^uxjt^T+2n&oK%KWF<|-Y6Es@(iNspus8b&-B6h_>}_!q54mt8Y3Ev zY`*&WtkSebK!H_e;gE&2i&KsGorT8z?moWl}tA^9*=ZBI9uJhc@J5P&LlWIny~ z{sMQm{AvjX|LBRp!v&y*w7d5Q_T-~6NMX^kv_@@3h-y>|ddOojnDU(*XaQ8+63sZ+ zRJUvwBF2T?R2-&D71j336}T#aUvoYEE;NT{(Y72x;pGq_8Qz-<8r5k6&Fmuk>i4{2(k+Lu&GbL}TC zHwKCdRnJ3>Om+?o9z4oqJ{)m=#Q1U41;+^r@^ObXLkqRb6#V$eMBbnZQG!z07?RIT z3n1L2KSo92?8f2I5&206a5ENrNGEg=h+y=nD8JVOl)wD8eVy> zv_B1~dzasxeW4%C@moR$u^>c}sQ<1z{WvdJ1Eq*Z#h8lfZw8b531uU8;VVP zeqA%vCt9HGyLkh=T1gpv>?0rks{dbx%qJ7w`Qam_DD>O$Ef%?Ko!0ydx!5xr1D|Ir z^&Lf5p?zL_K@REl-g;;jqiQ=SlX)>z9$l@3I&W_`tn1)>VV2B@v|_@qHt%$4w+r5M zKHDgMARKvoq>b&QOf3(;UOj*v=D}%yEuD};7MI|f1;^HS$+h#JTFFogSwgloF$<#D z!swMVP%}EBp*bfbN0EOWF$!4L#a`5b&1h-#Ik~CNw1mNgn~PjB^(-pJ7O{gnC`%_J z7QlL8IM=}luQj;XLA1X{VcO{Z{zBu&Lt3f+3nh0{^vnt=->G3nn;(yLEaa2Mz{OwB zAvPYTud2h;dO3;$JWkg>4*Xj&&eE?o)Xa}1=AmrP{)u8>&~&bHzf zz@-eJ$7d>hKTq`6OOQeRuk3rKkGD_ku!zqng`(S-=-~v~H$gfi zL%&5C4v!p1!B6v8LZ4SY!s2;YMAp3oMAT-kYJ^XQD3XgBqieOY@QiCTjh?Xdf zO?)e8&Zi(NFr@Iid({^t4D=r^@F4*$Ad#PO2I=)eqayCtcZ17?ta{8Y^kqsZX;Gp1>mK>5zP-F+F`WH6DOqTuZJSiTwinjNB{bpaKVA-{ zqjcDr2FsV2{d$<=IQqE5dx4j>x-0pQmukhnl4b?EpWog%1sVdatipDP=+-rxd@G?g z&ao5RR!lD3ouJJ*(|1yGO<72G|CHJvCx;N1l*-2-p2c)ntGKVujBiFHSsx>)1&mZ1Pi{{_9SF0$S2L zPyqI7NE$5>TvIAgQ67)yKqUgZrP_q@-ILtGnruL}u)TF*R~oIo*Y>kENLnOQJL1dH z&v0mHp6%zQI)F4%F%YZdnA6F;BnA@Fo35_#ClPWfJT%Yu1OH2*{nA4Kka#>NiD1nFX|LAWtpkam^$Z^u*00Zr)TKDY&g`}HvpMaPK-DUxPsZ_(@^FG?xx-yAbDW3D#k(%0D1(QNik&DpD~ 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 Date: Sun, 22 Feb 2026 14:31:19 +0100 Subject: [PATCH 35/35] Add minimal version in pyproject.toml --- pyproject.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 03643d30..1e472cba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,13 +29,13 @@ dynamic = ["version"] requires-python = ">=3.10" dependencies = [ "matplotlib", - "numpy", - "pandas>=1.5", + "numpy>=1.22.4", + "pandas>=2.1.1", "pandas_flavor", - "scikit-learn>=1.2", - "scipy", + "scikit-learn>=1.2.2", + "scipy>=1.8.0", "seaborn", - "statsmodels", + "statsmodels>=0.14.1", "tabulate", ] @@ -141,7 +141,7 @@ select = [ "E9", # Subset of pycodestyle rules "F", # All Pyflakes rules "NPY", # numpy - "W", + "W", "I", #"PD", # pandas imports #"UP" # Upgrade pythonv versions