Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Python 3.11 #50

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest]
python-version: [3.8, 3.9, "3.10"]
python-version: [3.8, 3.9, "3.10", "3.11"]

timeout-minutes: 30

Expand Down
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,34 @@ matter of days, with no need to reimplement all the plumbings from scratch.

This project requires Python v3.8 or later.

To install the latest version of the package from [PyPI](https://pypi.org/project/black-it/):
To install the latest version of the package from [PyPI](https://pypi.org/project/black-it/), with all the extra dependencies (recommended):
```
pip install black-it
pip install "black-it[all]"
```

Or, directly from GitHub:

```
pip install git+https://github.com/bancaditalia/black-it.git#egg=black-it
pip install git+https://github.com/bancaditalia/black-it.git#egg="black-it[all]"
```

If you'd like to contribute to the package, please read the [CONTRIBUTING.md](./CONTRIBUTING.md) guide.

### Feature-specific Package Dependencies

We use the [optional dependencies mechanism of `setuptools`](https://setuptools.pypa.io/en/latest/userguide/dependency_management.html#optional-dependencies)
(also called _extras_) to allow users to avoid dependencies for features they don't use.

For the basic features of the package, you can install the `black-it` package without extras, e.g. `pip install black-it`.
However, for certain components, you will need to install some more extras using the syntax `pip install black-it[extra-1,extra-2,...]`.

For example, the [Gaussian Process Sampler](https://bancaditalia.github.io/black-it/samplers/#black_it.samplers.gaussian_process.GaussianProcessSampler)
depends on the Python package [`GPy`](https://github.com/SheffieldML/GPy/).
If the Gaussian Process sampler is not needed by your application, you can avoid its installation by just installing `black-it` as explained above.
However, if you need the sampler, you must install `black-it` with the `gp-sampler` extra: `pip install black-it[gp-sampler]`.

The special extra `all` will install all the dependencies.

## Quick Example

The GitHub repo of Black-it contains a series ready-to-run calibration examples.
Expand Down
112 changes: 112 additions & 0 deletions black_it/_load_dependency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Black-box ABM Calibration Kit (Black-it)
# Copyright (C) 2021-2023 Banca d'Italia
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""
Python module to handle extras dependencies loading and import errors.
This is a private module of the library. There should be no point in using it directly from client code.
"""

import sys
from typing import Optional

# known extras and their dependencies
_GPY_PACKAGE_NAME = "GPy"
_GP_SAMPLER_EXTRA_NAME = "gp-sampler"

_XGBOOST_PACKAGE_NAME = "xgboost"
_XGBOOST_SAMPLER_EXTRA_NAME = "xgboost-sampler"


class DependencyNotInstalled(Exception):
"""Library exception for when a required dependency is not installed."""

def __init__(self, component_name: str, package_name: str, extra_name: str) -> None:
"""Initialize the exception object."""
message = (
f"Cannot import package '{package_name}', required by component {component_name}. "
f"To solve the issue, you can install the extra '{extra_name}': pip install black-it[{extra_name}]"
)
super().__init__(message)


class GPyNotSupportedOnPy311Exception(Exception):
"""Specific exception class for import error of GPy on Python 3.11."""

__ERROR_MSG = (
f"The GaussianProcessSampler depends on '{_GPY_PACKAGE_NAME}', which is not supported on Python 3.11; "
f"see https://github.com/bancaditalia/black-it/issues/36"
)

def __init__(self) -> None:
"""Initialize the exception object."""
super().__init__(self.__ERROR_MSG)


def _check_import_error_else_raise_exception(
import_error: Optional[ImportError],
component_name: str,
package_name: str,
black_it_extra_name: str,
) -> None:
"""
Check an import error; raise the DependencyNotInstalled exception with a useful message.
Args:
import_error: the ImportError object generated by the failed attempt. If None, then no error occurred.
component_name: the component for which the dependency is needed
package_name: the Python package name of the dependency
black_it_extra_name: the name of the black-it extra to install to solve the issue.
"""
if import_error is None:
# nothing to do.
return

# an import error happened; we need to raise error to the caller
raise DependencyNotInstalled(component_name, package_name, black_it_extra_name)


def _check_gpy_import_error_else_raise_exception(
import_error: Optional[ImportError],
component_name: str,
package_name: str,
black_it_extra_name: str,
) -> None:
"""
Check GPy import error and if an error occurred, raise erorr with a useful error message.
We need to handle two cases:
- the user is using Python 3.11: the GPy package cannot be installed there;
see https://github.com/SheffieldML/GPy/issues/998
- the user did not install the 'gp-sampler' extra.
Args:
import_error: the ImportError object generated by the failed attempt. If None, then no error occurred.
component_name: the component for which the dependency is needed
package_name: the Python package name of the dependency
black_it_extra_name: the name of the black-it extra to install to solve the issue.
"""
if import_error is None:
# nothing to do.
return

if sys.version_info == (3, 11):
raise GPyNotSupportedOnPy311Exception()

_check_import_error_else_raise_exception(
import_error, component_name, package_name, black_it_extra_name
)
29 changes: 25 additions & 4 deletions black_it/samplers/gaussian_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,26 @@
from enum import Enum
from typing import Optional, Tuple, cast

import GPy
import numpy as np
from GPy.models import GPRegression
from numpy.typing import NDArray
from scipy.special import erfc # pylint: disable=no-name-in-module

from black_it._load_dependency import (
_GP_SAMPLER_EXTRA_NAME,
_GPY_PACKAGE_NAME,
_check_gpy_import_error_else_raise_exception,
)
from black_it.samplers.surrogate import MLSurrogateSampler

_GPY_IMPORT_ERROR: Optional[ImportError]
try:
import GPy
from GPy.models import GPRegression
except ImportError as e:
_GPY_IMPORT_ERROR = e
else:
_GPY_IMPORT_ERROR = None


class _AcquisitionTypes(Enum):
"""Enumeration of allowed acquisition types."""
Expand Down Expand Up @@ -71,6 +83,8 @@ def __init__( # pylint: disable=too-many-arguments
optimize_restarts: number of independent random trials of the optimization of the GP hyperparameters
acquisition: type of acquisition function, it can be 'expected_improvement' of simply 'mean'
"""
self.__check_gpy_import_error()

self._validate_acquisition(acquisition)

super().__init__(
Expand All @@ -81,6 +95,13 @@ def __init__( # pylint: disable=too-many-arguments
self.acquisition = acquisition
self._gpmodel: Optional[GPRegression] = None

@classmethod
def __check_gpy_import_error(cls) -> None:
"""Check if an import error happened while attempting to import the 'GPy' package."""
_check_gpy_import_error_else_raise_exception(
_GPY_IMPORT_ERROR, cls.__name__, _GPY_PACKAGE_NAME, _GP_SAMPLER_EXTRA_NAME
)

@staticmethod
def _validate_acquisition(acquisition: str) -> None:
"""
Expand All @@ -94,12 +115,12 @@ def _validate_acquisition(acquisition: str) -> None:
"""
try:
_AcquisitionTypes(acquisition)
except ValueError as e:
except ValueError as exp:
raise ValueError(
"expected one of the following acquisition types: "
f"[{' '.join(map(str, _AcquisitionTypes))}], "
f"got {acquisition}"
) from e
) from exp

def fit(self, X: NDArray[np.float64], y: NDArray[np.float64]) -> None:
"""Fit a gaussian process surrogate model."""
Expand Down
25 changes: 24 additions & 1 deletion black_it/samplers/xgboost.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,27 @@
from typing import Optional, cast

import numpy as np
import xgboost as xgb
from numpy.typing import NDArray

from black_it._load_dependency import (
_XGBOOST_PACKAGE_NAME,
_XGBOOST_SAMPLER_EXTRA_NAME,
_check_import_error_else_raise_exception,
)
from black_it.samplers.surrogate import MLSurrogateSampler

MAX_FLOAT32 = np.finfo(np.float32).max
MIN_FLOAT32 = np.finfo(np.float32).min
EPS_FLOAT32 = np.finfo(np.float32).eps

_XGBOOST_IMPORT_ERROR: Optional[ImportError]
try:
import xgboost as xgb
except ImportError as e:
_XGBOOST_IMPORT_ERROR = e
else:
_XGBOOST_IMPORT_ERROR = None


class XGBoostSampler(MLSurrogateSampler):
"""This class implements xgboost sampling."""
Expand Down Expand Up @@ -64,6 +76,7 @@ def __init__( # pylint: disable=too-many-arguments
References:
Lamperti, Roventini, and Sani, "Agent-based model calibration using machine learning surrogates"
"""
self.__check_xgboost_import_error()
super().__init__(
batch_size, random_state, max_deduplication_passes, candidate_pool_size
)
Expand All @@ -75,6 +88,16 @@ def __init__( # pylint: disable=too-many-arguments
self._n_estimators = n_estimators
self._xg_regressor: Optional[xgb.XGBRegressor] = None

@classmethod
def __check_xgboost_import_error(cls) -> None:
"""Check if an import error happened while attempting to import the 'xgboost' package."""
_check_import_error_else_raise_exception(
_XGBOOST_IMPORT_ERROR,
cls.__name__,
_XGBOOST_PACKAGE_NAME,
_XGBOOST_SAMPLER_EXTRA_NAME,
)

@property
def colsample_bytree(self) -> float:
"""Get the colsample_bytree parameter."""
Expand Down
7 changes: 6 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ classifiers = [
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Topic :: Scientific/Engineering'
]

Expand All @@ -38,7 +39,7 @@ classifiers = [
"Pull Requests" = "https://github.com/bancaditalia/black-it/pulls"

[tool.poetry.dependencies]
python = ">=3.8,<3.11"
python = ">=3.8,<3.12"
GPy = {git = "https://github.com/SheffieldML/GPy.git", rev = "f63ed48"}
ipywidgets = "^8.0.4"
matplotlib = "^3.7.1"
Expand Down Expand Up @@ -91,6 +92,14 @@ tox = "^4.4.7"
twine = "^4.0.0"
vulture = "^2.3"

GPy = { version = "^1.10.0", optional = true }
xgboost = { version = "^1.7.2", optional = true }

[tool.poetry.extras]
gp-sampler = ["GPy"]
xgboost-sampler = ["xgboost"]
all = ["GPy", "xgboost"]

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
Expand Down
4 changes: 4 additions & 0 deletions tests/test_calibrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@
from black_it.search_space import SearchSpace

from .fixtures.test_models import NormalMV # type: ignore
from .utils.base import no_gpy_installed, no_python311_for_gpy, no_xgboost_installed


@no_python311_for_gpy
@no_gpy_installed
@no_xgboost_installed
class TestCalibrate: # pylint: disable=too-many-instance-attributes,attribute-defined-outside-init
"""Test the Calibrator.calibrate method."""

Expand Down
3 changes: 3 additions & 0 deletions tests/test_samplers/test_gaussian_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@

from black_it.samplers.gaussian_process import GaussianProcessSampler, _AcquisitionTypes
from black_it.search_space import SearchSpace
from tests.utils.base import no_gpy_installed, no_python311_for_gpy

pytestmark = [no_python311_for_gpy, no_gpy_installed] # noqa


class TestGaussianProcess2D: # pylint: disable=attribute-defined-outside-init
Expand Down
4 changes: 4 additions & 0 deletions tests/test_samplers/test_xgboost.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
from black_it.search_space import SearchSpace

from ..fixtures.test_models import BH4 # type: ignore
from ..utils.base import no_xgboost_installed

pytestmark = no_xgboost_installed # noqa


expected_params = np.array([[0.24, 0.26], [0.26, 0.02], [0.08, 0.24], [0.15, 0.15]])

Expand Down
Loading