Skip to content

Commit 500e8e4

Browse files
fix: Add temporary upper bounds on NumPy and SciPy to pass tests (#2592)
* Add upper bound of SciPy v1.16.0 as there are numerical differences compared to v1.15.x and convergence issues that can lead to 'Singular matrix E in LSQ subproblem'. - c.f. #2593 * Add upper bound of NumPy v2.0 to the PyTorch extra as PyTorch is currently incompatible with NumPy v2.0. - c.f. pytorch/pytorch#157973 * Add try-except for from `click.testing.CliRunner` to work with versions of Click older than v8.2.0. * Use typing.cast to have all NumPy backend operations be cast to type numpy.typing.ArrayLike to satisfy mypy. * Add pytest fixture to disable CUDA acceleration by default for GPU enabled machines so that the tests will run in CPU mode by default. - Add `--enable-cuda` flag that can be passed to let CUDA backends be turned on. * Remove `pyhf` CLI command with no arguments from Docker image test as it results in an exit code of 2. * Add ignores to filterwarning for Click, papermill, and PyTorch. - DeprecationWarning: 'MultiCommand' is deprecated and will be removed in Click 9.0. Use 'Group' instead. - DeprecationWarning: Jupyter is migrating its paths to use standard platformdirs given by the platformdirs library. To remove this warning and see the appropriate new directories, set the environment variable `JUPYTER_PLATFORM_DIRS=1` and then run `jupyter --paths`. The use of platformdirs will be the default in `jupyter_core` v6. - DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). - DeprecationWarning: In future, it will be an error for 'np.bool' scalars to be interpreted as an index. - DeprecationWarning: __array__ implementation doesn't accept a copy keyword, so passing copy=False failed. __array__ must implement 'dtype' and 'copy' keyword arguments. - c.f. https://numpy.org/devdocs/numpy_2_0_migration_guide.html#adapting-to-changes-in-the-copy-keyword Co-authored-by: Giordon Stark <[email protected]>
1 parent 40ebf6d commit 500e8e4

File tree

6 files changed

+67
-27
lines changed

6 files changed

+67
-27
lines changed

.github/workflows/docker.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,6 @@ jobs:
101101

102102
- name: Run CLI API check
103103
run: |
104-
printf "\npyhf\n"
105-
docker run --rm pyhf/pyhf:sha-${GITHUB_SHA::8}
106104
printf "\npyhf --version\n"
107105
docker run --rm pyhf/pyhf:sha-${GITHUB_SHA::8} --version
108106
printf "\npyhf --help\n"

pyproject.toml

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ dependencies = [
5050
"jsonpatch>=1.15",
5151
"jsonschema>=4.15.0", # for utils
5252
"pyyaml>=5.1", # for parsing CLI equal-delimited options
53-
"scipy>=1.5.2", # requires numpy, which is required by pyhf and tensorflow
53+
# c.f. https://github.com/scikit-hep/pyhf/issues/2593 for scipy v1.16.0 upper bound
54+
"scipy>=1.5.2,<1.16.0", # requires numpy, which is required by pyhf and tensorflow
5455
"tqdm>=4.56.0", # for readxml
5556
"numpy", # compatible versions controlled through scipy
5657
]
@@ -70,7 +71,7 @@ Homepage = "https://github.com/scikit-hep/pyhf"
7071
shellcomplete = ["click_completion"]
7172
# TODO: 'tensorflow' supports all platform_machine for tensorflow v2.16.1+
7273
# but TensorFlow only supports python_version 3.8 up through tensorflow v2.13.1.
73-
# So until Python 3.8 support is dropped, split requirments on python_version
74+
# So until Python 3.8 support is dropped, split requirements on python_version
7475
# before and after 3.9.
7576
# NOTE: macos x86 support is deprecated from tensorflow v2.17.0 onwards.
7677
tensorflow = [
@@ -79,9 +80,14 @@ tensorflow = [
7980
"tensorflow-macos>=2.7.0; python_version < '3.9' and platform_machine == 'arm64' and platform_system == 'Darwin'", # c.f. PR #2119, #2452
8081
"tensorflow-probability>=0.11.0; python_version < '3.9'", # c.f. PR #1657, #2452
8182
# python >= 3.9
82-
"tensorflow-probability[tf]>=0.24.0; python_version >= '3.9'" # c.f. PR #2452
83+
"tensorflow-probability[tf]>=0.24.0,<0.25.0; python_version >= '3.9' and platform_machine != 'arm64' and platform_system == 'Darwin'", # c.f. TensorFlow v2.17.0
84+
"tensorflow-probability[tf]>=0.24.0; python_version >= '3.9' and platform_machine == 'arm64' and platform_system == 'Darwin'", # c.f. TensorFlow v2.17.0
85+
"tensorflow-probability[tf]>=0.24.0; python_version >= '3.9' and platform_system != 'Darwin'" # c.f. TensorFlow v2.17.0
86+
]
87+
torch = [
88+
"torch>=1.10.0", # c.f. PR #1657
89+
"numpy<2.0" # c.f. https://github.com/pytorch/pytorch/issues/157973
8390
]
84-
torch = ["torch>=1.10.0"] # c.f. PR #1657
8591
jax = [
8692
"jax>=0.4.1", # c.f. PR #2079
8793
"jaxlib>=0.4.1", # c.f. PR #2079
@@ -229,6 +235,11 @@ filterwarnings = [
229235
"ignore:Skipping device Apple Paravirtual device that does not support Metal 2.0:UserWarning", # Can't fix given hardware/virtualized device
230236
'ignore:Type google._upb._message.[A-Z]+ uses PyType_Spec with a metaclass that has custom:DeprecationWarning', # protobuf via tensorflow
231237
"ignore:jax.xla_computation is deprecated. Please use the AOT APIs:DeprecationWarning", # jax v0.4.30
238+
"ignore:'MultiCommand' is deprecated and will be removed in Click 9.0. Use 'Group' instead.:DeprecationWarning", # Click
239+
"ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning", # papermill
240+
"ignore:datetime.datetime.utcnow\\(\\) is deprecated:DeprecationWarning", # papermill
241+
"ignore:In future, it will be an error for 'np.bool' scalars to be interpreted as an index:DeprecationWarning", # PyTorch
242+
"ignore:__array__ implementation doesn't accept a copy keyword, so passing copy=False failed. __array__ must implement 'dtype' and 'copy' keyword arguments.:DeprecationWarning", # PyTorch interacting with NumPy
232243
]
233244

234245
[tool.coverage.run]

src/pyhf/tensor/numpy_backend.py

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111
if TYPE_CHECKING:
1212
from numpy.typing import ArrayLike, DTypeLike, NBitBase, NDArray
1313
else:
14+
ArrayLike = "ArrayLike"
1415
NBitBase = "NBitBase"
1516

1617
from scipy import special
1718
from scipy.special import gammaln, xlogy
1819
from scipy.stats import norm, poisson
1920

2021
from pyhf.typing import Literal, Shape
22+
from typing import cast
2123

2224
T = TypeVar("T", bound=NBitBase)
2325

@@ -26,27 +28,32 @@
2628
log = logging.getLogger(__name__)
2729

2830

29-
class _BasicPoisson:
31+
class _BasicPoisson(Generic[T]):
3032
def __init__(self, rate: Tensor[T]):
3133
self.rate = rate
3234

3335
def sample(self, sample_shape: Shape) -> ArrayLike:
34-
return poisson(self.rate).rvs(size=sample_shape + self.rate.shape) # type: ignore[no-any-return]
36+
return cast(
37+
ArrayLike, poisson(self.rate).rvs(size=sample_shape + self.rate.shape)
38+
)
3539

36-
def log_prob(self, value: NDArray[np.number[T]]) -> ArrayLike:
40+
def log_prob(self, value: Tensor[T]) -> ArrayLike:
3741
tensorlib: numpy_backend[T] = numpy_backend()
3842
return tensorlib.poisson_logpdf(value, self.rate)
3943

4044

41-
class _BasicNormal:
45+
class _BasicNormal(Generic[T]):
4246
def __init__(self, loc: Tensor[T], scale: Tensor[T]):
4347
self.loc = loc
4448
self.scale = scale
4549

4650
def sample(self, sample_shape: Shape) -> ArrayLike:
47-
return norm(self.loc, self.scale).rvs(size=sample_shape + self.loc.shape) # type: ignore[no-any-return]
51+
return cast(
52+
ArrayLike,
53+
norm(self.loc, self.scale).rvs(size=sample_shape + self.loc.shape),
54+
)
4855

49-
def log_prob(self, value: NDArray[np.number[T]]) -> ArrayLike:
56+
def log_prob(self, value: Tensor[T]) -> ArrayLike:
5057
tensorlib: numpy_backend[T] = numpy_backend()
5158
return tensorlib.normal_logpdf(value, self.loc, self.scale)
5259

@@ -125,7 +132,7 @@ def erf(self, tensor_in: Tensor[T]) -> ArrayLike:
125132
Returns:
126133
NumPy ndarray: The values of the error function at the given points.
127134
"""
128-
return special.erf(tensor_in) # type: ignore[no-any-return]
135+
return cast(ArrayLike, special.erf(tensor_in))
129136

130137
def erfinv(self, tensor_in: Tensor[T]) -> ArrayLike:
131138
"""
@@ -145,7 +152,7 @@ def erfinv(self, tensor_in: Tensor[T]) -> ArrayLike:
145152
Returns:
146153
NumPy ndarray: The values of the inverse of the error function at the given points.
147154
"""
148-
return special.erfinv(tensor_in) # type: ignore[no-any-return]
155+
return cast(ArrayLike, special.erfinv(tensor_in))
149156

150157
def tile(self, tensor_in: Tensor[T], repeats: int | Sequence[int]) -> ArrayLike:
151158
"""
@@ -207,7 +214,7 @@ def tolist(self, tensor_in: Tensor[T] | list[T]) -> list[T]:
207214
raise
208215

209216
def outer(self, tensor_in_1: Tensor[T], tensor_in_2: Tensor[T]) -> ArrayLike:
210-
return np.outer(tensor_in_1, tensor_in_2) # type: ignore[arg-type]
217+
return cast(ArrayLike, np.outer(tensor_in_1, tensor_in_2))
211218

212219
def gather(self, tensor: Tensor[T], indices: NDArray[np.integer[T]]) -> ArrayLike:
213220
return tensor[indices]
@@ -255,7 +262,7 @@ def sum(self, tensor_in: Tensor[T], axis: int | None = None) -> ArrayLike:
255262
return np.sum(tensor_in, axis=axis)
256263

257264
def product(self, tensor_in: Tensor[T], axis: Shape | None = None) -> ArrayLike:
258-
return np.prod(tensor_in, axis=axis) # type: ignore[arg-type]
265+
return cast(ArrayLike, np.prod(tensor_in, axis=axis))
259266

260267
def abs(self, tensor: Tensor[T]) -> ArrayLike:
261268
return np.abs(tensor)
@@ -345,7 +352,7 @@ def percentile(
345352
.. versionadded:: 0.7.0
346353
"""
347354
# see https://github.com/numpy/numpy/issues/22125
348-
return np.percentile(tensor_in, q, axis=axis, interpolation=interpolation) # type: ignore[call-overload,no-any-return]
355+
return cast(ArrayLike, np.percentile(tensor_in, q, axis=axis, interpolation=interpolation)) # type: ignore[call-overload]
349356

350357
def stack(self, sequence: Sequence[Tensor[T]], axis: int = 0) -> ArrayLike:
351358
return np.stack(sequence, axis=axis)
@@ -392,7 +399,7 @@ def simple_broadcast(self, *args: Sequence[Tensor[T]]) -> Sequence[Tensor[T]]:
392399
return np.broadcast_arrays(*args)
393400

394401
def shape(self, tensor: Tensor[T]) -> Shape:
395-
return tensor.shape
402+
return cast(Shape, tensor.shape)
396403

397404
def reshape(self, tensor: Tensor[T], newshape: Shape) -> ArrayLike:
398405
return np.reshape(tensor, newshape)
@@ -434,10 +441,10 @@ def einsum(self, subscripts: str, *operands: Sequence[Tensor[T]]) -> ArrayLike:
434441
Returns:
435442
tensor: the calculation based on the Einstein summation convention
436443
"""
437-
return np.einsum(subscripts, *operands) # type: ignore[arg-type,no-any-return]
444+
return cast(ArrayLike, np.einsum(subscripts, *operands))
438445

439446
def poisson_logpdf(self, n: Tensor[T], lam: Tensor[T]) -> ArrayLike:
440-
return xlogy(n, lam) - lam - gammaln(n + 1.0) # type: ignore[no-any-return]
447+
return cast(ArrayLike, xlogy(n, lam) - lam - gammaln(n + 1.0))
441448

442449
def poisson(self, n: Tensor[T], lam: Tensor[T]) -> ArrayLike:
443450
r"""
@@ -481,7 +488,7 @@ def poisson(self, n: Tensor[T], lam: Tensor[T]) -> ArrayLike:
481488
"""
482489
_n = np.asarray(n)
483490
_lam = np.asarray(lam)
484-
return np.exp(xlogy(_n, _lam) - _lam - gammaln(_n + 1.0)) # type: ignore[no-any-return,operator]
491+
return cast(ArrayLike, np.exp(xlogy(_n, _lam) - _lam - gammaln(_n + 1)))
485492

486493
def normal_logpdf(self, x: Tensor[T], mu: Tensor[T], sigma: Tensor[T]) -> ArrayLike:
487494
# this is much faster than
@@ -491,7 +498,7 @@ def normal_logpdf(self, x: Tensor[T], mu: Tensor[T], sigma: Tensor[T]) -> ArrayL
491498
root2pi = np.sqrt(2 * np.pi)
492499
prefactor = -np.log(sigma * root2pi)
493500
summand = -np.square(np.divide((x - mu), (root2 * sigma)))
494-
return prefactor + summand # type: ignore[no-any-return]
501+
return cast(ArrayLike, prefactor + summand)
495502

496503
# def normal_logpdf(self, x, mu, sigma):
497504
# return norm.logpdf(x, loc=mu, scale=sigma)
@@ -522,7 +529,7 @@ def normal(self, x: Tensor[T], mu: Tensor[T], sigma: Tensor[T]) -> ArrayLike:
522529
Returns:
523530
NumPy float: Value of Normal(x|mu, sigma)
524531
"""
525-
return norm.pdf(x, loc=mu, scale=sigma) # type: ignore[no-any-return]
532+
return cast(ArrayLike, norm.pdf(x, loc=mu, scale=sigma))
526533

527534
def normal_cdf(
528535
self, x: Tensor[T], mu: float | Tensor[T] = 0, sigma: float | Tensor[T] = 1
@@ -548,9 +555,9 @@ def normal_cdf(
548555
Returns:
549556
NumPy float: The CDF
550557
"""
551-
return norm.cdf(x, loc=mu, scale=sigma) # type: ignore[no-any-return]
558+
return cast(ArrayLike, norm.cdf(x, loc=mu, scale=sigma))
552559

553-
def poisson_dist(self, rate: Tensor[T]) -> _BasicPoisson:
560+
def poisson_dist(self, rate: Tensor[T]) -> _BasicPoisson[T]:
554561
r"""
555562
The Poisson distribution with rate parameter :code:`rate`.
556563
@@ -571,7 +578,7 @@ def poisson_dist(self, rate: Tensor[T]) -> _BasicPoisson:
571578
"""
572579
return _BasicPoisson(rate)
573580

574-
def normal_dist(self, mu: Tensor[T], sigma: Tensor[T]) -> _BasicNormal:
581+
def normal_dist(self, mu: Tensor[T], sigma: Tensor[T]) -> _BasicNormal[T]:
575582
r"""
576583
The Normal distribution with mean :code:`mu` and standard deviation :code:`sigma`.
577584

tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import os
23
import pathlib
34
import shutil
45
import sys
@@ -18,6 +19,12 @@ def pytest_addoption(parser):
1819
choices=["tensorflow", "pytorch", "jax", "minuit"],
1920
help="list of backends to disable in tests",
2021
)
22+
parser.addoption(
23+
"--enable-cuda",
24+
action="store_true",
25+
default=False,
26+
help="Allow CUDA enabled backends to run CUDA accelerated code on GPUs",
27+
)
2128

2229

2330
# Factory as fixture pattern
@@ -167,3 +174,14 @@ def datadir(tmp_path, request):
167174
shutil.copytree(test_dir, tmp_path, dirs_exist_ok=True)
168175

169176
return tmp_path
177+
178+
179+
@pytest.fixture(scope="session", autouse=True)
180+
def setup_cuda_environment(request):
181+
"""
182+
Automatically force CUDA enabled backends to run in CPU mode unless
183+
--enable-cuda is passed.
184+
"""
185+
if not request.config.getoption("--enable-cuda"):
186+
# Ensure testing on CPU and not GPU
187+
os.environ["CUDA_VISIBLE_DEVICES"] = ""

tests/test_calculator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def test_asymptotic_calculator_has_fitted_pars(test_stat):
8585
fitted_pars.free_fit_to_data
8686
)
8787
# lower tolerance for amd64 and arm64 to agree
88+
# FIXME: SciPy v1.16.0 gives a different result from SciPy v1.15.3
8889
assert pytest.approx(
8990
[7.6470499e-05, 1.4997178], rel=1e-3
9091
) == pyhf.tensorlib.tolist(fitted_pars.free_fit_to_asimov)

tests/test_scripts.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,12 @@ def test_missing_contrib_download(caplog):
666666

667667
from pyhf.contrib.cli import download
668668

669-
runner = CliRunner(mix_stderr=False)
669+
# mix_stderr removed in Click v8.2.0.
670+
# Can simplify once pyhf is Python 3.10+.
671+
try:
672+
runner = CliRunner(mix_stderr=False)
673+
except TypeError:
674+
runner = CliRunner()
670675
result = runner.invoke(
671676
download,
672677
[

0 commit comments

Comments
 (0)