diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 9b4c2219..5c989640 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -46,6 +46,7 @@ jobs: shell: bash - uses: prefix-dev/setup-pixi@v0.11.2 + if: matrix.environment != 'ci-sklearn-nightly' with: pixi-version: v0.68.0 environments: ${{ matrix.environment }} @@ -53,6 +54,22 @@ jobs: # latest version time to time. frozen: true + # The nightly environment is installed by hand: the locked pandas nightly + # wheel is dropped from its index after a few days, so it is refreshed + # first. The action accepts no other input together with run-install. + - uses: prefix-dev/setup-pixi@v0.11.2 + if: matrix.environment == 'ci-sklearn-nightly' + with: + pixi-version: v0.68.0 + run-install: false + + - name: Install the nightly environment with the latest pandas nightly + if: matrix.environment == 'ci-sklearn-nightly' + run: | + pixi update --environment ci-sklearn-nightly pandas + pixi install --environment ci-sklearn-nightly --frozen + shell: bash + - name: Linters run: pixi run -e lint lint diff --git a/docs/changes.rst b/docs/changes.rst index 2fdc1539..abd9910d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -21,6 +21,25 @@ v0.17 with an error while it is read, before anything in it is audited or constructed, instead of failing with an unrelated error, or being accepted, during construction. :pr:`547` by `Adrin Jalali`_. +- Add support for pandas objects: :class:`~pandas.DataFrame`, + :class:`~pandas.Series`, every kind of :class:`~pandas.Index`, and the + extension arrays and extension dtypes of pandas itself, not those of other + libraries, can now be saved and loaded. They are stored as + the numpy arrays and scalars they are made of and rebuilt through the public + pandas constructors, so no pandas internals end up in the file. They are + not trusted by default: the pandas types a file holds, which + :func:`skops.io.get_untrusted_types` lists, have to be passed as + ``trusted``. The scalars :class:`~pandas.Timestamp`, + :class:`~pandas.Timedelta` and ``pandas.NA`` are saved as well. The pyarrow + backed arrays and dtypes, those with an ``ArrowDtype``, are not supported + yet; the string dtype with pyarrow storage is, as a string dtype. + Estimators from other libraries that keep pandas objects + in their fitted attributes, such as ``category_encoders``, can now be + persisted. A file written with one pandas version loads with any other from + 2.0 on, keeping the dtypes of the version that wrote it. Not preserved are + the ``freq`` of datetime-like indexes and arrays, the ``attrs`` and ``flags`` + of a Series or DataFrame, and the storage, python or pyarrow, of a string + dtype. :pr:`552` by `Adrin Jalali`_. - Fix a regression since v0.12.0 where saving an object whose ``__reduce__`` raises failed at dump time. ``__reduce__`` is called on every object to detect a plain constructor call, but Cython extension types with a diff --git a/docs/persistence.rst b/docs/persistence.rst index 5c75d657..b6207286 100644 --- a/docs/persistence.rst +++ b/docs/persistence.rst @@ -250,7 +250,22 @@ Supported libraries Skops intends to support all of **scikit-learn**, that is, not only its estimators, but also other classes like cross validation splitters. Furthermore, most types from **numpy** and **scipy** should be supported, such as (sparse) -arrays, dtypes, random generators, and ufuncs. +arrays, dtypes, random generators, and ufuncs. **pandas** objects, that is +``DataFrame``, ``Series``, every kind of ``Index``, and the extension arrays +and extension dtypes of pandas itself, not those of other libraries, are +supported as well with pandas 2.0 or later: they are +stored as the arrays they are made of and rebuilt through the public pandas +constructors, so that no pandas internals end up in the file, and a file +written with one pandas version loads with any other. They are not trusted by +default: the pandas types a file holds, which +:func:`skops.io.get_untrusted_types` lists, have to be passed as ``trusted``. +The scalars ``Timestamp``, ``Timedelta`` and ``NA`` are supported as well. The +pyarrow backed arrays and dtypes, those with an ``ArrowDtype``, are not +supported yet; the string dtype with pyarrow storage is, as a string dtype. +Not preserved are the +``freq`` of datetime-like indexes and arrays, the ``attrs`` and ``flags`` of a +``Series`` or ``DataFrame``, and the storage, python or pyarrow, of a string +dtype, which is an environment choice over the same values. Apart from this core, we plan to support machine learning libraries commonly used be the community. So far, we have tested the following libraries: diff --git a/docs/requirements.txt b/docs/requirements.txt index 3c5b8c06..6cac6248 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ # to be synced with the versions in pyproject.toml matplotlib>=3.3 -pandas>=1 +pandas>=2 fairlearn>=0.7.0 sphinx>=3.2.0 sphinx-gallery>=0.7.0 diff --git a/pixi.lock b/pixi.lock index 15aa943e..84f42369 100644 --- a/pixi.lock +++ b/pixi.lock @@ -117,7 +117,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-heb1ab33_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.4-h781a0a9_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pandas-3.0.5-py312h8ecdadd_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pandoc-3.11-ha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-h8b3dc9c_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pillow-12.3.0-py312h38079b3_4.conda @@ -210,6 +209,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/99/c7/bd05c5c430feb347aa040fcc8870135d70b256718deee9bc7d2ca74a77ff/xgboost-3.4.1-py3-none-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/be/2e6798ace5cc036f5d05d36b7b2fd85346f1a708c87060890b070d0ec607/prettytable-3.18.0-py3-none-any.whl + - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/pandas/3.2.0.dev0+83.g71e11934c8/pandas-3.2.0.dev0+83.g71e11934c8-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scikit-learn/1.10.dev0/scikit_learn-1.10.dev0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scipy/2.0.0.dev0/scipy-2.0.0.dev0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl osx-64: @@ -307,7 +307,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/numpy-2.5.3-py314he0ba898_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/openjpeg-2.5.4-he4eae51_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.4-h332eb6d_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/pandas-3.0.5-py314h99bb933_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/pandoc-3.11-h694c41f_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/pcre2-10.47-h31793e3_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/pillow-12.3.0-py314h909093e_4.conda @@ -335,6 +334,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cd/05/7213965863cba1ed0150ad045bceed6276a1afaaaedbaeff4699ec4f0ccb/lightgbm-4.7.0-py3-none-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fe/be/2e6798ace5cc036f5d05d36b7b2fd85346f1a708c87060890b070d0ec607/prettytable-3.18.0-py3-none-any.whl + - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/pandas/3.2.0.dev0+83.g71e11934c8/pandas-3.2.0.dev0+83.g71e11934c8-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scikit-learn/1.10.dev0/scikit_learn-1.10.dev0-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scipy/2.0.0.dev0/scipy-2.0.0.dev0-cp314-cp314-macosx_10_15_x86_64.whl osx-arm64: @@ -432,7 +432,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.3-py314he06036b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-h4d1e80c_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.4-h55eecbc_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandas-3.0.5-py314he609de1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandoc-3.11-hce30654_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-he63d830_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pillow-12.3.0-py314h709c99d_4.conda @@ -460,6 +459,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f7/94/e5c37a8972ad780edc1d8459d1931356344ca133f7f99ba9cfda516b5bba/xgboost-3.4.1-py3-none-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/fe/be/2e6798ace5cc036f5d05d36b7b2fd85346f1a708c87060890b070d0ec607/prettytable-3.18.0-py3-none-any.whl + - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/pandas/3.2.0.dev0+83.g71e11934c8/pandas-3.2.0.dev0+83.g71e11934c8-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scikit-learn/1.10.dev0/scikit_learn-1.10.dev0-cp314-cp314-macosx_12_0_arm64.whl - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scipy/2.0.0.dev0/scipy-2.0.0.dev0-cp314-cp314-macosx_12_0_arm64.whl win-64: @@ -494,7 +494,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.6.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-9_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-84.0.0-pyh332efcf_0.conda @@ -560,7 +559,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.3-py314h02f10f6_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openjpeg-2.5.4-h90fa87c_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.4-hf411b9b_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pandas-3.0.5-py314hf700ef7_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pandoc-3.11-h57928b3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pcre2-10.47-h8466c1e_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pillow-12.3.0-py314h6acc0e1_4.conda @@ -591,7 +589,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/99/bd/776182bf82b0a3223773a43b0e7b9510afe6c3d32ed8dd5a27b30a89530b/fairlearn-0.14.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/0b/c5c17d862b12ce292f24cd85d40f2f8f8981668fbdbd43fdc2625eccbc79/lightgbm-4.7.0-py3-none-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/be/2e6798ace5cc036f5d05d36b7b2fd85346f1a708c87060890b070d0ec607/prettytable-3.18.0-py3-none-any.whl + - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/pandas/3.2.0.dev0+83.g71e11934c8/pandas-3.2.0.dev0+83.g71e11934c8-cp314-cp314-win_amd64.whl - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scikit-learn/1.10.dev0/scikit_learn-1.10.dev0-cp314-cp314-win_amd64.whl - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scipy/2.0.0.dev0/scipy-2.0.0.dev0-cp314-cp314-win_amd64.whl ci-sklearn12: @@ -5333,11 +5333,13 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/scikit-learn-1.9.0-np2py314hf09ca88_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/scipy-1.18.0-py314hf07bd8e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/statsmodels-0.15.0-np2py314h8874201_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.7-py314h5bd0f2a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py314h9891dd4_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.1-py314h5bd0f2a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.25.0-hd6090a7_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/wrapt-2.4.1-py314hfe1a184_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-cursor-0.1.6-hb03c661_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-image-0.4.0-hb711507_2.conda @@ -5367,6 +5369,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/category_encoders-2.11.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cuda-cudart_linux-64-12.9.79-h3f2d84a_0.conda @@ -5384,9 +5387,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.63.0-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/formulaic-1.2.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/interface_meta-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda @@ -5394,6 +5399,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.22.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/patsy-1.0.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda @@ -5412,6 +5418,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda @@ -5425,6 +5432,7 @@ environments: osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/category_encoders-2.11.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda @@ -5440,9 +5448,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.63.0-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/formulaic-1.2.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/interface_meta-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda @@ -5450,6 +5460,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.22.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/patsy-1.0.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda @@ -5468,6 +5479,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda @@ -5551,10 +5563,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/scikit-learn-1.9.0-np2py314h67cc4f9_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/scipy-1.18.0-py314h5727af0_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/statsmodels-0.15.0-np2py314hc91a65a_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/tornado-6.5.7-py314h217eccc_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ukkonen-1.1.0-py314h473ef84_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/unicodedata2-17.0.1-py314h4f144dc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/wrapt-2.4.1-py314h17af80e_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/xorg-libxau-1.0.12-h8616949_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/xorg-libxdmcp-1.1.5-h8616949_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda @@ -5568,6 +5582,7 @@ environments: osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/category_encoders-2.11.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda @@ -5583,9 +5598,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.63.0-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/formulaic-1.2.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/interface_meta-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda @@ -5593,6 +5610,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.22.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/patsy-1.0.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda @@ -5611,6 +5629,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda @@ -5694,10 +5713,12 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/scikit-learn-1.9.0-np2py314h15f0f0f_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/scipy-1.18.0-py314h18e1515_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/statsmodels-0.15.0-np2py314h40f2a13_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tornado-6.5.7-py314h6c2aa35_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py314h6cfcd04_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/unicodedata2-17.0.1-py314h6c2aa35_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/wrapt-2.4.1-py314hd98292b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxau-1.0.12-hc919400_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxdmcp-1.1.5-hc919400_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda @@ -5710,6 +5731,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fe/be/2e6798ace5cc036f5d05d36b7b2fd85346f1a708c87060890b070d0ec607/prettytable-3.18.0-py3-none-any.whl win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/category_encoders-2.11.1-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda @@ -5725,9 +5747,11 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2 - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/fonttools-4.63.0-pyh7db6752_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/formulaic-1.2.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/interface_meta-1.3.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/joblib-1.5.3-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda @@ -5735,6 +5759,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.22.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/patsy-1.0.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda @@ -5754,6 +5779,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/threadpoolctl-3.6.0-pyhecae5ae_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda @@ -5838,6 +5864,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/quantile-forest-1.4.2-py314h13fbf68_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/scikit-learn-1.9.0-np2py314h1b5b07a_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/scipy-1.18.0-py314h221f224_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/statsmodels-0.15.0-np2py314hea88fa1_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.0.0-hd3d4ead_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py314h5a2d7ad_0.conda @@ -5847,6 +5874,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/wrapt-2.4.1-py314hc5dbbe4_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/xorg-libice-1.1.2-h0e40799_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/xorg-libsm-1.2.6-h0e40799_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/xorg-libx11-1.8.13-hfa52320_0.conda @@ -5959,7 +5987,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pandas-3.0.3-py314hb4ffadd_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pandas-3.0.6-np2py314h9e1d7c2_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pandoc-3.11-ha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py314h8ec4b1a_0.conda @@ -6248,7 +6276,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/numpy-2.5.0-py314h7b24d9b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/openjpeg-2.5.4-h52bb76a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/pandas-3.0.3-py314h99bb933_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pandas-3.0.6-np2py314h1f26284_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/pandoc-3.11-h694c41f_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/pcre2-10.47-h13923f0_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/pillow-12.2.0-py314hc904d5e_0.conda @@ -6423,7 +6451,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.0-py314hb79c6fa_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-hd9e9057_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandas-3.0.3-py314he609de1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandas-3.0.6-np2py314hc3b1fbe_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandoc-3.11-hce30654_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pillow-12.2.0-py314hab283cf_0.conda @@ -6511,7 +6539,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.5-pyh5ded981_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda @@ -6605,7 +6633,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.0.0-h57928b3_908.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openjpeg-2.5.4-h0e57b4f_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pandas-3.0.3-py314hf700ef7_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pandas-3.0.6-np2py314h1b5b07a_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pandoc-3.11-h57928b3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pcre2-10.47-hd2b5f0e_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pillow-12.2.0-py314h61b30b5_0.conda @@ -6734,7 +6762,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openldap-2.6.13-hbde042b_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/pandas-3.0.3-py314hb4ffadd_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py314h8ec4b1a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda @@ -6835,6 +6862,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/99/bd/776182bf82b0a3223773a43b0e7b9510afe6c3d32ed8dd5a27b30a89530b/fairlearn-0.14.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/fe/be/2e6798ace5cc036f5d05d36b7b2fd85346f1a708c87060890b070d0ec607/prettytable-3.18.0-py3-none-any.whl osx-64: @@ -6946,7 +6974,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/numpy-2.5.0-py314h7b24d9b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/openjpeg-2.5.4-h52bb76a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/pandas-3.0.3-py314h99bb933_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/pcre2-10.47-h13923f0_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/pillow-12.2.0-py314hc904d5e_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/pixman-0.46.4-ha059160_1.conda @@ -6964,6 +6991,7 @@ environments: - pypi: ./ - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/bd/776182bf82b0a3223773a43b0e7b9510afe6c3d32ed8dd5a27b30a89530b/fairlearn-0.14.0-py3-none-any.whl @@ -7079,7 +7107,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.0-py314hb79c6fa_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openjpeg-2.5.4-hd9e9057_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandas-3.0.3-py314he609de1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pillow-12.2.0-py314hab283cf_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pixman-0.46.4-h81086ad_1.conda @@ -7096,6 +7123,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl @@ -7135,7 +7163,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda @@ -7221,7 +7248,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.0.0-h57928b3_908.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openjpeg-2.5.4-h0e57b4f_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pandas-3.0.3-py314hf700ef7_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pcre2-10.47-hd2b5f0e_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pillow-12.2.0-py314h61b30b5_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/pixman-0.46.4-h5112557_1.conda @@ -7250,7 +7276,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/99/bd/776182bf82b0a3223773a43b0e7b9510afe6c3d32ed8dd5a27b30a89530b/fairlearn-0.14.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fe/be/2e6798ace5cc036f5d05d36b7b2fd85346f1a708c87060890b070d0ec607/prettytable-3.18.0-py3-none-any.whl lint: channels: @@ -12411,18 +12439,18 @@ packages: run_exports: {} size: 15303815 timestamp: 1778602611222 -- conda: https://conda.anaconda.org/conda-forge/linux-64/pandas-3.0.5-py312h8ecdadd_1.conda - sha256: 393e529c0574c020a9b790275af10ff35e21cbb4e12740888bd3f214f2f07034 - md5: 85eb29ade84d1bd97be5ec55607efb00 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pandas-3.0.6-np2py314h9e1d7c2_0.conda + sha256: 68a1803d2f72f6911f2cf2a9ea0eae483fc494a09d9feb8da81f03757795c4da + md5: e0f55ab2a87a7f0483a633ac39557098 depends: - python - numpy >=1.26.0 - python-dateutil >=2.8.2 - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - libstdcxx >=14 - - numpy >=1.23,<3 - - python_abi 3.12.* *_cp312 + - libstdcxx >=15 + - libgcc >=15 + - numpy >=1.25,<3 + - python_abi 3.14.* *_cp314 constrains: - adbc-driver-postgresql >=1.2.0 - adbc-driver-sqlite >=1.2.0 @@ -12465,10 +12493,10 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/pandas?source=hash-mapping + - pkg:pypi/pandas?source=compressed-mapping run_exports: {} - size: 14936796 - timestamp: 1785276227779 + size: 15425802 + timestamp: 1789803361338 - conda: https://conda.anaconda.org/conda-forge/linux-64/pandoc-3.11-ha770c72_0.conda sha256: cb14839e33fbbb2163f259476091dd99f9e83fbb1690b789cd1e6e6a05d17db7 md5: 2c71f77db75fa5e9dd64abb706c66788 @@ -14001,6 +14029,28 @@ packages: run_exports: {} size: 17260022 timestamp: 1781912924009 +- conda: https://conda.anaconda.org/conda-forge/linux-64/statsmodels-0.15.0-np2py314h8874201_2.conda + sha256: b31c70966a738e052eeda0bb5e6bb711c6a04bc6c75875e13b89bcaadd8c2ed5 + md5: f7ce33f5fb4b72ccfdadcd68571b7a81 + depends: + - python + - numpy >=1.23.5,<3 + - scipy >=1.8,!=1.9.2 + - pandas >=1.4,!=2.1.0 + - patsy >=0.5.6 + - packaging >=21.3 + - formulaic >=1.1.0 + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + - numpy >=1.25,<3 + - python_abi 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/statsmodels?source=compressed-mapping + run_exports: {} + size: 14186732 + timestamp: 1790033647794 - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h1df4ec4_4.conda build_number: 104 sha256: a1a241d172c1ccab067ba245206dd048bc3c2d1b84504b53c9468e99adfc16a1 @@ -14383,6 +14433,21 @@ packages: - wayland >=1.26.0,<2.0a0 size: 340058 timestamp: 1787793849093 +- conda: https://conda.anaconda.org/conda-forge/linux-64/wrapt-2.4.1-py314hfe1a184_0.conda + sha256: 1d706a003b2bdee04784a8fac90a14e6f00c3dca91bf4137a5f6db50c358bd42 + md5: 4f79d07d0f466843e04c7a54bd63d6d7 + depends: + - python + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/wrapt?source=compressed-mapping + run_exports: {} + size: 149268 + timestamp: 1789986014273 - conda: https://conda.anaconda.org/conda-forge/linux-64/xcb-util-0.4.1-h4f16b4b_2.conda sha256: ad8cab7e07e2af268449c2ce855cbb51f43f4664936eff679b1f3862e6e4b01d md5: fdc27cb255a7a2cc73b7919a968b48f0 @@ -15138,6 +15203,24 @@ packages: run_exports: {} size: 131780 timestamp: 1784754889428 +- conda: https://conda.anaconda.org/conda-forge/noarch/category_encoders-2.11.1-pyh5ded981_0.conda + sha256: 93dd5e34d287e264fbe548a375a7bb512dcc39d75fe93db4c947127094a347a9 + md5: e0b2a8381b5cb1f6c53e5756180ea258 + depends: + - numpy >=1.14.0 + - pandas >=1.0.5 + - python >=3.11 + - scikit-learn >=1.6.0 + - scipy >=1.0.0 + - statsmodels >=0.9.0 + - python + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/category-encoders?source=hash-mapping + run_exports: {} + size: 123562 + timestamp: 1788904404770 - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2025.8.3-pyhd8ed1ab_0.conda sha256: a1ad5b0a2a242f439608f22a538d2175cac4444b7b3f4e2b8c090ac337aaea40 md5: 11f59985f49df4620890f3e746ed7102 @@ -15544,6 +15627,29 @@ packages: run_exports: {} size: 846038 timestamp: 1778770337113 +- conda: https://conda.anaconda.org/conda-forge/noarch/formulaic-1.2.2-pyhd8ed1ab_0.conda + sha256: a6e065128ef1f2b6b2636268409155c1013460babb619e6e8415686305e776ea + md5: f18f43fb3e20ab309d46faebfb3348b0 + depends: + - interface_meta >=1.2 + - narwhals >=1.17 + - numpy >=1.20 + - pandas >=1.3 + - python >=3.10 + - scipy >=1.6 + - typing-extensions >=4.2 + - wrapt >=1.17 + constrains: + - polars >=1 + - pyarrow >=1 + - sympy >=1.3,!=1.10 + license: MIT + license_family: MIT + purls: + - pkg:pypi/formulaic?source=hash-mapping + run_exports: {} + size: 90142 + timestamp: 1780453843138 - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.2.0-pyhd8ed1ab_0.conda sha256: 0aa1cdc67a9fe75ea95b5644b734a756200d6ec9d0dff66530aec3d1c1e9df75 md5: b4754fb1bdcb70c8fd54f918301582c6 @@ -15726,6 +15832,17 @@ packages: run_exports: {} size: 13387 timestamp: 1760831448842 +- conda: https://conda.anaconda.org/conda-forge/noarch/interface_meta-1.3.0-pyhd8ed1ab_1.conda + sha256: cdd4377e9565f383e3e69f26b43fc1abc892fda591fa724af508e498c2dc0859 + md5: 97f07ae607e01877f0454fce4c833e40 + depends: + - python >=3.9 + license: MIT + license_family: MIT + purls: + - pkg:pypi/interface-meta?source=hash-mapping + size: 18422 + timestamp: 1734284523576 - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.14.1-pyh53cf698_0.conda sha256: a3f76e06c31bcf1bda0f633d5c9f1c834286b4f6decc6626067a6cffee283318 md5: fbd58549b374103c1a80577f09a328ef @@ -16009,6 +16126,20 @@ packages: run_exports: {} size: 82472 timestamp: 1777722955579 +- conda: https://conda.anaconda.org/conda-forge/noarch/patsy-1.0.3-pyhcf101f3_0.conda + sha256: d204290be1e2d895ebd945ffd310ff00ea6a7670fe20a92a04ade7d197cdadf5 + md5: 13e1355debaf21a0b8c2726697ebb01b + depends: + - numpy >=1.4.0 + - packaging + - python >=3.10 + - python + license: BSD-2-Clause AND PSF-2.0 + purls: + - pkg:pypi/patsy?source=hash-mapping + run_exports: {} + size: 193838 + timestamp: 1788106018957 - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda sha256: 202af1de83b585d36445dc1fda94266697341994d1a3328fabde4989e1b3d07a md5: d0d408b1f18883a944376da5cf8101ea @@ -16491,18 +16622,19 @@ packages: run_exports: {} size: 146639 timestamp: 1777068997932 -- conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - sha256: 3f05db78cf8be33cf6dbc469664b8e3a01f3980d61d6d6bef48669b171896d8a - md5: eefc8d916bd2e708d76d40398ef9a1ee +- conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.5-pyh5ded981_0.conda + sha256: b66b783be882069a9f6c00bc44ee561fc3dfba21fe4bab6de31599caf3c63154 + md5: 4a0ae2a06b469e6f8ca95d0655e31c86 depends: - - python >=3.10 + - python >=3.11 + - python license: Apache-2.0 license_family: APACHE purls: - - pkg:pypi/tzdata?source=hash-mapping + - pkg:pypi/tzdata?source=compressed-mapping run_exports: {} - size: 146862 - timestamp: 1783704822814 + size: 141379 + timestamp: 1791025923003 - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.10-8_cp310.conda build_number: 8 sha256: 7ad76fa396e4bde336872350124c0819032a9e8a0a40590744ff9527b54351c1 @@ -17037,6 +17169,17 @@ packages: run_exports: {} size: 115158 timestamp: 1780507822178 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda + sha256: 7c2df5721c742c2a47b2c8f960e718c930031663ac1174da67c1ed5999f7938c + md5: edd329d7d3a4ab45dcf905899a7a6115 + depends: + - typing_extensions ==4.15.0 pyhcf101f3_0 + license: PSF-2.0 + license_family: PSF + purls: [] + run_exports: {} + size: 91383 + timestamp: 1756220668932 - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.14.1-pyhe01879c_0.conda sha256: 4f52390e331ea8b9019b87effaebc4f80c6466d09f68453f52d5cdc2a3e1194f md5: e523f4f1e980ed7a4240d7e27e9ec81f @@ -20495,17 +20638,17 @@ packages: run_exports: {} size: 14597208 timestamp: 1778602856255 -- conda: https://conda.anaconda.org/conda-forge/osx-64/pandas-3.0.5-py314h99bb933_1.conda - sha256: 7910c507692b67567627edbed6ff0ce80bb800b704bd0b238729e4c64d9fbc4b - md5: 7c7abc0faf169efe0e103c62f2824857 +- conda: https://conda.anaconda.org/conda-forge/osx-64/pandas-3.0.6-np2py314h1f26284_0.conda + sha256: a6bb85c11ead049fb94e9e6cc8d56f6009ec18e57479ea39e8c73fdaedcac21d + md5: 7fa1ed5a832fcd262d8b812a8c988540 depends: - python - numpy >=1.26.0 - python-dateutil >=2.8.2 - - libcxx >=19 + - libcxx >=21 - __osx >=11.0 - - numpy >=1.23,<3 - python_abi 3.14.* *_cp314 + - numpy >=1.25,<3 constrains: - adbc-driver-postgresql >=1.2.0 - adbc-driver-sqlite >=1.2.0 @@ -20548,10 +20691,10 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/pandas?source=hash-mapping + - pkg:pypi/pandas?source=compressed-mapping run_exports: {} - size: 14639133 - timestamp: 1785276464478 + size: 14753057 + timestamp: 1789803514551 - conda: https://conda.anaconda.org/conda-forge/osx-64/pandoc-3.11-h694c41f_0.conda sha256: 0ef2d60b6d9ae28d4e70fae3fbb716e751db77743b6f5d18dc298fe13e280827 md5: 932c47502f555da85dcf6f562b3e36a5 @@ -21576,6 +21719,27 @@ packages: run_exports: {} size: 15667374 timestamp: 1781913667133 +- conda: https://conda.anaconda.org/conda-forge/osx-64/statsmodels-0.15.0-np2py314hc91a65a_2.conda + sha256: 8c115627f8aa864727e23f85a7858d9cef57ee88df4391967441318c09648d5d + md5: f2bce35ac57139912e622ceef1f20d20 + depends: + - python + - numpy >=1.23.5,<3 + - scipy >=1.8,!=1.9.2 + - pandas >=1.4,!=2.1.0 + - patsy >=0.5.6 + - packaging >=21.3 + - formulaic >=1.1.0 + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + - numpy >=1.25,<3 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/statsmodels?source=compressed-mapping + run_exports: {} + size: 13777604 + timestamp: 1790033664701 - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda sha256: 7f0d9c320288532873e2d8486c331ec6d87919c9028208d3f6ac91dc8f99a67b md5: 6e6efb7463f8cef69dbcb4c2205bf60e @@ -21867,6 +22031,20 @@ packages: run_exports: {} size: 406478 timestamp: 1770909238815 +- conda: https://conda.anaconda.org/conda-forge/osx-64/wrapt-2.4.1-py314h17af80e_0.conda + sha256: dda78e5462fb86a2682d00dee62927d3c791ba8e4fbc0851c3460c571fd9db78 + md5: fef84b3d0694187c6c4642a3ebf78882 + depends: + - python + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/wrapt?source=compressed-mapping + run_exports: {} + size: 144915 + timestamp: 1789986067548 - conda: https://conda.anaconda.org/conda-forge/osx-64/xorg-libxau-1.0.12-h8616949_1.conda sha256: 928f28bd278c7da674b57d71b2e7f4ac4e7c7ce56b0bf0f60d6a074366a2e76d md5: 47f1b8b4a76ebd0cd22bd7153e54a4dc @@ -25343,18 +25521,17 @@ packages: run_exports: {} size: 14368928 timestamp: 1778602917992 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandas-3.0.5-py314he609de1_0.conda - sha256: 9db72f83b107fab7393e02c4005003c97c7a7aaffc33417e78aadb5a82056cd5 - md5: b1dde16791eca59c8316d0ad1487bff0 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandas-3.0.6-np2py314hc3b1fbe_0.conda + sha256: 3e47df60710e7a8f03357d707d18b2e614c5e01ef9e6f79e40e15842a850336f + md5: 4364f79cbf78bb7d745f1ebc9c72bf64 depends: - python - numpy >=1.26.0 - python-dateutil >=2.8.2 - - python 3.14.* *_cp314 - - libcxx >=19 - __osx >=11.0 - - numpy >=1.23,<3 + - libcxx >=21 - python_abi 3.14.* *_cp314 + - numpy >=1.25,<3 constrains: - adbc-driver-postgresql >=1.2.0 - adbc-driver-sqlite >=1.2.0 @@ -25397,10 +25574,10 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/pandas?source=hash-mapping + - pkg:pypi/pandas?source=compressed-mapping run_exports: {} - size: 14402418 - timestamp: 1784821943837 + size: 14475966 + timestamp: 1789803428830 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pandoc-3.11-hce30654_0.conda sha256: 1520d26e93867d62aa105d6f50e279c446d4bcd79f8db8d5b6a8442b148e8389 md5: a2f9df29541bc9c9ec08950fcedfea84 @@ -26455,6 +26632,27 @@ packages: run_exports: {} size: 14122215 timestamp: 1781912992503 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/statsmodels-0.15.0-np2py314h40f2a13_2.conda + sha256: 6762596d9b868d6dfb84e62feb1cd7a29d7b6bcbc38b54afdeac2d6875879413 + md5: ad5201d6bc5426933389149c98c7f08a + depends: + - python + - numpy >=1.23.5,<3 + - scipy >=1.8,!=1.9.2 + - pandas >=1.4,!=2.1.0 + - patsy >=0.5.6 + - packaging >=21.3 + - formulaic >=1.1.0 + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + - numpy >=1.25,<3 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/statsmodels?source=compressed-mapping + run_exports: {} + size: 13740298 + timestamp: 1790033597056 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda sha256: 799cab4b6cde62f91f750149995d149bc9db525ec12595e8a1d91b9317f038b3 md5: a9d86bc62f39b94c4661716624eb21b0 @@ -26764,6 +26962,20 @@ packages: run_exports: {} size: 416130 timestamp: 1770909728445 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/wrapt-2.4.1-py314hd98292b_0.conda + sha256: 149b00f33024a5e25acc59195ecda340672ad3a17701b911a986a225ed6ade06 + md5: d36dc009656ba3dd1dcfed7890d7ac3b + depends: + - python + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/wrapt?source=compressed-mapping + run_exports: {} + size: 144486 + timestamp: 1789986025741 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xorg-libxau-1.0.12-hb001647_2.conda sha256: cde7cecabb662b435e6537591e5845e870a0648887542d3e538c9f6c20dd6be5 md5: 610f557e75d6a0d3f676914da5822227 @@ -30708,9 +30920,9 @@ packages: run_exports: {} size: 14062915 timestamp: 1778602665890 -- conda: https://conda.anaconda.org/conda-forge/win-64/pandas-3.0.5-py314hf700ef7_1.conda - sha256: 399c1d7f7918986d9c1a646f4e789ac24749149598d16b529febc3361e9aad23 - md5: a519b50147401cf6bad2fd188028ad38 +- conda: https://conda.anaconda.org/conda-forge/win-64/pandas-3.0.6-np2py314h1b5b07a_0.conda + sha256: 928402fb898126ef23d79baafa389b6e967653bb58a72e7c5d8aacb0c37051bd + md5: 2dabbff823b8623a67918f18b75d0e0a depends: - python - numpy >=1.26.0 @@ -30720,7 +30932,7 @@ packages: - vc14_runtime >=14.44.35208 - ucrt >=10.0.20348.0 - python_abi 3.14.* *_cp314 - - numpy >=1.23,<3 + - numpy >=1.25,<3 constrains: - adbc-driver-postgresql >=1.2.0 - adbc-driver-sqlite >=1.2.0 @@ -30763,10 +30975,10 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/pandas?source=hash-mapping + - pkg:pypi/pandas?source=compressed-mapping run_exports: {} - size: 14103773 - timestamp: 1785276287020 + size: 14059614 + timestamp: 1789803361326 - conda: https://conda.anaconda.org/conda-forge/win-64/pandoc-3.11-h57928b3_0.conda sha256: 03b03e1c841f25e738bafe60e20cae65a98951683f14e72aae6a8ac359e0501c md5: 4587e6544ff96ef0ee50b042f9d3719d @@ -32126,6 +32338,29 @@ packages: run_exports: {} size: 15353018 timestamp: 1781914001107 +- conda: https://conda.anaconda.org/conda-forge/win-64/statsmodels-0.15.0-np2py314hea88fa1_2.conda + sha256: 13b1e307d29164a9f5d18de89cb8e3281026b0e514463fe0b1f05a2987ec8134 + md5: 9fc91bab302b183bcf3d6fee97e0a368 + depends: + - python + - numpy >=1.23.5,<3 + - scipy >=1.8,!=1.9.2 + - pandas >=1.4,!=2.1.0 + - patsy >=0.5.6 + - packaging >=21.3 + - formulaic >=1.1.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.14.* *_cp314 + - numpy >=1.25,<3 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/statsmodels?source=compressed-mapping + run_exports: {} + size: 13612207 + timestamp: 1790033589104 - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2021.13.0-h62715c5_1.conda sha256: 03cc5442046485b03dd1120d0f49d35a7e522930a2ab82f275e938e17b07b302 md5: 9190dd0a23d925f7602f9628b3aed511 @@ -32610,6 +32845,22 @@ packages: run_exports: {} size: 20355 timestamp: 1781320968804 +- conda: https://conda.anaconda.org/conda-forge/win-64/wrapt-2.4.1-py314hc5dbbe4_0.conda + sha256: 87917bc5e8fa8cc18432c07ac8b1d7348df4cad957d741f8c618dd8f667f6ab8 + md5: 2c0ae1b4889d3ff02546a45ac3df3b8e + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.14.* *_cp314 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/wrapt?source=compressed-mapping + run_exports: {} + size: 142909 + timestamp: 1789986015962 - conda: https://conda.anaconda.org/conda-forge/win-64/xorg-kbproto-1.0.7-hcd874cb_1002.tar.bz2 sha256: 5b16e1ca1ecc0d2907f236bc4d8e6ecfd8417db013c862a01afb7f9d78e48c09 md5: 8d11c1dac4756ca57e78c1bfe173bba4 @@ -33186,6 +33437,96 @@ packages: version: 3.6.0 sha256: 43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl + name: pandas + version: 3.0.6 + sha256: ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729 + requires_dist: + - numpy>=1.26.0 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.8.2 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - hypothesis>=6.116.0 ; extra == 'test' + - pytest>=8.3.4,<9.1 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - bottleneck>=1.4.2 ; extra == 'performance' + - numba>=0.60.0 ; extra == 'performance' + - numexpr>=2.10.2 ; extra == 'performance' + - scipy>=1.14.1 ; extra == 'computation' + - xarray>=2024.10.0 ; extra == 'computation' + - fsspec>=2024.10.0 ; extra == 'fss' + - s3fs>=2024.10.0 ; extra == 'aws' + - gcsfs>=2024.10.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.3.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.1 ; extra == 'excel' + - xlsxwriter>=3.2.0 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.8.1 ; extra == 'iceberg' + - tables>=3.10.1 ; extra == 'hdf5' + - pyreadstat>=1.2.8 ; extra == 'spss' + - sqlalchemy>=2.0.36 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.36 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.36 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' + - beautifulsoup4>=4.12.3 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'xml' + - matplotlib>=3.9.3 ; extra == 'plot' + - jinja2>=3.1.5 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.9 ; extra == 'clipboard' + - qtpy>=2.4.2 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.2.0 ; extra == 'all' + - adbc-driver-sqlite>=1.2.0 ; extra == 'all' + - beautifulsoup4>=4.12.3 ; extra == 'all' + - bottleneck>=1.4.2 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2024.10.0 ; extra == 'all' + - gcsfs>=2024.10.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - hypothesis>=6.116.0 ; extra == 'all' + - jinja2>=3.1.5 ; extra == 'all' + - lxml>=5.3.0 ; extra == 'all' + - matplotlib>=3.9.3 ; extra == 'all' + - numba>=0.60.0 ; extra == 'all' + - numexpr>=2.10.2 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=13.0.0 ; extra == 'all' + - pyiceberg>=0.8.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.9 ; extra == 'all' + - pyreadstat>=1.2.8 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.3.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.2 ; extra == 'all' + - scipy>=1.14.1 ; extra == 'all' + - s3fs>=2024.10.0 ; extra == 'all' + - sqlalchemy>=2.0.36 ; extra == 'all' + - tables>=3.10.1 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2024.10.0 ; extra == 'all' + - xlrd>=2.0.1 ; extra == 'all' + - xlsxwriter>=3.2.0 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl name: scikit-learn version: 1.9.0 @@ -33389,6 +33730,96 @@ packages: - pandas>=0.24.0 ; extra == 'pandas' - scikit-learn>=0.24.2 ; extra == 'scikit-learn' requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl + name: pandas + version: 3.0.6 + sha256: ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1 + requires_dist: + - numpy>=1.26.0 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.8.2 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - hypothesis>=6.116.0 ; extra == 'test' + - pytest>=8.3.4,<9.1 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - bottleneck>=1.4.2 ; extra == 'performance' + - numba>=0.60.0 ; extra == 'performance' + - numexpr>=2.10.2 ; extra == 'performance' + - scipy>=1.14.1 ; extra == 'computation' + - xarray>=2024.10.0 ; extra == 'computation' + - fsspec>=2024.10.0 ; extra == 'fss' + - s3fs>=2024.10.0 ; extra == 'aws' + - gcsfs>=2024.10.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.3.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.1 ; extra == 'excel' + - xlsxwriter>=3.2.0 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.8.1 ; extra == 'iceberg' + - tables>=3.10.1 ; extra == 'hdf5' + - pyreadstat>=1.2.8 ; extra == 'spss' + - sqlalchemy>=2.0.36 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.36 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.36 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' + - beautifulsoup4>=4.12.3 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'xml' + - matplotlib>=3.9.3 ; extra == 'plot' + - jinja2>=3.1.5 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.9 ; extra == 'clipboard' + - qtpy>=2.4.2 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.2.0 ; extra == 'all' + - adbc-driver-sqlite>=1.2.0 ; extra == 'all' + - beautifulsoup4>=4.12.3 ; extra == 'all' + - bottleneck>=1.4.2 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2024.10.0 ; extra == 'all' + - gcsfs>=2024.10.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - hypothesis>=6.116.0 ; extra == 'all' + - jinja2>=3.1.5 ; extra == 'all' + - lxml>=5.3.0 ; extra == 'all' + - matplotlib>=3.9.3 ; extra == 'all' + - numba>=0.60.0 ; extra == 'all' + - numexpr>=2.10.2 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=13.0.0 ; extra == 'all' + - pyiceberg>=0.8.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.9 ; extra == 'all' + - pyreadstat>=1.2.8 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.3.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.2 ; extra == 'all' + - scipy>=1.14.1 ; extra == 'all' + - s3fs>=2024.10.0 ; extra == 'all' + - sqlalchemy>=2.0.36 ; extra == 'all' + - tables>=3.10.1 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2024.10.0 ; extra == 'all' + - xlrd>=2.0.1 ; extra == 'all' + - xlsxwriter>=3.2.0 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl name: scipy version: 1.18.0 @@ -33720,6 +34151,96 @@ packages: - scikit-learn ; extra == 'pyspark' - scikit-learn ; extra == 'scikit-learn' requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl + name: pandas + version: 3.0.6 + sha256: 77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553 + requires_dist: + - numpy>=1.26.0 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.8.2 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - hypothesis>=6.116.0 ; extra == 'test' + - pytest>=8.3.4,<9.1 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - bottleneck>=1.4.2 ; extra == 'performance' + - numba>=0.60.0 ; extra == 'performance' + - numexpr>=2.10.2 ; extra == 'performance' + - scipy>=1.14.1 ; extra == 'computation' + - xarray>=2024.10.0 ; extra == 'computation' + - fsspec>=2024.10.0 ; extra == 'fss' + - s3fs>=2024.10.0 ; extra == 'aws' + - gcsfs>=2024.10.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.3.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.1 ; extra == 'excel' + - xlsxwriter>=3.2.0 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.8.1 ; extra == 'iceberg' + - tables>=3.10.1 ; extra == 'hdf5' + - pyreadstat>=1.2.8 ; extra == 'spss' + - sqlalchemy>=2.0.36 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.36 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.36 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' + - beautifulsoup4>=4.12.3 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'xml' + - matplotlib>=3.9.3 ; extra == 'plot' + - jinja2>=3.1.5 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.9 ; extra == 'clipboard' + - qtpy>=2.4.2 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.2.0 ; extra == 'all' + - adbc-driver-sqlite>=1.2.0 ; extra == 'all' + - beautifulsoup4>=4.12.3 ; extra == 'all' + - bottleneck>=1.4.2 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2024.10.0 ; extra == 'all' + - gcsfs>=2024.10.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - hypothesis>=6.116.0 ; extra == 'all' + - jinja2>=3.1.5 ; extra == 'all' + - lxml>=5.3.0 ; extra == 'all' + - matplotlib>=3.9.3 ; extra == 'all' + - numba>=0.60.0 ; extra == 'all' + - numexpr>=2.10.2 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=13.0.0 ; extra == 'all' + - pyiceberg>=0.8.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.9 ; extra == 'all' + - pyreadstat>=1.2.8 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.3.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.2 ; extra == 'all' + - scipy>=1.14.1 ; extra == 'all' + - s3fs>=2024.10.0 ; extra == 'all' + - sqlalchemy>=2.0.36 ; extra == 'all' + - tables>=3.10.1 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2024.10.0 ; extra == 'all' + - xlrd>=2.0.1 ; extra == 'all' + - xlsxwriter>=3.2.0 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl name: wcwidth version: 0.8.1 @@ -33749,6 +34270,96 @@ packages: - scikit-learn ; extra == 'pyspark' - scikit-learn ; extra == 'scikit-learn' requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl + name: pandas + version: 3.0.6 + sha256: 62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1 + requires_dist: + - numpy>=1.26.0 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.8.2 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - hypothesis>=6.116.0 ; extra == 'test' + - pytest>=8.3.4,<9.1 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - bottleneck>=1.4.2 ; extra == 'performance' + - numba>=0.60.0 ; extra == 'performance' + - numexpr>=2.10.2 ; extra == 'performance' + - scipy>=1.14.1 ; extra == 'computation' + - xarray>=2024.10.0 ; extra == 'computation' + - fsspec>=2024.10.0 ; extra == 'fss' + - s3fs>=2024.10.0 ; extra == 'aws' + - gcsfs>=2024.10.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.3.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.1 ; extra == 'excel' + - xlsxwriter>=3.2.0 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.8.1 ; extra == 'iceberg' + - tables>=3.10.1 ; extra == 'hdf5' + - pyreadstat>=1.2.8 ; extra == 'spss' + - sqlalchemy>=2.0.36 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.36 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.36 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other' + - beautifulsoup4>=4.12.3 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'html' + - lxml>=5.3.0 ; extra == 'xml' + - matplotlib>=3.9.3 ; extra == 'plot' + - jinja2>=3.1.5 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.9 ; extra == 'clipboard' + - qtpy>=2.4.2 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.2.0 ; extra == 'all' + - adbc-driver-sqlite>=1.2.0 ; extra == 'all' + - beautifulsoup4>=4.12.3 ; extra == 'all' + - bottleneck>=1.4.2 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2024.10.0 ; extra == 'all' + - gcsfs>=2024.10.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - hypothesis>=6.116.0 ; extra == 'all' + - jinja2>=3.1.5 ; extra == 'all' + - lxml>=5.3.0 ; extra == 'all' + - matplotlib>=3.9.3 ; extra == 'all' + - numba>=0.60.0 ; extra == 'all' + - numexpr>=2.10.2 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=13.0.0 ; extra == 'all' + - pyiceberg>=0.8.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.9 ; extra == 'all' + - pyreadstat>=1.2.8 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.3.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.2 ; extra == 'all' + - scipy>=1.14.1 ; extra == 'all' + - s3fs>=2024.10.0 ; extra == 'all' + - sqlalchemy>=2.0.36 ; extra == 'all' + - tables>=3.10.1 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2024.10.0 ; extra == 'all' + - xlrd>=2.0.1 ; extra == 'all' + - xlsxwriter>=3.2.0 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' - pypi: https://files.pythonhosted.org/packages/cd/05/7213965863cba1ed0150ad045bceed6276a1afaaaedbaeff4699ec4f0ccb/lightgbm-4.7.0-py3-none-macosx_10_15_x86_64.whl name: lightgbm version: 4.7.0 @@ -34048,6 +34659,11 @@ packages: - scikit-learn ; extra == 'pyspark' - cloudpickle ; extra == 'pyspark' requires_python: '>=3.12' +- pypi: https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl + name: tzdata + version: '2026.4' + sha256: c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81 + requires_python: '>=2' - pypi: https://files.pythonhosted.org/packages/fc/72/3b68983c0215ef65d48e9eeb1f168c3c6e3d62a61ece605de3209c79cae1/xgboost-3.3.0-py3-none-macosx_10_15_x86_64.whl name: xgboost version: 3.3.0 @@ -34077,61 +34693,413 @@ packages: - pytest-lazy-fixtures ; extra == 'tests' - pytest>=9 ; extra == 'tests' requires_python: '>=3.10' +- pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/pandas/3.2.0.dev0+83.g71e11934c8/pandas-3.2.0.dev0+83.g71e11934c8-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl + name: pandas + version: 3.2.0.dev0+83.g71e11934c8 + index: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple + requires_dist: + - numpy>=2.0.2 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.9.0 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - pytest>=8.3.4 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=16.0.0 ; extra == 'pyarrow' + - bottleneck>=1.5.0 ; extra == 'performance' + - numba>=0.61.2 ; extra == 'performance' + - numexpr>=2.11.0,!=2.14.1 ; extra == 'performance' + - scipy>=1.16.1 ; extra == 'computation' + - xarray>=2025.7.1 ; extra == 'computation' + - fsspec>=2025.7.0 ; extra == 'fss' + - s3fs>=2025.7.0 ; extra == 'aws' + - gcsfs>=2025.7.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.4.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.2 ; extra == 'excel' + - xlsxwriter>=3.2.5 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.9.1 ; extra == 'iceberg' + - tables>=3.10.2 ; extra == 'hdf5' + - pyreadstat>=1.3.0 ; extra == 'spss' + - sqlalchemy>=2.0.42 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.7.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.42 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.42 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.7.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.7.0 ; extra == 'sql-other' + - beautifulsoup4>=4.13.4 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=6.0.0 ; extra == 'html' + - lxml>=6.0.0 ; extra == 'xml' + - matplotlib>=3.10.5 ; extra == 'plot' + - jinja2>=3.1.6 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.11 ; extra == 'clipboard' + - qtpy>=2.4.3 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.7.0 ; extra == 'all' + - adbc-driver-sqlite>=1.7.0 ; extra == 'all' + - beautifulsoup4>=4.13.4 ; extra == 'all' + - bottleneck>=1.5.0 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2025.7.0 ; extra == 'all' + - gcsfs>=2025.7.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - jinja2>=3.1.6 ; extra == 'all' + - lxml>=6.0.0 ; extra == 'all' + - matplotlib>=3.10.5 ; extra == 'all' + - numba>=0.61.2 ; extra == 'all' + - numexpr>=2.11.0,!=2.14.1 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=16.0.0 ; extra == 'all' + - pyiceberg>=0.9.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.11 ; extra == 'all' + - pyreadstat>=1.3.0 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.4.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.3 ; extra == 'all' + - scipy>=1.16.1 ; extra == 'all' + - s3fs>=2025.7.0 ; extra == 'all' + - sqlalchemy>=2.0.42 ; extra == 'all' + - tables>=3.10.2 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2025.7.1 ; extra == 'all' + - xlrd>=2.0.2 ; extra == 'all' + - xlsxwriter>=3.2.5 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/pandas/3.2.0.dev0+83.g71e11934c8/pandas-3.2.0.dev0+83.g71e11934c8-cp314-cp314-macosx_10_15_x86_64.whl + name: pandas + version: 3.2.0.dev0+83.g71e11934c8 + index: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple + requires_dist: + - numpy>=2.0.2 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.9.0 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - pytest>=8.3.4 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=16.0.0 ; extra == 'pyarrow' + - bottleneck>=1.5.0 ; extra == 'performance' + - numba>=0.61.2 ; extra == 'performance' + - numexpr>=2.11.0,!=2.14.1 ; extra == 'performance' + - scipy>=1.16.1 ; extra == 'computation' + - xarray>=2025.7.1 ; extra == 'computation' + - fsspec>=2025.7.0 ; extra == 'fss' + - s3fs>=2025.7.0 ; extra == 'aws' + - gcsfs>=2025.7.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.4.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.2 ; extra == 'excel' + - xlsxwriter>=3.2.5 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.9.1 ; extra == 'iceberg' + - tables>=3.10.2 ; extra == 'hdf5' + - pyreadstat>=1.3.0 ; extra == 'spss' + - sqlalchemy>=2.0.42 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.7.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.42 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.42 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.7.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.7.0 ; extra == 'sql-other' + - beautifulsoup4>=4.13.4 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=6.0.0 ; extra == 'html' + - lxml>=6.0.0 ; extra == 'xml' + - matplotlib>=3.10.5 ; extra == 'plot' + - jinja2>=3.1.6 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.11 ; extra == 'clipboard' + - qtpy>=2.4.3 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.7.0 ; extra == 'all' + - adbc-driver-sqlite>=1.7.0 ; extra == 'all' + - beautifulsoup4>=4.13.4 ; extra == 'all' + - bottleneck>=1.5.0 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2025.7.0 ; extra == 'all' + - gcsfs>=2025.7.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - jinja2>=3.1.6 ; extra == 'all' + - lxml>=6.0.0 ; extra == 'all' + - matplotlib>=3.10.5 ; extra == 'all' + - numba>=0.61.2 ; extra == 'all' + - numexpr>=2.11.0,!=2.14.1 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=16.0.0 ; extra == 'all' + - pyiceberg>=0.9.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.11 ; extra == 'all' + - pyreadstat>=1.3.0 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.4.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.3 ; extra == 'all' + - scipy>=1.16.1 ; extra == 'all' + - s3fs>=2025.7.0 ; extra == 'all' + - sqlalchemy>=2.0.42 ; extra == 'all' + - tables>=3.10.2 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2025.7.1 ; extra == 'all' + - xlrd>=2.0.2 ; extra == 'all' + - xlsxwriter>=3.2.5 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/pandas/3.2.0.dev0+83.g71e11934c8/pandas-3.2.0.dev0+83.g71e11934c8-cp314-cp314-macosx_11_0_arm64.whl + name: pandas + version: 3.2.0.dev0+83.g71e11934c8 + index: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple + requires_dist: + - numpy>=2.0.2 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.9.0 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - pytest>=8.3.4 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=16.0.0 ; extra == 'pyarrow' + - bottleneck>=1.5.0 ; extra == 'performance' + - numba>=0.61.2 ; extra == 'performance' + - numexpr>=2.11.0,!=2.14.1 ; extra == 'performance' + - scipy>=1.16.1 ; extra == 'computation' + - xarray>=2025.7.1 ; extra == 'computation' + - fsspec>=2025.7.0 ; extra == 'fss' + - s3fs>=2025.7.0 ; extra == 'aws' + - gcsfs>=2025.7.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.4.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.2 ; extra == 'excel' + - xlsxwriter>=3.2.5 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.9.1 ; extra == 'iceberg' + - tables>=3.10.2 ; extra == 'hdf5' + - pyreadstat>=1.3.0 ; extra == 'spss' + - sqlalchemy>=2.0.42 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.7.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.42 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.42 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.7.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.7.0 ; extra == 'sql-other' + - beautifulsoup4>=4.13.4 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=6.0.0 ; extra == 'html' + - lxml>=6.0.0 ; extra == 'xml' + - matplotlib>=3.10.5 ; extra == 'plot' + - jinja2>=3.1.6 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.11 ; extra == 'clipboard' + - qtpy>=2.4.3 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.7.0 ; extra == 'all' + - adbc-driver-sqlite>=1.7.0 ; extra == 'all' + - beautifulsoup4>=4.13.4 ; extra == 'all' + - bottleneck>=1.5.0 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2025.7.0 ; extra == 'all' + - gcsfs>=2025.7.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - jinja2>=3.1.6 ; extra == 'all' + - lxml>=6.0.0 ; extra == 'all' + - matplotlib>=3.10.5 ; extra == 'all' + - numba>=0.61.2 ; extra == 'all' + - numexpr>=2.11.0,!=2.14.1 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=16.0.0 ; extra == 'all' + - pyiceberg>=0.9.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.11 ; extra == 'all' + - pyreadstat>=1.3.0 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.4.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.3 ; extra == 'all' + - scipy>=1.16.1 ; extra == 'all' + - s3fs>=2025.7.0 ; extra == 'all' + - sqlalchemy>=2.0.42 ; extra == 'all' + - tables>=3.10.2 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2025.7.1 ; extra == 'all' + - xlrd>=2.0.2 ; extra == 'all' + - xlsxwriter>=3.2.5 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' +- pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/pandas/3.2.0.dev0+83.g71e11934c8/pandas-3.2.0.dev0+83.g71e11934c8-cp314-cp314-win_amd64.whl + name: pandas + version: 3.2.0.dev0+83.g71e11934c8 + index: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple + requires_dist: + - numpy>=2.0.2 ; python_full_version < '3.14' + - numpy>=2.3.3 ; python_full_version >= '3.14' + - python-dateutil>=2.9.0 + - tzdata ; sys_platform == 'win32' + - tzdata ; sys_platform == 'emscripten' + - pytest>=8.3.4 ; extra == 'test' + - pytest-xdist>=3.6.1 ; extra == 'test' + - pyarrow>=16.0.0 ; extra == 'pyarrow' + - bottleneck>=1.5.0 ; extra == 'performance' + - numba>=0.61.2 ; extra == 'performance' + - numexpr>=2.11.0,!=2.14.1 ; extra == 'performance' + - scipy>=1.16.1 ; extra == 'computation' + - xarray>=2025.7.1 ; extra == 'computation' + - fsspec>=2025.7.0 ; extra == 'fss' + - s3fs>=2025.7.0 ; extra == 'aws' + - gcsfs>=2025.7.0 ; extra == 'gcp' + - odfpy>=1.4.1 ; extra == 'excel' + - openpyxl>=3.1.5 ; extra == 'excel' + - python-calamine>=0.4.0 ; extra == 'excel' + - pyxlsb>=1.0.10 ; extra == 'excel' + - xlrd>=2.0.2 ; extra == 'excel' + - xlsxwriter>=3.2.5 ; extra == 'excel' + - pyarrow>=13.0.0 ; extra == 'parquet' + - pyarrow>=13.0.0 ; extra == 'feather' + - pyiceberg>=0.9.1 ; extra == 'iceberg' + - tables>=3.10.2 ; extra == 'hdf5' + - pyreadstat>=1.3.0 ; extra == 'spss' + - sqlalchemy>=2.0.42 ; extra == 'postgresql' + - psycopg2>=2.9.10 ; extra == 'postgresql' + - adbc-driver-postgresql>=1.7.0 ; extra == 'postgresql' + - sqlalchemy>=2.0.42 ; extra == 'mysql' + - pymysql>=1.1.1 ; extra == 'mysql' + - sqlalchemy>=2.0.42 ; extra == 'sql-other' + - adbc-driver-postgresql>=1.7.0 ; extra == 'sql-other' + - adbc-driver-sqlite>=1.7.0 ; extra == 'sql-other' + - beautifulsoup4>=4.13.4 ; extra == 'html' + - html5lib>=1.1 ; extra == 'html' + - lxml>=6.0.0 ; extra == 'html' + - lxml>=6.0.0 ; extra == 'xml' + - matplotlib>=3.10.5 ; extra == 'plot' + - jinja2>=3.1.6 ; extra == 'output-formatting' + - tabulate>=0.9.0 ; extra == 'output-formatting' + - pyqt5>=5.15.11 ; extra == 'clipboard' + - qtpy>=2.4.3 ; extra == 'clipboard' + - zstandard>=0.23.0 ; extra == 'compression' + - pytz>=2020.1 ; extra == 'timezone' + - adbc-driver-postgresql>=1.7.0 ; extra == 'all' + - adbc-driver-sqlite>=1.7.0 ; extra == 'all' + - beautifulsoup4>=4.13.4 ; extra == 'all' + - bottleneck>=1.5.0 ; extra == 'all' + - fastparquet>=2024.11.0 ; extra == 'all' + - fsspec>=2025.7.0 ; extra == 'all' + - gcsfs>=2025.7.0 ; extra == 'all' + - html5lib>=1.1 ; extra == 'all' + - jinja2>=3.1.6 ; extra == 'all' + - lxml>=6.0.0 ; extra == 'all' + - matplotlib>=3.10.5 ; extra == 'all' + - numba>=0.61.2 ; extra == 'all' + - numexpr>=2.11.0,!=2.14.1 ; extra == 'all' + - odfpy>=1.4.1 ; extra == 'all' + - openpyxl>=3.1.5 ; extra == 'all' + - psycopg2>=2.9.10 ; extra == 'all' + - pyarrow>=16.0.0 ; extra == 'all' + - pyiceberg>=0.9.1 ; extra == 'all' + - pymysql>=1.1.1 ; extra == 'all' + - pyqt5>=5.15.11 ; extra == 'all' + - pyreadstat>=1.3.0 ; extra == 'all' + - pytest>=8.3.4 ; extra == 'all' + - pytest-xdist>=3.6.1 ; extra == 'all' + - python-calamine>=0.4.0 ; extra == 'all' + - pytz>=2020.1 ; extra == 'all' + - pyxlsb>=1.0.10 ; extra == 'all' + - qtpy>=2.4.3 ; extra == 'all' + - scipy>=1.16.1 ; extra == 'all' + - s3fs>=2025.7.0 ; extra == 'all' + - sqlalchemy>=2.0.42 ; extra == 'all' + - tables>=3.10.2 ; extra == 'all' + - tabulate>=0.9.0 ; extra == 'all' + - xarray>=2025.7.1 ; extra == 'all' + - xlrd>=2.0.2 ; extra == 'all' + - xlsxwriter>=3.2.5 ; extra == 'all' + - zstandard>=0.23.0 ; extra == 'all' + requires_python: '>=3.11' - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scikit-learn/1.10.dev0/scikit_learn-1.10.dev0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl name: scikit-learn version: 1.10.dev0 index: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple requires_dist: - - numpy>=1.24.1 - - scipy>=1.10.0 + - numpy>=1.26.0 + - scipy>=1.11.4 - joblib>=1.4.0 - narwhals>=2.0.1 - threadpoolctl>=3.5.0 - requires_python: '>=3.11' + requires_python: '>=3.12' - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scikit-learn/1.10.dev0/scikit_learn-1.10.dev0-cp314-cp314-macosx_10_15_x86_64.whl name: scikit-learn version: 1.10.dev0 index: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple requires_dist: - - numpy>=1.24.1 - - scipy>=1.10.0 + - numpy>=1.26.0 + - scipy>=1.11.4 - joblib>=1.4.0 - narwhals>=2.0.1 - threadpoolctl>=3.5.0 - requires_python: '>=3.11' + requires_python: '>=3.12' - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scikit-learn/1.10.dev0/scikit_learn-1.10.dev0-cp314-cp314-macosx_12_0_arm64.whl name: scikit-learn version: 1.10.dev0 index: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple requires_dist: - - numpy>=1.24.1 - - scipy>=1.10.0 + - numpy>=1.26.0 + - scipy>=1.11.4 - joblib>=1.4.0 - narwhals>=2.0.1 - threadpoolctl>=3.5.0 - requires_python: '>=3.11' + requires_python: '>=3.12' - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scikit-learn/1.10.dev0/scikit_learn-1.10.dev0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl name: scikit-learn version: 1.10.dev0 index: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple requires_dist: - - numpy>=1.24.1 - - scipy>=1.10.0 + - numpy>=1.26.0 + - scipy>=1.11.4 - joblib>=1.4.0 - narwhals>=2.0.1 - threadpoolctl>=3.5.0 - requires_python: '>=3.11' + requires_python: '>=3.12' - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scikit-learn/1.10.dev0/scikit_learn-1.10.dev0-cp314-cp314-win_amd64.whl name: scikit-learn version: 1.10.dev0 index: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple requires_dist: - - numpy>=1.24.1 - - scipy>=1.10.0 + - numpy>=1.26.0 + - scipy>=1.11.4 - joblib>=1.4.0 - narwhals>=2.0.1 - threadpoolctl>=3.5.0 - requires_python: '>=3.11' + requires_python: '>=3.12' - pypi: https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/scipy/2.0.0.dev0/scipy-2.0.0.dev0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl name: scipy version: 2.0.0.dev0 diff --git a/pyproject.toml b/pyproject.toml index a0ca2667..785492b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,7 +131,6 @@ skops = { path = ".", editable = true } [tool.pixi.feature.docs.dependencies] # To be synced with the versions in docs/requirements.txt matplotlib = ">=3.3" -pandas = ">=1" sphinx = ">=3.2.0" sphinx-gallery = ">=0.7.0" sphinx-rtd-theme = ">=1" @@ -141,8 +140,10 @@ sphinx-issues = ">=1.2.0" [tool.pixi.feature.docs.pypi-dependencies] # everything that depends on scikit-learn needs to be a pypi dependency so that this -# spec is compatible with the nightly build environment. +# spec is compatible with the nightly build environment. The same holds for pandas, +# whose dev version the nightly build environment installs from pypi. fairlearn = ">=0.7.0" +pandas = ">=2" [tool.pixi.feature.tests.dependencies] pytest = ">=7" @@ -151,7 +152,6 @@ flaky = ">=3.7.0" pandoc = ">=3.6.4" rich = ">=12" matplotlib = ">=3.3" -pandas = ">=1" [tool.pixi.feature.tests.pypi-dependencies] # these are packages that require scikit-learn. They need to be as a pypi dependency @@ -159,12 +159,21 @@ pandas = ">=1" # when installing pre-release nightly release. lightgbm = ">=3" xgboost = ">=1.6" +# skops.io supports pandas 2.0 and later; each CI environment pins one minor +# version so that the whole range is tested, see the sklearn* features below. +# A pypi dependency for the same reason as above: the nightly environment +# installs the dev version of pandas from pypi. +pandas = ">=2" [tool.pixi.feature.lint.dependencies] pre-commit = "*" [tool.pixi.feature.dev.dependencies] ipython = "*" +# A conda pandas for the default environment, which includes the nightly index +# of the sklearn-nightly feature: that index shadows PyPI for the packages it +# has, so a pypi pandas there would be a dev build. +pandas = ">=2" [tool.pixi.feature.sklearn12.dependencies] scikit-learn = "~=1.2.0" @@ -242,6 +251,8 @@ numpy = "~=2.5.0" scipy = "~=1.18.0" catboost = ">=1.0" quantile-forest = "~=1.4.0" +# keeps pandas objects in fitted attributes, see the test for issue #450 +category_encoders = ">=2.6" python = "~=3.14.0" # [tool.pixi.feature.sklearn17] @@ -260,10 +271,21 @@ extra-index-urls = ["https://pypi.anaconda.org/scientific-python-nightly-wheels/ # The version value here needs to be exact, hence == instead of ~= scikit-learn = "==1.10.dev0" fairlearn = "*" -pandas = "*" numpy = "*" scipy = "*" +[tool.pixi.feature.pandas-nightly.pypi-dependencies] +# The dev version of pandas from the nightly index; "*" would pick the latest +# release, since pre-releases are only considered when named explicitly. Unlike +# the scikit-learn and scipy nightlies, pandas puts the git revision in the +# version, so every build is a new file and the index drops old ones after a +# few days: the locked wheel goes stale. CI refreshes it before installing the +# environment, see .github/workflows/build-test.yml; do the same locally with +# ``pixi update --environment ci-sklearn-nightly pandas``. This is why the +# feature is not part of the default environment. Like the scikit-learn pin +# above, the version needs a bump after each pandas release. +pandas = "==3.2.0.dev0" + [tool.pixi.feature.lint.tasks] lint = { cmd = "pre-commit install && pre-commit run -v --all-files --show-diff-on-failure" } @@ -282,4 +304,4 @@ ci-sklearn16 = ["rich", "tests", "lint", "sklearn16"] ci-sklearn17 = ["rich", "tests", "lint", "sklearn17"] ci-sklearn18 = ["rich", "tests", "lint", "sklearn18"] ci-sklearn19 = ["rich", "tests", "lint", "sklearn19"] -ci-sklearn-nightly = ["rich", "tests", "lint", "sklearn-nightly"] +ci-sklearn-nightly = ["rich", "tests", "lint", "sklearn-nightly", "pandas-nightly"] diff --git a/skops/io/_pandas.py b/skops/io/_pandas.py new file mode 100644 index 00000000..54374197 --- /dev/null +++ b/skops/io/_pandas.py @@ -0,0 +1,997 @@ +"""Persistence of pandas objects. + +pandas objects are not persisted through ``__reduce__`` or ``__getstate__``: +those expose internals such as block managers, index engines and reference +trackers, which change between pandas versions and cannot be rebuilt from data +alone. Instead, every object is taken apart into the public pieces its +constructor accepts, and rebuilt by calling that constructor with an explicit +dtype, so that no type inference happens on load: + +- an :class:`~pandas.Index` is stored as its values and name, +- a :class:`~pandas.Series` as its values, index and name, +- a :class:`~pandas.DataFrame` as its columns, index and one array per column, +- extension arrays as the numpy arrays or scalars they are made of, plus their + dtype, and extension dtypes as their string representation, +- a :class:`~pandas.Timestamp` or :class:`~pandas.Timedelta` as the numpy + scalar of its value, plus the zone of an aware Timestamp, and ``pandas.NA`` + by its name alone. + +Values with a numpy dtype are stored as numpy arrays, everything else as lists +of scalars. + +pandas is optional and slow to import, so ``skops.io`` does not import it. The +``get_state`` handlers are registered on the first dump after the user has +imported pandas, see :func:`register_if_imported`, and the nodes only import +pandas when they construct an object. + +Not preserved: the ``freq`` of datetime-like indexes and arrays, the ``attrs`` +and ``flags`` of a Series or DataFrame, and the storage, python or pyarrow, of +a string dtype, which is an environment choice over the same values. + +Not supported: the pyarrow backed arrays and dtypes, those with an +``ArrowDtype``, whose names pyarrow parses. The string dtype with pyarrow +storage is a string dtype, and supported as one. + +The pandas types are not trusted by default, since this support is new; a user +passes the names a file holds, see :func:`skops.io.get_untrusted_types`. +Trusting them should mean trusting pandas and nothing else, so loading stays +within pandas' constructors and the data from the file. In particular, no name +from the file is looked up in pandas' registry of extension dtypes, where any +imported library can register a dtype whose parser would then run, and time +zones are parsed here rather than by pandas, whose parser can open any file on +disk. +""" + +from __future__ import annotations + +import datetime +import pickle +import re +import sys +import warnings +import zoneinfo +from typing import Any + +import numpy as np + +from ._audit import Node, get_tree +from ._general import JsonNode, ListNode +from ._numpy import NdArrayNode +from ._protocol import PROTOCOL +from ._utils import ( + LoadContext, + SaveContext, + TrustedTypes, + _get_state, + get_module, + get_state, + gettype, +) +from .exceptions import UnsupportedTypeException + + +def _public_module(cls: type) -> str: + # The public module of a pandas class: "pandas.arrays" for the array + # classes and "pandas" for everything else. pandas 3 reports these as + # ``__module__`` already, but older versions report the defining module, + # e.g. ``pandas.core.series``, and a file must not depend on that: the + # public name is what a user passes as ``trusted``, and what stays + # importable across versions. Deprecated aliases such as + # ``pandas.arrays.PandasArray`` warn when accessed, hence the suppression. + import pandas as pd + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + for module in (pd.arrays, pd): + if getattr(module, cls.__name__, None) is cls: + return module.__name__ + return get_module(cls) + + +# Classes that later pandas versions renamed, mapped to their current name and +# the pandas version that introduced it, so that a file never names a class +# the loading version may not have. An entry can go once the versions before +# the rename are no longer supported. The test suite checks each entry against +# the running pandas version. +_RENAMED_CLASSES = { + "PandasArray": ("NumpyExtensionArray", "2.1"), +} + +_ARROW_UNSUPPORTED = ( + "The pyarrow backed arrays and dtypes of pandas, those with an ArrowDtype," + " are not supported yet." +) + + +def _public_name(cls: type) -> str: + # The name a file holds for a pandas class, which is also the name to pass + # as ``trusted``. + class_name = cls.__name__ + if class_name in _RENAMED_CLASSES: + class_name, _ = _RENAMED_CLASSES[class_name] + return f"{_public_module(cls)}.{class_name}" + + +def _public_trusted(trusted: Any) -> Any: + # pandas classes passed as ``trusted`` are matched by the public names the + # file holds, rather than by their defining module, which pandas before 3.0 + # reports, or by an old name. + if isinstance(trusted, (str, type)): + trusted = [trusted] + if not isinstance(trusted, (list, tuple)): + return trusted + return [ + _public_name(entry) + if isinstance(entry, type) and entry.__module__.partition(".")[0] == "pandas" + else entry + for entry in trusted + ] + + +def _pandas_state( + obj: Any, loader: str, content: dict[str, Any], save_context: SaveContext +) -> dict[str, Any]: + cls = type(obj) + # The nodes below rebuild objects with the pandas constructors, so an + # instance of a subclass defined by another library would silently be + # loaded as its pandas base class. Refuse those instead. + if cls.__module__.partition(".")[0] != "pandas": + raise UnsupportedTypeException( + f"{get_module(cls)}.{cls.__name__} is a subclass of a pandas type" + " defined outside pandas, which is not supported: it would be loaded" + " as its pandas base class." + ) + + module, _, class_name = _public_name(cls).rpartition(".") + return { + "__class__": class_name, + "__module__": module, + "__loader__": loader, + "content": { + key: get_state(value, save_context) for key, value in content.items() + }, + } + + +def _values(obj: Any) -> Any: + # The array behind an Index or Series: a numpy array for numpy dtypes, the + # extension array otherwise. + if isinstance(obj.dtype, np.dtype): + return obj.to_numpy() + return obj.array + + +def index_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + content = {"values": _values(obj), "name": obj.name} + return _pandas_state(obj, "PandasIndexNode", content, save_context) + + +def range_index_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + content = {"start": obj.start, "stop": obj.stop, "step": obj.step, "name": obj.name} + return _pandas_state(obj, "PandasRangeIndexNode", content, save_context) + + +def multi_index_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + content = { + "levels": list(obj.levels), + "codes": list(obj.codes), + "sortorder": obj.sortorder, + "names": list(obj.names), + } + return _pandas_state(obj, "PandasMultiIndexNode", content, save_context) + + +def series_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + content = {"values": _values(obj), "index": obj.index, "name": obj.name} + return _pandas_state(obj, "PandasSeriesNode", content, save_context) + + +def dataframe_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + content = { + "columns": obj.columns, + "index": obj.index, + "data": [_values(obj.iloc[:, i]) for i in range(obj.shape[1])], + } + return _pandas_state(obj, "PandasDataFrameNode", content, save_context) + + +_FIXED_OFFSET = re.compile(r"^UTC([+-])(\d{2}):(\d{2})(?::(\d{2}))?$") + + +def _timezone_name(tz: Any) -> str: + # The name of a time zone as written to the file: the key of a zoneinfo + # or pytz zone, "UTC", or a fixed offset as "UTC+01:00", with its seconds + # as "UTC+00:19:32" if it has any. These are the only names ``_timezone`` + # accepts when loading. + if isinstance(tz, zoneinfo.ZoneInfo): + try: + tz.__reduce__() + except pickle.PicklingError as err: + # Created with ``ZoneInfo.from_file``: its key, if it has one, + # need not describe its data, and the key is all the file holds. + # Refused like ``skops.io`` refuses such objects on their own. + raise UnsupportedTypeException( + "ZoneInfo objects created with ZoneInfo.from_file are not" + " supported, since their time zone data cannot be loaded from a" + " key; create the zone with ZoneInfo(key) instead." + ) from err + name = getattr(tz, "key", None) or getattr(tz, "zone", None) + if name is not None: + return name + offset = tz.utcoffset(None) + if offset is None or offset.microseconds: + raise UnsupportedTypeException( + f"The time zone {tz!r} has neither a name nor a fixed offset of whole" + " seconds, so it cannot be saved." + ) + seconds = int(offset.total_seconds()) + if seconds == 0: + return "UTC" + sign, seconds = ("-", -seconds) if seconds < 0 else ("+", seconds) + name = f"UTC{sign}{seconds // 3600:02d}:{seconds % 3600 // 60:02d}" + if seconds % 60: + name += f":{seconds % 60:02d}" + return name + + +def _timezone(name: str) -> str | datetime.tzinfo: + # The time zone for a name from the file, parsed here rather than by + # pandas: pandas' own parser would also accept "tzlocal()" and + # "dateutil/", the latter opening any file on disk, and it drops the + # seconds of a fixed offset. "UTC" and fixed offsets become the + # ``datetime.timezone`` objects pandas builds for them too. A zone key is + # checked with zoneinfo, which only looks in its own directories, and then + # handed to pandas as a name, so that pandas builds the zone of its + # default implementation and the loaded zone equals the saved one. + if name == "UTC": + return datetime.timezone.utc + match = _FIXED_OFFSET.match(name) + if match: + sign, hours, minutes, seconds = match.groups() + offset = datetime.timedelta( + hours=int(hours), minutes=int(minutes), seconds=int(seconds or 0) + ) + return datetime.timezone(-offset if sign == "-" else offset) + try: + zoneinfo.ZoneInfo(name) + except (ValueError, KeyError, OSError) as err: + raise ValueError( + f"{name!r} is not a known time zone. This is probably due to a" + " corrupted or a malicious file." + ) from err + return name + + +def numpy_backed_array_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + # NumpyExtensionArray, DatetimeArray and TimedeltaArray wrap a numpy array. + # Time zone aware datetimes are stored as naive UTC values plus the zone, + # since ``to_numpy`` would otherwise give an array of Timestamp objects. + tz = getattr(obj, "tz", None) + values = obj if tz is None else obj.tz_convert("UTC").tz_localize(None) + content = { + "values": values.to_numpy(), + "tz": None if tz is None else _timezone_name(tz), + } + return _pandas_state(obj, "PandasNumpyBackedArrayNode", content, save_context) + + +def timestamp_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + # A Timestamp as the numpy datetime64 of its value, which keeps its unit + # and its nanoseconds, in UTC when it is aware, plus the name of its zone. + tz = obj.tz + value = obj if tz is None else obj.tz_convert("UTC").tz_localize(None) + content = { + "value": value.to_datetime64(), + "tz": None if tz is None else _timezone_name(tz), + } + return _pandas_state(obj, "PandasTimestampNode", content, save_context) + + +def timedelta_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + content = {"value": obj.to_timedelta64()} + return _pandas_state(obj, "PandasTimedeltaNode", content, save_context) + + +def na_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + # ``pandas.NA`` is a singleton, so nothing but its name is stored. + return _pandas_state(obj, "PandasNANode", {}, save_context) + + +def masked_array_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + # IntegerArray, FloatingArray and BooleanArray: a numpy array of values and + # a boolean mask of the missing entries, which is what their constructor + # takes. Masked positions hold arbitrary values, so they are zeroed. + numpy_dtype = obj.dtype.numpy_dtype + content = { + "values": obj.to_numpy(dtype=numpy_dtype, na_value=numpy_dtype.type(0)), + "mask": obj.isna(), + } + return _pandas_state(obj, "PandasMaskedArrayNode", content, save_context) + + +def categorical_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + content = {"codes": obj.codes, "dtype": obj.dtype} + return _pandas_state(obj, "PandasCategoricalNode", content, save_context) + + +def period_array_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + content = {"ordinals": obj.asi8, "dtype": obj.dtype} + return _pandas_state(obj, "PandasPeriodArrayNode", content, save_context) + + +def interval_array_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + content = {"left": obj.left, "right": obj.right, "closed": obj.closed} + return _pandas_state(obj, "PandasIntervalArrayNode", content, save_context) + + +def extension_array_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + # Any other extension array, e.g. a string array of either storage: stored + # as its scalars, with ``None`` for missing values, plus its dtype. + import pandas as pd + + if isinstance(obj.dtype, pd.ArrowDtype): + raise UnsupportedTypeException(_ARROW_UNSUPPORTED) + content = {"values": obj.to_numpy(dtype=object, na_value=None), "dtype": obj.dtype} + return _pandas_state(obj, "PandasExtensionArrayNode", content, save_context) + + +def sparse_array_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + # Stored dense, as the numpy array of its subtype, rather than as scalars: + # the array keeps its subtype, e.g. a complex one which scalars would not, + # and the fill value is part of the dtype, which rebuilds the sparse array. + content = {"values": obj.to_numpy(), "dtype": obj.dtype} + return _pandas_state(obj, "PandasExtensionArrayNode", content, save_context) + + +def extension_dtype_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + # Extension dtypes are rebuilt from their string form, e.g. "Int64" or + # "period[M]", except those whose parser consults pandas' registry of + # extension dtypes or the file system, which are stored as parts below. + import pandas as pd + + if isinstance(obj, pd.ArrowDtype): + raise UnsupportedTypeException(_ARROW_UNSUPPORTED) + name = str(obj) + try: + rebuilt = type(obj).construct_from_string(name) + except TypeError: + rebuilt = None + if type(rebuilt) is not type(obj): + # e.g. a dtype whose name another dtype claims + raise UnsupportedTypeException( + f"The dtype {obj!r} cannot be rebuilt from its name {name!r}, so it" + " cannot be saved." + ) + content = {"name": name} + return _pandas_state(obj, "PandasExtensionDtypeNode", content, save_context) + + +def datetime_tz_dtype_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + content = {"unit": obj.unit, "tz": _timezone_name(obj.tz)} + return _pandas_state(obj, "PandasDatetimeTZDtypeNode", content, save_context) + + +def interval_dtype_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + # The subtype is a numpy dtype, stored by its name like the subtype of a + # sparse dtype, a tz-aware datetime dtype, or None. + subtype = obj.subtype + if isinstance(subtype, np.dtype): + subtype = str(subtype) + content = {"subtype": subtype, "closed": obj.closed} + return _pandas_state(obj, "PandasIntervalDtypeNode", content, save_context) + + +def categorical_dtype_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + # The string form of a categorical dtype does not include its categories. + content = {"categories": obj.categories, "ordered": obj.ordered} + return _pandas_state(obj, "PandasCategoricalDtypeNode", content, save_context) + + +def _sparse_fill_value(dtype: Any) -> Any: + """The fill value of a sparse dtype in a form that can be stored. + + JSON scalars and numpy scalars are stored as they are. Any other scalar is + converted to the numpy scalar of the subtype, e.g. a complex number, a + Timestamp or a Timedelta, which JSON cannot hold. + """ + import pandas as pd + + fill_value = dtype.fill_value + if fill_value is None or isinstance( + fill_value, (str, bool, int, float, np.generic) + ): + return fill_value + kind = dtype.subtype.kind + if kind in "biufcMm" and not pd.isna(fill_value): + try: + if kind == "M": + # keeps the nanoseconds, which np.datetime64(datetime) drops + return pd.Timestamp(fill_value).to_datetime64() + if kind == "m": + return pd.Timedelta(fill_value).to_timedelta64() + return dtype.subtype.type(fill_value) + except (TypeError, ValueError): # pragma: no cover + pass + raise UnsupportedTypeException( + f"The fill value {fill_value!r} of the sparse dtype {dtype} cannot be saved." + ) + + +def sparse_dtype_get_state(obj: Any, save_context: SaveContext) -> dict[str, Any]: + # The string form of a sparse dtype can only be parsed back when the fill + # value is the default one for its subtype, so the parts are stored. + content = {"subtype": str(obj.subtype), "fill_value": _sparse_fill_value(obj)} + return _pandas_state(obj, "PandasSparseDtypeNode", content, save_context) + + +class _PandasNode(Node): + """Base class of the pandas nodes. + + The children are the entries of ``state["content"]``. ``_allowed_types`` + of each subclass names every entry and the node types it may hold, which + is checked while the file is read, and ``_build`` builds the object from + the constructed children. + + The audit trusts the class a file declares, so the declared class has to + be one the loader builds: ``_module`` and ``_classes`` of each subclass + name them, the declaration is checked while the file is read, and the + built object is checked to be of the declared class. ``_module`` is None + for classes whose public module differs between pandas versions, any + module of pandas is accepted then, and the loaders whose ``_classes`` is + None explain why they cannot check the class by name. + """ + + _module: str | None = "pandas" + _classes: tuple[str, ...] | None = () + + def __init__( + self, + state: dict[str, Any], + load_context: LoadContext, + trusted: TrustedTypes | None = None, + ) -> None: + super().__init__(state, load_context, trusted) + trusted = _public_trusted(trusted) + self.trusted = self._get_trusted(trusted, []) + if self._module is None: + declared_ok = self.module_name.partition(".")[0] == "pandas" + else: + declared_ok = self.module_name == self._module + if self._classes is not None: + declared_ok = declared_ok and self.class_name in self._classes + if not declared_ok: + raise ValueError( + f"{self.module_name}.{self.class_name} is not a class" + f" {type(self).__name__} builds. This is probably due to a" + " corrupted or a malicious file." + ) + allowed_types = self._allowed_types() + if set(state["content"]) != set(allowed_types): + raise ValueError( + f"Expected the entries {sorted(allowed_types)}, got" + f" {sorted(state['content'])}. This is probably due to a corrupted" + " or a malicious file." + ) + self.content = { + key: get_tree( + value, load_context, trusted=trusted, allowed_types=allowed_types[key] + ) + for key, value in state["content"].items() + } + self.children = dict(self.content) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + # The node types each entry may hold, ``None`` for any: names for + # instance can be any hashable. + raise NotImplementedError # pragma: no cover + + def _construct_content(self) -> dict[str, Any]: + return {key: node.construct() for key, node in self.content.items()} + + def _build(self): + raise NotImplementedError # pragma: no cover + + def _construct(self): + obj = self._build() + if self._classes is not None: + built = _public_name(type(obj)) + module, _, class_name = built.rpartition(".") + if class_name != self.class_name or ( + self._module is not None and module != self.module_name + ): + raise ValueError( + f"The file declares {self.module_name}.{self.class_name}, but" + f" its data builds {built}. This is probably due to a corrupted" + " or a malicious file." + ) + return obj + + +class PandasIndexNode(_PandasNode): + # ``pd.Index`` returns the subclass for the dtype of the values + _classes = ( + "Index", + "DatetimeIndex", + "TimedeltaIndex", + "PeriodIndex", + "CategoricalIndex", + "IntervalIndex", + ) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"values": _ARRAY_NODES, "name": None} + + def _build(self): + import pandas as pd + + content = self._construct_content() + values = content["values"] + # The dtype is passed to prevent inference, and ``tupleize_cols`` keeps + # an object Index of tuples from becoming a MultiIndex. + return pd.Index( + values, dtype=values.dtype, name=content["name"], tupleize_cols=False + ) + + +class PandasRangeIndexNode(_PandasNode): + _classes = ("RangeIndex",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return { + "start": (JsonNode,), + "stop": (JsonNode,), + "step": (JsonNode,), + "name": None, + } + + def _build(self): + import pandas as pd + + content = self._construct_content() + return pd.RangeIndex( + content["start"], content["stop"], content["step"], name=content["name"] + ) + + +class PandasMultiIndexNode(_PandasNode): + _classes = ("MultiIndex",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return { + "levels": (ListNode,), + "codes": (ListNode,), + "sortorder": (JsonNode,), + "names": (ListNode,), + } + + def _build(self): + import pandas as pd + + content = self._construct_content() + return pd.MultiIndex( + levels=content["levels"], + codes=content["codes"], + sortorder=content["sortorder"], + names=content["names"], + ) + + +class PandasSeriesNode(_PandasNode): + _classes = ("Series",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"values": _ARRAY_NODES, "index": _INDEX_NODES, "name": None} + + def _build(self): + import pandas as pd + + content = self._construct_content() + values = content["values"] + return pd.Series( + values, index=content["index"], dtype=values.dtype, name=content["name"] + ) + + +class PandasDataFrameNode(_PandasNode): + _classes = ("DataFrame",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"columns": _INDEX_NODES, "index": _INDEX_NODES, "data": (ListNode,)} + + def _build(self): + import pandas as pd + + content = self._construct_content() + columns = [pd.Series(values, dtype=values.dtype) for values in content["data"]] + if columns: + frame = pd.concat(columns, axis=1, ignore_index=True) + frame.index = content["index"] + else: + frame = pd.DataFrame(index=content["index"]) + frame.columns = content["columns"] + return frame + + +class PandasNumpyBackedArrayNode(_PandasNode): + _module = "pandas.arrays" + _classes = ("NumpyExtensionArray", "DatetimeArray", "TimedeltaArray") + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"values": (NdArrayNode,), "tz": (JsonNode,)} + + def _build(self): + import pandas as pd + + content = self._construct_content() + values, tz = content["values"], content["tz"] + if self.class_name == "NumpyExtensionArray": + # The plain wrapper of a numpy array of any dtype, datetime64 and + # timedelta64 included, which pandas 2.0 has under its old name. + if tz is not None: + raise ValueError( + "A NumpyExtensionArray has no time zone. This is probably due" + " to a corrupted or a malicious file." + ) + cls = getattr(pd.arrays, "NumpyExtensionArray", None) + if cls is None: + cls = pd.arrays.PandasArray + return cls(values) + # A DatetimeArray or TimedeltaArray, which ``pd.array`` infers from the + # dtype of the values on every pandas version. + kind = "M" if self.class_name == "DatetimeArray" else "m" + if values.dtype.kind != kind: + raise ValueError( + f"The values of a {self.class_name} must have a {np.dtype(kind)}" + " dtype. This is probably due to a corrupted or a malicious file." + ) + if tz is not None and kind == "m": + raise ValueError( + "A TimedeltaArray has no time zone. This is probably due to a" + " corrupted or a malicious file." + ) + array = pd.array(values) + if tz is not None: + array = array.tz_localize("UTC").tz_convert(_timezone(tz)) + return array + + +class PandasTimestampNode(_PandasNode): + _classes = ("Timestamp",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"value": (NdArrayNode,), "tz": (JsonNode,)} + + def _build(self): + import pandas as pd + + content = self._construct_content() + stamp = pd.Timestamp(content["value"]) + if content["tz"] is not None: + stamp = stamp.tz_localize("UTC").tz_convert(_timezone(content["tz"])) + return stamp + + +class PandasTimedeltaNode(_PandasNode): + _classes = ("Timedelta",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"value": (NdArrayNode,)} + + def _build(self): + import pandas as pd + + return pd.Timedelta(self._construct_content()["value"]) + + +class PandasNANode(_PandasNode): + # the public module of NAType differs between pandas versions + _module = None + _classes = ("NAType",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {} + + def _build(self): + import pandas as pd + + return pd.NA + + +class PandasMaskedArrayNode(_PandasNode): + _module = "pandas.arrays" + _classes = ("IntegerArray", "FloatingArray", "BooleanArray") + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"values": (NdArrayNode,), "mask": (NdArrayNode,)} + + def _build(self): + content = self._construct_content() + cls = gettype(self.module_name, self.class_name) + return cls(content["values"], content["mask"]) + + +class PandasCategoricalNode(_PandasNode): + _module = "pandas.arrays" + _classes = ("Categorical",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"codes": (NdArrayNode,), "dtype": (PandasCategoricalDtypeNode,)} + + def _build(self): + import pandas as pd + + content = self._construct_content() + return pd.Categorical.from_codes(content["codes"], dtype=content["dtype"]) + + +class PandasPeriodArrayNode(_PandasNode): + _module = "pandas.arrays" + _classes = ("PeriodArray",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"ordinals": (NdArrayNode,), "dtype": (PandasExtensionDtypeNode,)} + + def _build(self): + import pandas as pd + + content = self._construct_content() + return pd.arrays.PeriodArray(content["ordinals"], dtype=content["dtype"]) + + +class PandasIntervalArrayNode(_PandasNode): + _module = "pandas.arrays" + _classes = ("IntervalArray",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"left": _INDEX_NODES, "right": _INDEX_NODES, "closed": (JsonNode,)} + + def _build(self): + import pandas as pd + + content = self._construct_content() + return pd.arrays.IntervalArray.from_arrays( + content["left"], content["right"], closed=content["closed"] + ) + + +class PandasExtensionArrayNode(_PandasNode): + # Not checked by name: the array is built by ``pd.array`` from its dtype, + # whose node is audited itself, the class of a string array follows the + # string storage of the environment, and pandas has private array + # classes, e.g. the ones behind the ``str`` dtype of pandas 3. + _module = None + _classes = None + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"values": (NdArrayNode,), "dtype": _DTYPE_NODES} + + def _build(self): + import pandas as pd + + content = self._construct_content() + return pd.array(content["values"], dtype=content["dtype"]) + + +class PandasExtensionDtypeNode(_PandasNode): + # Not checked by name: the declared class is resolved and checked when + # the dtype is built, and pandas has private dtype classes, e.g. the one + # of a NumpyExtensionArray. + _module = None + _classes = None + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"name": (JsonNode,)} + + def _build(self): + import pandas as pd + + name = self._construct_content()["name"] + # The declared, trusted dtype class parses the name itself. + # ``pandas.api.types.pandas_dtype`` would look the name up in pandas' + # registry of extension dtypes instead, where any imported library can + # register one, and run that library's code for a name from the file. + # The parsers of the remaining pandas dtypes, the masked numeric and + # boolean ones, StringDtype and PeriodDtype, compare the name with + # their own or hand it to the offset parser. ArrowDtype hands it to + # pyarrow. + cls = gettype(self.module_name, self.class_name) + if not issubclass(cls, pd.api.extensions.ExtensionDtype): + raise ValueError( + f"{self.module_name}.{self.class_name} is not a pandas extension" + " dtype. This is probably due to a corrupted or a malicious file." + ) + if cls in _dtypes_stored_as_parts(): + # Their parsers consult the registry (the subtype of an interval + # or sparse dtype) or the file system (the time zone of a + # datetime dtype), so they have loaders of their own. + raise ValueError( + f"{self.class_name} is not stored by its name. This is probably" + " due to a corrupted or a malicious file." + ) + if cls is pd.ArrowDtype: + # its names would be parsed by pyarrow, which has not been reviewed + raise ValueError(_ARROW_UNSUPPORTED) + try: + return cls.construct_from_string(name) + except TypeError: + if name == "str" and cls is pd.StringDtype: + # "str" is the default string dtype of pandas 3. Older versions + # do not know it, and keep strings in object arrays instead, + # which is what the values are stored as. + return np.dtype(object) + raise + + +class PandasCategoricalDtypeNode(_PandasNode): + _classes = ("CategoricalDtype",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"categories": _INDEX_NODES + (JsonNode,), "ordered": (JsonNode,)} + + def _build(self): + import pandas as pd + + content = self._construct_content() + return pd.CategoricalDtype(content["categories"], ordered=content["ordered"]) + + +class PandasSparseDtypeNode(_PandasNode): + _classes = ("SparseDtype",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"subtype": (JsonNode,), "fill_value": (JsonNode, NdArrayNode)} + + def _build(self): + import pandas as pd + + content = self._construct_content() + # numpy parses the subtype, so that the name from the file is not + # looked up in pandas' registry of extension dtypes + return pd.SparseDtype(np.dtype(content["subtype"]), content["fill_value"]) + + +class PandasDatetimeTZDtypeNode(_PandasNode): + _classes = ("DatetimeTZDtype",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"unit": (JsonNode,), "tz": (JsonNode,)} + + def _build(self): + import pandas as pd + + content = self._construct_content() + return pd.DatetimeTZDtype(unit=content["unit"], tz=_timezone(content["tz"])) + + +class PandasIntervalDtypeNode(_PandasNode): + _classes = ("IntervalDtype",) + + def _allowed_types(self) -> dict[str, tuple[type[Node], ...] | None]: + return {"subtype": (JsonNode, PandasDatetimeTZDtypeNode), "closed": (JsonNode,)} + + def _build(self): + import pandas as pd + + content = self._construct_content() + subtype = content["subtype"] + # A name is parsed by numpy, so that it is not looked up in pandas' + # registry of extension dtypes; otherwise the subtype is a tz-aware + # datetime dtype, built by its own node, or None. + message = ( + "The subtype of an interval dtype must be a numpy dtype, a datetime" + " dtype or None. This is probably due to a corrupted or a malicious" + " file." + ) + if isinstance(subtype, str): + try: + subtype = np.dtype(subtype) + except TypeError as err: + raise ValueError(message) from err + elif subtype is not None and not isinstance(subtype, pd.DatetimeTZDtype): + raise ValueError(message) + return pd.IntervalDtype(subtype, closed=content["closed"]) + + +def _dtypes_stored_as_parts(): + import pandas as pd + + return (pd.CategoricalDtype, pd.SparseDtype, pd.DatetimeTZDtype, pd.IntervalDtype) + + +# The node types the values, the index and the dtype of a pandas object may be +# stored as. +_ARRAY_NODES = ( + NdArrayNode, + PandasNumpyBackedArrayNode, + PandasMaskedArrayNode, + PandasCategoricalNode, + PandasPeriodArrayNode, + PandasIntervalArrayNode, + PandasExtensionArrayNode, +) +_INDEX_NODES = (PandasIndexNode, PandasRangeIndexNode, PandasMultiIndexNode) +_DTYPE_NODES = ( + PandasExtensionDtypeNode, + PandasCategoricalDtypeNode, + PandasSparseDtypeNode, + PandasDatetimeTZDtypeNode, + PandasIntervalDtypeNode, +) + + +_registered = False + + +def register_if_imported() -> None: + """Register the ``get_state`` handlers of pandas types, if pandas is imported. + + This is called before every dump. An object can only contain pandas + objects if pandas has been imported, so checking ``sys.modules`` is + enough, and skops itself never imports pandas. + """ + global _registered + if _registered or "pandas" not in sys.modules: + return + + import pandas as pd + + dispatch_functions = [ + (pd.Index, index_get_state), + (pd.RangeIndex, range_index_get_state), + (pd.MultiIndex, multi_index_get_state), + (pd.Series, series_get_state), + (pd.DataFrame, dataframe_get_state), + (pd.api.extensions.ExtensionArray, extension_array_get_state), + (pd.arrays.DatetimeArray, numpy_backed_array_get_state), + (pd.arrays.TimedeltaArray, numpy_backed_array_get_state), + (pd.Timestamp, timestamp_get_state), + (pd.Timedelta, timedelta_get_state), + (type(pd.NA), na_get_state), + (pd.arrays.IntegerArray, masked_array_get_state), + (pd.arrays.FloatingArray, masked_array_get_state), + (pd.arrays.BooleanArray, masked_array_get_state), + (pd.Categorical, categorical_get_state), + (pd.arrays.PeriodArray, period_array_get_state), + (pd.arrays.IntervalArray, interval_array_get_state), + (pd.arrays.SparseArray, sparse_array_get_state), + (pd.api.extensions.ExtensionDtype, extension_dtype_get_state), + (pd.CategoricalDtype, categorical_dtype_get_state), + (pd.SparseDtype, sparse_dtype_get_state), + (pd.DatetimeTZDtype, datetime_tz_dtype_get_state), + (pd.IntervalDtype, interval_dtype_get_state), + ] + # pandas.arrays.PandasArray was renamed to NumpyExtensionArray in pandas 2.1 + numpy_backed = getattr(pd.arrays, "NumpyExtensionArray", None) + if numpy_backed is None: + numpy_backed = pd.arrays.PandasArray + dispatch_functions.append((numpy_backed, numpy_backed_array_get_state)) + # StringArray subclasses NumpyExtensionArray, but its numpy form is an + # object array which loses the string dtype, so it takes the generic path. + dispatch_functions.append((pd.arrays.StringArray, extension_array_get_state)) + + for cls, func in dispatch_functions: + _get_state.register(cls)(func) + _registered = True + + +NODE_TYPE_MAPPING = { + ("PandasIndexNode", PROTOCOL): PandasIndexNode, + ("PandasRangeIndexNode", PROTOCOL): PandasRangeIndexNode, + ("PandasMultiIndexNode", PROTOCOL): PandasMultiIndexNode, + ("PandasSeriesNode", PROTOCOL): PandasSeriesNode, + ("PandasDataFrameNode", PROTOCOL): PandasDataFrameNode, + ("PandasNumpyBackedArrayNode", PROTOCOL): PandasNumpyBackedArrayNode, + ("PandasTimestampNode", PROTOCOL): PandasTimestampNode, + ("PandasTimedeltaNode", PROTOCOL): PandasTimedeltaNode, + ("PandasNANode", PROTOCOL): PandasNANode, + ("PandasMaskedArrayNode", PROTOCOL): PandasMaskedArrayNode, + ("PandasCategoricalNode", PROTOCOL): PandasCategoricalNode, + ("PandasPeriodArrayNode", PROTOCOL): PandasPeriodArrayNode, + ("PandasIntervalArrayNode", PROTOCOL): PandasIntervalArrayNode, + ("PandasExtensionArrayNode", PROTOCOL): PandasExtensionArrayNode, + ("PandasExtensionDtypeNode", PROTOCOL): PandasExtensionDtypeNode, + ("PandasCategoricalDtypeNode", PROTOCOL): PandasCategoricalDtypeNode, + ("PandasSparseDtypeNode", PROTOCOL): PandasSparseDtypeNode, + ("PandasDatetimeTZDtypeNode", PROTOCOL): PandasDatetimeTZDtypeNode, + ("PandasIntervalDtypeNode", PROTOCOL): PandasIntervalDtypeNode, +} diff --git a/skops/io/_persist.py b/skops/io/_persist.py index ba1fc8cb..027bb220 100644 --- a/skops/io/_persist.py +++ b/skops/io/_persist.py @@ -9,13 +9,21 @@ import skops +from . import _pandas from ._audit import NODE_TYPE_MAPPING, audit_tree, get_tree from ._utils import SaveContext, TrustedTypes, _get_state, get_state, read_schema # We load the dispatch functions from the corresponding modules and register # them. Old protocols are found in the 'old/' directory, with the protocol # version appended to the corresponding module name. -modules = ["._general", "._numpy", "._scipy", "._sklearn", "._quantile_forest"] +modules = [ + "._general", + "._numpy", + "._scipy", + "._sklearn", + "._quantile_forest", + "._pandas", +] modules.extend( [".old._general_v0", ".old._general_v2", ".old._numpy_v0", ".old._numpy_v1"] ) @@ -29,6 +37,10 @@ def _save(obj: Any, compression: int, compresslevel: int | None) -> io.BytesIO: + # pandas is optional and only imported by the user, so its get_state + # functions are registered here rather than when skops.io is imported. + _pandas.register_if_imported() + buffer = io.BytesIO() with ZipFile( diff --git a/skops/io/tests/_utils.py b/skops/io/tests/_utils.py index 28bd72c4..95da1d60 100644 --- a/skops/io/tests/_utils.py +++ b/skops/io/tests/_utils.py @@ -15,6 +15,11 @@ from skops.io._protocol import PROTOCOL from skops.io._utils import LoadContext, SaveContext +try: + import pandas as pd +except ImportError: # pragma: no cover + pd = None + # TODO: Investigate why that seems to be an issue on MacOS (only observed with # Python 3.8) ATOL = 1e-6 if sys.platform == "darwin" else 1e-7 @@ -104,10 +109,51 @@ def _assert_tuples_equal(val1, val2, path=""): _assert_vals_equal(subval1, subval2, path=f"{path}[]") +def _is_pandas_object(val): + return pd is not None and isinstance( + val, + ( + pd.DataFrame, + pd.Series, + pd.Index, + pd.api.extensions.ExtensionArray, + pd.api.extensions.ExtensionDtype, + ), + ) + + +def _assert_pandas_equal(val1, val2, path=""): + # Strict equality of pandas objects, up to what skops does not preserve: + # the freq of datetime-like indexes and arrays. + assert pd is not None # only called for pandas objects + assert type(val1) is type(val2), f"Path: type({path})" + if isinstance(val1, pd.DataFrame): + pd.testing.assert_frame_equal(val1, val2, check_freq=False, obj=path or "df") + elif isinstance(val1, pd.Series): + pd.testing.assert_series_equal( + val1, val2, check_freq=False, obj=path or "Series" + ) + elif isinstance(val1, pd.MultiIndex): + assert val1.sortorder == val2.sortorder, f"Path: {path}.sortorder" + pd.testing.assert_index_equal(val1, val2, exact=True, obj=path or "Index") + elif isinstance(val1, pd.Index): + if isinstance(val1, (pd.DatetimeIndex, pd.TimedeltaIndex)): + # assert_index_equal starts checking the freq by default in pandas 3.1 + val1 = type(val1)(val1, freq=None) + pd.testing.assert_index_equal(val1, val2, exact=True, obj=path or "Index") + elif isinstance(val1, pd.api.extensions.ExtensionArray): + pd.testing.assert_extension_array_equal(val1, val2) + else: # an extension dtype + assert val1 == val2, f"Path: {path}" + + @_skip_circular_references def _assert_vals_equal(val1, val2, path=""): if isinstance(val1, type): # e.g. could be np.int64 assert val1 is val2, f"Path: {path}" + elif _is_pandas_object(val1): + # before the __getstate__ branch, which would compare pandas internals + _assert_pandas_equal(val1, val2, path=path) elif hasattr(val1, "__getstate__") and (val1.__getstate__() is not None): # This includes BaseEstimator since they implement __getstate__ and # that returns the parameters as well. diff --git a/skops/io/tests/data/pandas-2.0.3.skops b/skops/io/tests/data/pandas-2.0.3.skops new file mode 100644 index 00000000..21408da0 Binary files /dev/null and b/skops/io/tests/data/pandas-2.0.3.skops differ diff --git a/skops/io/tests/data/pandas-3.0.3.skops b/skops/io/tests/data/pandas-3.0.3.skops new file mode 100644 index 00000000..92fb2356 Binary files /dev/null and b/skops/io/tests/data/pandas-3.0.3.skops differ diff --git a/skops/io/tests/test_external.py b/skops/io/tests/test_external.py index c608fdb2..c7de6028 100644 --- a/skops/io/tests/test_external.py +++ b/skops/io/tests/test_external.py @@ -17,7 +17,8 @@ import pytest from sklearn.datasets import make_classification, make_regression -from skops.io import dumps, loads, visualize +from skops.io import dumps, get_untrusted_types, loads, visualize +from skops.io._utils import get_type_name from skops.io.tests._utils import assert_method_outputs_equal, assert_params_equal from skops.utils._fixes import make_xgboost_random_forest @@ -439,3 +440,43 @@ def test_quantile_forest(self, quantile_forest, regr_data, trusted, tree_method) assert_method_outputs_equal(estimator, loaded, X) visualize(dumped, trusted=trusted) + + +class TestCategoryEncoders: + """Tests for category_encoders, whose fitted attributes hold pandas objects""" + + @pytest.fixture(autouse=True) + def ce(self): + return pytest.importorskip("category_encoders") + + @pytest.fixture + def trusted(self, ce): + return [ce.OrdinalEncoder, ce.TargetEncoder] + + # category_encoders uses deprecated pandas options, which the test setup + # turns into errors + @pytest.mark.filterwarnings("ignore") + def test_target_encoder(self, ce, trusted): + # the report in https://github.com/skops-dev/skops/issues/450 + pd = pytest.importorskip("pandas") + X = pd.DataFrame({"category": list("ABACBACCBA")}) + y = [0, 1, 0, 1, 1, 0, 1, 1, 1, 0] + + estimator = ce.TargetEncoder() + loaded = loads(dumps(estimator), trusted=trusted) + assert_params_equal(estimator.get_params(), loaded.get_params()) + + estimator.fit(X, y) + dumped = dumps(estimator) + # the pandas objects in the fitted attributes are not trusted by + # default either; a user passes the names the file holds + untrusted = get_untrusted_types(data=dumped) + assert {name for name in untrusted if not name.startswith("pandas.")} == { + get_type_name(t) for t in trusted + } + loaded = loads(dumped, trusted=untrusted) + assert_params_equal(estimator.__dict__, loaded.__dict__) + X_new = pd.DataFrame({"category": ["A", "C", "unseen", None]}) + assert_method_outputs_equal(estimator, loaded, X_new) + + visualize(dumped, trusted=untrusted) diff --git a/skops/io/tests/test_pandas.py b/skops/io/tests/test_pandas.py new file mode 100644 index 00000000..35c31baf --- /dev/null +++ b/skops/io/tests/test_pandas.py @@ -0,0 +1,686 @@ +"""Tests for persisting pandas objects.""" + +from __future__ import annotations + +import datetime as dt +import io +import json +import struct +import warnings +import zoneinfo +from pathlib import Path +from zipfile import ZipFile + +import numpy as np +import pytest +from packaging.version import Version + +from skops.io import dump, dumps, get_untrusted_types, load, loads, visualize +from skops.io._pandas import _RENAMED_CLASSES +from skops.io._utils import get_type_name +from skops.io.exceptions import ( + UnsupportedTypeException, + UntrustedTypesFoundException, +) +from skops.io.tests._utils import _assert_vals_equal + +pd = pytest.importorskip("pandas") + + +INDEXES = [ + pd.Index([1, 2, 3], name="ints"), + pd.Index([1.5, np.nan, 3.0]), + pd.Index([True, False]), + pd.Index(["a", None, "c"], name="strings"), + pd.Index([1, "a", None], dtype=object), + pd.Index([(1, 2), (3, 4)], dtype=object, tupleize_cols=False), + pd.Index([], dtype=object), + pd.Index([1, None, 3], dtype="Int64"), + pd.RangeIndex(5), + pd.RangeIndex(2, 20, 3, name="range"), + pd.date_range("2024-01-01", periods=3, name="dates"), + pd.date_range("2024-01-01", periods=3, tz="Europe/Berlin"), + pd.date_range("2024-01-01", periods=2, tz="UTC"), + # a fixed offset parsed from the strings + pd.to_datetime(["2024-01-01T00:00:00+01:00", "2024-01-02T00:00:00+01:00"]), + pd.DatetimeIndex(["2024-01-01", None]), + pd.timedelta_range("1D", periods=2), + pd.period_range("2024-01", periods=2, freq="M"), + pd.interval_range(0, 3), + pd.CategoricalIndex(["a", "b", "a"], categories=["b", "a"], ordered=True), + pd.MultiIndex.from_tuples([("a", 1), ("b", 2)], names=["letters", None]), + pd.MultiIndex.from_tuples([("a", 1), ("b", 2)], sortorder=0), +] + +SERIES = [ + pd.Series([1, 2, 3]), + pd.Series([1.5, np.nan], index=["a", "b"], name="floats"), + pd.Series(["x", "y", None]), + pd.Series(["x", 1, None], dtype=object), + pd.Series(np.array(["x", "y"], dtype=object), dtype=object), + pd.Series([1, None, 3], dtype="Int64"), + pd.Series([True, None], dtype="boolean"), + pd.Series([1.5, None], dtype="Float64"), + pd.Series(["a", "b", "a"], dtype="category"), + pd.Series(pd.Categorical(["a", "b"], categories=["b", "a", "c"], ordered=True)), + pd.Series(pd.to_datetime(["2024-01-01", None])), + pd.Series(pd.date_range("2024-01-01", periods=2, tz="UTC")), + pd.Series(pd.to_timedelta([1, 2], unit="D")), + pd.Series(pd.period_range("2024-01", periods=2, freq="M")), + pd.Series(pd.interval_range(0, 2)), + pd.Series(pd.arrays.SparseArray([0, 0, 1.5])), + pd.Series(pd.arrays.SparseArray([1 + 2j, 0, 0], fill_value=0)), + pd.Series(pd.arrays.SparseArray(pd.to_datetime(["2024-01-01", None]))), + pd.Series([1, 2], index=pd.MultiIndex.from_tuples([("a", 1), ("b", 2)])), + pd.Series([1, 2, 3], index=[1, 1, 2]), + pd.Series([1], name=("a", "b")), + pd.Series([], dtype=float), + # the shape of category_encoders' TargetEncoder.mapping values + pd.Series([0.49, 0.66, 0.6], index=pd.Index([1, 2, -1]), name="category"), + # pandas scalars in an object column + pd.Series( + [ + pd.Timestamp("2024-01-01 00:00:00.000000001", tz="Europe/Berlin"), + pd.Timedelta(1, unit="ns"), + pd.NA, + ], + dtype=object, + ), +] + +FRAMES = [ + pd.DataFrame( + {"i": [1, 2], "f": [1.5, np.nan], "s": ["a", None], "b": [True, False]} + ), + pd.DataFrame( + { + "o": pd.Series(["a", "b"], dtype=object), + "n": pd.array([1, None], dtype="Int64"), + "c": pd.Categorical(["x", "y"]), + "t": pd.date_range("2024-01-01", periods=2, tz="Europe/Berlin"), + } + ), + pd.DataFrame([[1, 2], [3, 4]], columns=["a", "a"]), + pd.DataFrame({"a": [1, 2]}, index=pd.Index(["x", "x"], name="dups")), + pd.DataFrame(index=pd.RangeIndex(3)), + pd.DataFrame(columns=["a", "b"]), + pd.DataFrame(), + pd.DataFrame( + np.arange(6).reshape(2, 3), + columns=pd.MultiIndex.from_tuples([("x", 1), ("x", 2), ("y", 1)]), + ), + pd.DataFrame( + {"a": [1]}, index=pd.MultiIndex.from_tuples([("k", 0)], names=["l", "n"]) + ), +] + +# the plain wrapper of a numpy array, under its old name on pandas 2.0 +_NumpyBackedArray = ( + getattr(pd.arrays, "NumpyExtensionArray", None) or pd.arrays.PandasArray +) + +ARRAYS = [ + pd.array([1, None], dtype="Int64"), + pd.array([1.5, None], dtype="Float64"), + pd.array([True, None], dtype="boolean"), + pd.array(["a", None], dtype="string"), + pd.Categorical(["a", "b"], categories=["b", "a"], ordered=True), + pd.array(pd.to_datetime(["2024-01-01", None])), + pd.array( + pd.to_datetime(["2024-01-01"]).tz_localize(dt.timezone(dt.timedelta(hours=1))) + ), + # fixed offsets with seconds, as in the local mean time of old data + pd.array( + pd.to_datetime(["1850-01-01"]).tz_localize( + dt.timezone(dt.timedelta(minutes=19, seconds=32)) + ) + ), + pd.array( + pd.to_datetime(["2024-01-01"]).tz_localize( + dt.timezone(-dt.timedelta(hours=3, minutes=30, seconds=45)) + ) + ), + pd.array(pd.to_timedelta([1], unit="s")), + pd.array(pd.period_range("2024-01", periods=1, freq="M")), + pd.array(pd.interval_range(0, 2)), + pd.arrays.SparseArray([0, 1]), + pd.Series([1, 2]).array, + # the plain wrapper around datetime64 and timedelta64 values, which pandas + # itself represents as DatetimeArray and TimedeltaArray + _NumpyBackedArray(np.array(["2024-01-01"], dtype="datetime64[ns]")), + _NumpyBackedArray(np.array([1], dtype="timedelta64[ns]")), +] + +DTYPES = [ + pd.Int64Dtype(), + pd.BooleanDtype(), + pd.StringDtype(), + pd.CategoricalDtype(["b", "a"], ordered=True), + pd.CategoricalDtype(), + pd.DatetimeTZDtype("ns", "UTC"), + pd.DatetimeTZDtype("us", "Europe/Berlin"), + pd.DatetimeTZDtype("ns", dt.timezone(dt.timedelta(seconds=30))), + pd.PeriodDtype("M"), + pd.IntervalDtype("int64", closed="left"), + pd.IntervalDtype("datetime64[ns]"), + pd.IntervalDtype(), + pd.SparseDtype(float, 0.0), + # fill values JSON cannot hold, and missing ones + pd.SparseDtype("complex128", 1 + 2j), + pd.SparseDtype("int64", np.nan), + pd.SparseDtype("datetime64[ns]"), + pd.SparseDtype("datetime64[ns]", pd.Timestamp("2024-01-01")), + pd.SparseDtype("timedelta64[ns]", pd.Timedelta("1h")), + # fill values with nanoseconds, which the numpy scalar constructors drop + pd.SparseDtype("datetime64[ns]", pd.Timestamp("2024-01-01 00:00:00.000000001")), + pd.SparseDtype("timedelta64[ns]", pd.Timedelta(1, unit="ns")), + pd.SparseDtype(object, "missing"), +] + + +SCALARS = [ + pd.Timestamp("2024-01-01 00:00:00.000000001"), + pd.Timestamp("2024-01-01", tz="Europe/Berlin"), + pd.Timestamp("2024-01-01", tz=dt.timezone(dt.timedelta(hours=1))), + pd.Timestamp("2024-01-01").as_unit("s"), + pd.Timedelta(1, unit="ns"), + pd.Timedelta(days=1).as_unit("ms"), + pd.NA, +] + + +def _id(obj): + return f"{type(obj).__name__}-{getattr(obj, 'dtype', '')}" + + +def _loads(dumped): + # pandas types are not trusted by default: a user passes the names a file + # holds, which are all public pandas names + untrusted = get_untrusted_types(data=dumped) + assert untrusted and all(name.partition(".")[0] == "pandas" for name in untrusted) + return loads(dumped, trusted=untrusted) + + +@pytest.mark.parametrize("obj", INDEXES + SERIES + FRAMES + ARRAYS + DTYPES, ids=_id) +def test_roundtrip(obj): + loaded = _loads(dumps(obj)) + # the strict pandas comparison shared with the estimator tests + _assert_vals_equal(obj, loaded) + + +@pytest.mark.parametrize("obj", SCALARS, ids=repr) +def test_scalar_roundtrip(obj): + loaded = _loads(dumps(obj)) + assert type(loaded) is type(obj) + # the repr shows the unit, the nanoseconds and the zone + assert repr(loaded) == repr(obj) + + +def test_pandas_types_are_not_trusted_by_default(): + dumped = dumps(FRAMES[1]) + untrusted = get_untrusted_types(data=dumped) + assert "pandas.DataFrame" in untrusted + with pytest.raises(UntrustedTypesFoundException, match="pandas.DataFrame"): + loads(dumped) + loads(dumped, trusted=untrusted) + + +def test_private_pandas_type_is_named_by_its_module(): + # the dtype of a numpy-backed extension array has no public name, so the + # file holds its defining module, which is then the name to trust + dtype = pd.Series([1, 2]).array.dtype + assert get_untrusted_types(data=dumps(dtype)) == [get_type_name(type(dtype))] + + +def _with_edited_schema(dumped, edit): + # ``dumped`` with ``edit`` applied to its schema, to mimic a crafted file + with ZipFile(io.BytesIO(dumped)) as zip_file: + schema = json.loads(zip_file.read("schema.json")) + files = { + name: zip_file.read(name) + for name in zip_file.namelist() + if name != "schema.json" + } + edit(schema) + buffer = io.BytesIO() + with ZipFile(buffer, "w") as zip_file: + zip_file.writestr("schema.json", json.dumps(schema)) + for name, data in files.items(): + zip_file.writestr(name, data) + return buffer.getvalue() + + +def test_dtype_node_only_builds_the_declared_class(): + # the name in the file is parsed by the declared dtype class, which the + # user trusts, and not looked up in pandas' registry of extension dtypes, + # where it could name the dtype of another library, whose code would run + dumped = dumps(pd.Int64Dtype()) + + def edit(schema): + schema["content"]["name"]["content"] = json.dumps("period[M]") + + with pytest.raises(TypeError, match="Cannot construct"): + loads(_with_edited_schema(dumped, edit), trusted=["pandas.Int64Dtype"]) + + +def test_dtype_node_refuses_other_classes(): + # a pandas class the user trusts that is not an extension dtype cannot be + # routed through the name based dtype loader + dumped = dumps(pd.Int64Dtype()) + + def edit(schema): + schema["__class__"] = "Series" + + with pytest.raises(ValueError, match="not a pandas extension dtype"): + loads(_with_edited_schema(dumped, edit), trusted=["pandas.Series"]) + + +def test_dtype_node_refuses_dtypes_stored_as_parts(): + # the parsers of these dtypes consult pandas' registry of extension dtypes + # or the file system, so a file cannot route them through the name based + # loader + dumped = dumps(pd.Int64Dtype()) + + def edit(schema): + schema["__class__"] = "DatetimeTZDtype" + schema["content"]["name"]["content"] = json.dumps( + "datetime64[ns, dateutil//etc/localtime]" + ) + + with pytest.raises(ValueError, match="not stored by its name"): + loads(_with_edited_schema(dumped, edit), trusted=["pandas.DatetimeTZDtype"]) + + +def test_dtype_node_refuses_arrow_dtype(): + # pyarrow backed dtypes are not supported: pyarrow would parse their + # names, so a file cannot route a name to it, whatever the user trusts + dumped = dumps(pd.Int64Dtype()) + + def edit(schema): + schema["__class__"] = "ArrowDtype" + + with pytest.raises(ValueError, match="not supported"): + loads(_with_edited_schema(dumped, edit), trusted=["pandas.ArrowDtype"]) + + +def test_arrow_backed_types_are_unsupported(): + pa = pytest.importorskip("pyarrow") + arrow = pd.ArrowDtype(pa.int64()) + for obj in [arrow, pd.array([1, None], dtype=arrow), pd.Series([1], dtype=arrow)]: + with pytest.raises(UnsupportedTypeException, match="not supported"): + dumps(obj) + # the string dtype with pyarrow storage is a string dtype, and supported + strings = pd.array(["a", None], dtype=pd.StringDtype("pyarrow")) + _assert_vals_equal(strings, _loads(dumps(strings))) + + +@pytest.mark.parametrize( + "name", ["dateutil//etc/localtime", "tzlocal()", "../../etc/localtime", "/x"] +) +def test_timezone_from_file_is_restricted(name): + # pandas' own parser would open any file on disk for "dateutil/"; + # only "UTC", fixed offsets and valid zoneinfo keys are accepted + dumped = dumps(pd.date_range("2024-01-01", periods=2, tz="UTC")) + trusted = get_untrusted_types(data=dumped) + + def edit(schema): + schema["content"]["values"]["content"]["tz"]["content"] = json.dumps(name) + + with pytest.raises(ValueError, match="not a known time zone"): + loads(_with_edited_schema(dumped, edit), trusted=trusted) + + +def test_file_backed_zone_is_unsupported(): + # a zone created with ZoneInfo.from_file may carry a key that does not + # describe its data, and the key is all the file holds, so it is refused + # like skops.io refuses such zones on their own. This one is a minimal + # version 1 TZif file with a single offset of one hour, under the key UTC. + tzif = ( + b"TZif\x00" + + bytes(15) + # number of isut, isstd, leap, transition, type and abbreviation entries + + struct.pack(">6l", 0, 0, 0, 0, 1, 4) + # the single type entry: utoff, isdst, abbreviation index + + struct.pack(">lbb", 3600, 0, 0) + + b"UTC\x00" + ) + tz = zoneinfo.ZoneInfo.from_file(io.BytesIO(tzif), key="UTC") + with pytest.raises(UnsupportedTypeException, match="ZoneInfo.from_file"): + dumps(pd.DatetimeTZDtype("ns", tz)) + with pytest.raises(UnsupportedTypeException, match="ZoneInfo.from_file"): + dumps(pd.array(pd.to_datetime(["2024-01-01"]).tz_localize(tz))) + + +def test_declared_class_must_be_one_the_loader_builds(): + # the audit trusts the class a file declares, so a loader only accepts the + # classes it builds: a trusted name cannot make it build another class + dumped = dumps(pd.RangeIndex(3)) + + def edit(schema): + schema["__class__"] = "Series" + + with pytest.raises(ValueError, match="not a class PandasRangeIndexNode builds"): + get_untrusted_types(data=_with_edited_schema(dumped, edit)) + + def edit_module(schema): + schema["__module__"] = "pandas.core.indexes.range" + + with pytest.raises(ValueError, match="not a class PandasRangeIndexNode builds"): + get_untrusted_types(data=_with_edited_schema(dumped, edit_module)) + + +def test_built_object_must_be_of_the_declared_class(): + # the declared class is one the loader builds, but the data builds another + dumped = dumps(pd.Index(pd.to_datetime(["2024-01-01"]))) + + def edit(schema): + schema["__class__"] = "Index" + + with pytest.raises(ValueError, match="builds pandas.DatetimeIndex"): + loads(_with_edited_schema(dumped, edit), trusted=["pandas.Index"]) + + +def test_numpy_backed_array_follows_the_declared_class(): + # the declared class, not the dtype of the values, decides between the + # plain wrapper and the datetime-like arrays, and must fit the values + timedeltas = dumps(pd.array(pd.to_timedelta([1], unit="s"))) + aware = dumps(pd.array(pd.to_datetime(["2024-01-01"]).tz_localize("UTC"))) + + def to_datetime_array(schema): + schema["__class__"] = "DatetimeArray" + + with pytest.raises(ValueError, match="must have a datetime64 dtype"): + loads( + _with_edited_schema(timedeltas, to_datetime_array), + trusted=["pandas.arrays.DatetimeArray"], + ) + + def to_wrapper(schema): + schema["__class__"] = "NumpyExtensionArray" + + with pytest.raises(ValueError, match="NumpyExtensionArray has no time zone"): + loads( + _with_edited_schema(aware, to_wrapper), + trusted=["pandas.arrays.NumpyExtensionArray"], + ) + + def with_zone(schema): + schema["content"]["tz"]["content"] = json.dumps("UTC") + + with pytest.raises(ValueError, match="TimedeltaArray has no time zone"): + loads( + _with_edited_schema(timedeltas, with_zone), + trusted=["pandas.arrays.TimedeltaArray"], + ) + + +def test_pandas_classes_as_trusted_entries(): + # a pandas class passed as trusted is matched by the public name the file + # holds, which on pandas 2.x differs from its defining module + loads(dumps(pd.Int64Dtype()), trusted=[pd.Int64Dtype]) + loads(dumps(pd.Series([1], index=pd.Index([1]))), trusted=[pd.Series, pd.Index]) + # PandasArray on pandas 2.0, with a dtype of a private class + array = pd.Series([1, 2]).array + loads(dumps(array), trusted=[type(array), type(array.dtype)]) + + +def test_sub_second_offset_is_unsupported(): + # the name of a fixed offset holds whole seconds; a finer offset cannot be + # written, and must not be rounded silently + tz = dt.timezone(dt.timedelta(microseconds=1)) + with pytest.raises(UnsupportedTypeException, match="whole seconds"): + dumps(pd.DatetimeTZDtype("ns", tz)) + + +def test_interval_dtype_subtype_is_parsed_by_numpy(): + # pandas would look a subtype name up in its registry of extension dtypes, + # where any imported library can register a dtype whose parser then runs + dumped = dumps(pd.IntervalDtype("int64")) + + def edit(schema): + schema["content"]["subtype"]["content"] = json.dumps("probe") + + trusted = ["pandas.IntervalDtype"] + with pytest.raises(ValueError, match="subtype of an interval dtype"): + loads(_with_edited_schema(dumped, edit), trusted=trusted) + + def edit_to_number(schema): + schema["content"]["subtype"]["content"] = json.dumps(5) + + with pytest.raises(ValueError, match="subtype of an interval dtype"): + loads(_with_edited_schema(dumped, edit_to_number), trusted=trusted) + + +def test_child_of_wrong_kind_is_refused(): + # the values of an Index are an array; a file holding something else there + # is refused while it is read, before anything is constructed + dumped = dumps(pd.Index([1, 2])) + + def edit(schema): + schema["content"]["values"] = { + "__class__": "str", + "__module__": "builtins", + "__loader__": "JsonNode", + "content": json.dumps("x"), + "is_json": True, + "__id__": 1, + } + + with pytest.raises(ValueError, match="Expected a node of type"): + loads(_with_edited_schema(dumped, edit)) + + +def test_missing_entry_is_refused(): + dumped = dumps(pd.Index([1, 2])) + + def edit(schema): + del schema["content"]["name"] + + with pytest.raises(ValueError, match="Expected the entries"): + loads(_with_edited_schema(dumped, edit)) + + +def test_subclass_from_other_library_is_unsupported(): + class MySeries(pd.Series): + pass + + with pytest.raises(UnsupportedTypeException, match="subclass of a pandas type"): + dumps(MySeries([1, 2])) + + +def test_visualize(capsys): + visualize(dumps(FRAMES[0])) + assert "pandas.DataFrame" in capsys.readouterr().out + + +def test_file_uses_public_type_names(): + # older pandas versions report the defining module of a class, e.g. + # pandas.core.series.Series, and pandas 3 reports pandas.Series; the file + # always holds the public name, which is the one a user passes as trusted + dumped = dumps(pd.Series([1], index=pd.Index([1]))) + with ZipFile(io.BytesIO(dumped)) as zip_file: + schema = json.loads(zip_file.read("schema.json")) + assert (schema["__module__"], schema["__class__"]) == ("pandas", "Series") + index = schema["content"]["index"] + assert (index["__module__"], index["__class__"]) == ("pandas", "Index") + + +class NamelessDtype(pd.api.extensions.ExtensionDtype): + """A dtype whose name does not rebuild it. + + pandas has such a dtype, ``ArrowDtype(pyarrow.string())``, whose name + "string[pyarrow]" it reserves for its ``StringDtype``; this stand-in needs + no pyarrow. The module is faked since skops only saves pandas' own dtypes. + """ + + __module__ = "pandas.tests" + name = "nameless" + type = object + + @classmethod + def construct_array_type(cls): + return pd.arrays.NumpyExtensionArray # pragma: no cover + + @classmethod + def construct_from_string(cls, string): + raise TypeError(f"Cannot construct a 'NamelessDtype' from '{string}'") + + +def test_sparse_fill_value_outside_json_is_unsupported(): + # a fill value of an object subtype has to be a JSON scalar: refused when + # saving rather than failing when loading + dtype = pd.SparseDtype(object, pd.Timestamp("2024-01-01")) + with pytest.raises(UnsupportedTypeException, match="fill value"): + dumps(dtype) + + +def test_sparse_fill_value_pandas_nat(): + # The missing value of pandas is not the one of numpy, and pandas does not + # consider the two dtypes equal, so it is refused; recent versions of + # pandas convert it to the numpy one when building the dtype. + dtype = pd.SparseDtype("datetime64[ns]", pd.NaT) + if isinstance(dtype.fill_value, np.generic): + assert _loads(dumps(dtype)) == dtype + else: + with pytest.raises(UnsupportedTypeException, match="fill value"): + dumps(dtype) + + +def test_dtype_not_rebuildable_from_its_name_is_unsupported(): + # refused when saving rather than failing when loading + with pytest.raises(UnsupportedTypeException, match="cannot be rebuilt"): + dumps(NamelessDtype()) + + +def test_renamed_classes_match_pandas_version(): + # each rename is a fact about pandas: before the version that introduced + # the new name only the old one exists, from then on the new one does + def exists(name): + with warnings.catch_warnings(): + # the old name may live on as a deprecated alias that warns + warnings.simplefilter("ignore") + return any(hasattr(module, name) for module in (pd, pd.arrays)) + + for old, (new, since) in _RENAMED_CLASSES.items(): + if Version(pd.__version__) < Version(since): + assert exists(old) and not exists(new), old + else: + assert exists(new), new + + +FIXTURE_DIR = Path(__file__).parent / "data" + + +def cross_version_objects(): + """Objects whose files, written by one pandas version, load on every other. + + :func:`write_pandas_fixture_file` dumps them with the running pandas version + into ``data/``, and :func:`test_load_file_of_other_pandas_version` loads + every file there. Add a file for a new pandas minor version when it changes + how any of these is represented, and never regenerate an existing one. + """ + return { + "str_index": pd.Index(["a", None, "c"], name="strings"), + "str_series": pd.Series(["x", "y", None], index=["a", "b", "c"]), + "mixed_frame": pd.DataFrame( + { + "i": [1, 2], + "f": [1.5, np.nan], + "s": ["a", None], + "o": pd.Series(["a", 1], dtype=object), + "c": pd.Categorical(["x", "y"], categories=["y", "x"], ordered=True), + "t": pd.date_range("2024-01-01", periods=2, tz="Europe/Berlin"), + } + ), + "datetime_index": pd.date_range("2024-01-01", periods=3, name="dates"), + "datetime_index_tz": pd.date_range("2024-01-01", periods=3, tz="UTC"), + "datetime_fixed_offset": pd.to_datetime(["2024-01-01T00:00:00+01:00"]), + "timedelta_index": pd.timedelta_range("1D", periods=2), + "period_series": pd.Series(pd.period_range("2024-01", periods=2, freq="M")), + "interval_index": pd.interval_range(0, 3), + "categorical_index": pd.CategoricalIndex( + ["a", "b", "a"], categories=["b", "a"], ordered=True + ), + "multi_index": pd.MultiIndex.from_tuples( + [("a", 1), ("b", 2)], names=["letters", None] + ), + "range_index": pd.RangeIndex(2, 20, 3, name="range"), + "nullable_frame": pd.DataFrame( + { + "i": pd.array([1, None], dtype="Int64"), + "b": pd.array([True, None], dtype="boolean"), + "f": pd.array([1.5, None], dtype="Float64"), + } + ), + "sparse_series": pd.Series(pd.arrays.SparseArray([0, 0, 1.5])), + "duplicate_columns": pd.DataFrame([[1, 2]], columns=["a", "a"]), + # the shape of category_encoders' TargetEncoder.mapping + "encoder_mapping": {"col": pd.Series([0.49, 0.66], index=pd.Index([1, 2]))}, + "dtypes": [ + pd.Int64Dtype(), + pd.CategoricalDtype(["b", "a"], ordered=True), + pd.StringDtype(), + ], + } + + +def write_pandas_fixture_file(): + """Dump the cross-version objects with the running pandas version.""" + path = FIXTURE_DIR / f"pandas-{pd.__version__}.skops" + dump(cross_version_objects(), path) + return path + + +def _as_objects(values): + # the values as Python objects, with ``None`` for every missing value + return values.to_numpy(dtype=object, na_value=None) + + +def assert_same_data(expected, actual): + """Equality up to the dtype differences between pandas versions. + + Strings are ``str`` in pandas 3 and ``object`` before, datetimes have a + microsecond resolution in pandas 3 and nanoseconds before, and a file keeps + the dtypes of the version that wrote it. The values are therefore compared + as Python objects, with a single marker for missing values. + """ + assert type(actual) is type(expected) + if isinstance(expected, pd.DataFrame): + assert expected.shape == actual.shape + assert_same_data(expected.columns, actual.columns) + assert_same_data(expected.index, actual.index) + for i in range(expected.shape[1]): + np.testing.assert_array_equal( + _as_objects(expected.iloc[:, i]), _as_objects(actual.iloc[:, i]) + ) + elif isinstance(expected, pd.Series): + assert expected.name == actual.name + assert_same_data(expected.index, actual.index) + np.testing.assert_array_equal(_as_objects(expected), _as_objects(actual)) + elif isinstance(expected, pd.MultiIndex): + assert list(expected.names) == list(actual.names) + assert_same_data(expected.to_frame(index=False), actual.to_frame(index=False)) + elif isinstance(expected, pd.Index): + assert expected.name == actual.name + np.testing.assert_array_equal(_as_objects(expected), _as_objects(actual)) + elif isinstance(expected, dict): + assert expected.keys() == actual.keys() + for key in expected: + assert_same_data(expected[key], actual[key]) + elif isinstance(expected, list): + assert len(expected) == len(actual) + for expected_item, actual_item in zip(expected, actual): + assert_same_data(expected_item, actual_item) + else: + assert expected == actual + + +@pytest.mark.parametrize( + "path", sorted(FIXTURE_DIR.glob("pandas-*.skops")), ids=lambda path: path.stem +) +def test_load_file_of_other_pandas_version(path): + # the names in the file are the public ones, which every version resolves + untrusted = get_untrusted_types(file=path) + assert all(name.partition(".")[0] == "pandas" for name in untrusted) + loaded = load(path, trusted=untrusted) + assert_same_data(cross_version_objects(), loaded) diff --git a/skops/io/tests/test_persist.py b/skops/io/tests/test_persist.py index edb5e4ad..d758bb70 100644 --- a/skops/io/tests/test_persist.py +++ b/skops/io/tests/test_persist.py @@ -76,7 +76,14 @@ SCIPY_UFUNC_TYPE_NAMES, SKLEARN_ESTIMATOR_TYPE_NAMES, ) -from skops.io._utils import LoadContext, _get_state, get_module, get_state, gettype +from skops.io._utils import ( + LoadContext, + _get_state, + get_module, + get_state, + get_type_name, + gettype, +) from skops.io.exceptions import UnsupportedTypeException, UntrustedTypesFoundException from skops.io.tests._utils import ( assert_method_outputs_equal, @@ -1587,6 +1594,32 @@ def test_reduce_raises_falls_back_to_dict(): assert loaded_obj.x == 3 +# This class is here as opposed to inside the test because it needs to be importable. +# It mirrors the fitted attributes of category_encoders' TargetEncoder, see gh-450. +class PandasEncoder(BaseEstimator): + def fit(self, X, y=None): + import pandas as pd + + self.mapping_ = {"col": pd.Series([0.49, 0.66], index=pd.Index([1, 2]))} + self.categories_ = pd.Index(["A", "B"], name="col") + self.dtypes_ = [pd.StringDtype(), pd.Int64Dtype()] + return self + + +def test_estimator_with_pandas_attributes(): + pytest.importorskip("pandas") + estimator = PandasEncoder().fit(None) + dumped = dumps(estimator) + # neither the estimator nor the pandas objects it holds are trusted by + # default; the file names the latter by their public pandas names + untrusted = get_untrusted_types(data=dumped) + assert get_type_name(PandasEncoder) in untrusted + assert {"pandas.Series", "pandas.Index", "pandas.StringDtype"} <= set(untrusted) + + loaded = loads(dumped, trusted=untrusted) + assert_params_equal(estimator.__dict__, loaded.__dict__) + + def test_loss_get_state_unsupported_reduce(): # loss_get_state understands the two shapes of __reduce__ output produced by # scikit-learn's loss classes, and refuses anything else.