Skip to content

Commit 8db8672

Browse files
andyfaffmdhaber
andauthored
API: optimize.differential_evolution: transition to Generator (SPEC 7) (scipy#21774)
* MAINT: SPEC007, transition differential_evolution to Generator --------- Co-authored-by: Matt Haberland <[email protected]>
1 parent 94197e0 commit 8db8672

File tree

6 files changed

+283
-57
lines changed

6 files changed

+283
-57
lines changed

scipy/_lib/_util.py

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,169 @@ def float_factorial(n: int) -> float:
245245
return float(math.factorial(n)) if n < 171 else np.inf
246246

247247

248+
# SPEC 7
249+
def _transition_to_rng(old_name, *, position_num=None, end_version=None):
250+
"""Example decorator to transition from old PRNG usage to new `rng` behavior
251+
252+
Suppose the decorator is applied to a function that used to accept parameter
253+
`old_name='random_state'` either by keyword or as a positional argument at
254+
`position_num=1`. At the time of application, the name of the argument in the
255+
function signature is manually changed to the new name, `rng`. If positional
256+
use was allowed before, this is not changed.*
257+
258+
- If the function is called with both `random_state` and `rng`, the decorator
259+
raises an error.
260+
- If `random_state` is provided as a keyword argument, the decorator passes
261+
`random_state` to the function's `rng` argument as a keyword. If `end_version`
262+
is specified, the decorator will emit a `DeprecationWarning` about the
263+
deprecation of keyword `random_state`.
264+
- If `random_state` is provided as a positional argument, the decorator passes
265+
`random_state` to the function's `rng` argument by position. If `end_version`
266+
is specified, the decorator will emit a `FutureWarning` about the changing
267+
interpretation of the argument.
268+
- If `rng` is provided as a keyword argument, the decorator validates `rng` using
269+
`numpy.random.default_rng` before passing it to the function.
270+
- If `end_version` is specified and neither `random_state` nor `rng` is provided
271+
by the user, the decorator checks whether `np.random.seed` has been used to set
272+
the global seed. If so, it emits a `FutureWarning`, noting that usage of
273+
`numpy.random.seed` will eventually have no effect. Either way, the decorator
274+
calls the function without explicitly passing the `rng` argument.
275+
276+
If `end_version` is specified, a user must pass `rng` as a keyword to avoid
277+
warnings.
278+
279+
After the deprecation period, the decorator can be removed, and the function
280+
can simply validate the `rng` argument by calling `np.random.default_rng(rng)`.
281+
282+
* A `FutureWarning` is emitted when the PRNG argument is used by
283+
position. It indicates that the "Hinsen principle" (same
284+
code yielding different results in two versions of the software)
285+
will be violated, unless positional use is deprecated. Specifically:
286+
287+
- If `None` is passed by position and `np.random.seed` has been used,
288+
the function will change from being seeded to being unseeded.
289+
- If an integer is passed by position, the random stream will change.
290+
- If `np.random` or an instance of `RandomState` is passed by position,
291+
an error will be raised.
292+
293+
We suggest that projects consider deprecating positional use of
294+
`random_state`/`rng` (i.e., change their function signatures to
295+
``def my_func(..., *, rng=None)``); that might not make sense
296+
for all projects, so this SPEC does not make that
297+
recommendation, neither does this decorator enforce it.
298+
299+
Parameters
300+
----------
301+
old_name : str
302+
The old name of the PRNG argument (e.g. `seed` or `random_state`).
303+
position_num : int, optional
304+
The (0-indexed) position of the old PRNG argument (if accepted by position).
305+
Maintainers are welcome to eliminate this argument and use, for example,
306+
`inspect`, if preferred.
307+
end_version : str, optional
308+
The full version number of the library when the behavior described in
309+
`DeprecationWarning`s and `FutureWarning`s will take effect. If left
310+
unspecified, no warnings will be emitted by the decorator.
311+
312+
"""
313+
NEW_NAME = "rng"
314+
315+
cmn_msg = (
316+
"To silence this warning and ensure consistent behavior in SciPy "
317+
f"{end_version}, control the RNG using argument `{NEW_NAME}`. Arguments passed "
318+
f"to keyword `{NEW_NAME}` will be validated by `np.random.default_rng`, so the "
319+
"behavior corresponding with a given value may change compared to use of "
320+
f"`{old_name}`. For example, "
321+
"1) `None` will result in unpredictable random numbers, "
322+
"2) an integer will result in a different stream of random numbers, (with the "
323+
"same distribution), and "
324+
"3) `np.random` or `RandomState` instances will result in an error. "
325+
"See the documentation of `default_rng` for more information."
326+
)
327+
328+
def decorator(fun):
329+
@functools.wraps(fun)
330+
def wrapper(*args, **kwargs):
331+
# Determine how PRNG was passed
332+
as_old_kwarg = old_name in kwargs
333+
as_new_kwarg = NEW_NAME in kwargs
334+
as_pos_arg = position_num is not None and len(args) >= position_num + 1
335+
emit_warning = end_version is not None
336+
337+
# Can only specify PRNG one of the three ways
338+
if int(as_old_kwarg) + int(as_new_kwarg) + int(as_pos_arg) > 1:
339+
message = (
340+
f"{fun.__name__}() got multiple values for "
341+
f"argument now known as `{NEW_NAME}`. Specify one of "
342+
f"`{NEW_NAME}` or `{old_name}`."
343+
)
344+
raise TypeError(message)
345+
346+
# Check whether global random state has been set
347+
global_seed_set = np.random.mtrand._rand._bit_generator._seed_seq is None
348+
349+
if as_old_kwarg: # warn about deprecated use of old kwarg
350+
kwargs[NEW_NAME] = kwargs.pop(old_name)
351+
if emit_warning:
352+
message = (
353+
f"Use of keyword argument `{old_name}` is "
354+
f"deprecated and replaced by `{NEW_NAME}`. "
355+
f"Support for `{old_name}` will be removed "
356+
f"in SciPy {end_version}. "
357+
) + cmn_msg
358+
warnings.warn(message, DeprecationWarning, stacklevel=2)
359+
360+
elif as_pos_arg:
361+
# Warn about changing meaning of positional arg
362+
363+
# Note that this decorator does not deprecate positional use of the
364+
# argument; it only warns that the behavior will change in the future.
365+
# Simultaneously transitioning to keyword-only use is another option.
366+
367+
arg = args[position_num]
368+
# If the argument is None and the global seed wasn't set, or if the
369+
# argument is one of a few new classes, the user will not notice change
370+
# in behavior.
371+
ok_classes = (
372+
np.random.Generator,
373+
np.random.SeedSequence,
374+
np.random.BitGenerator,
375+
)
376+
if (arg is None and not global_seed_set) or isinstance(arg, ok_classes):
377+
pass
378+
elif emit_warning:
379+
message = (
380+
f"Positional use of `{NEW_NAME}` (formerly known as "
381+
f"`{old_name}`) is still allowed, but the behavior is "
382+
"changing: the argument will be normalized using "
383+
f"`np.random.default_rng` beginning in SciPy {end_version}, "
384+
"and the resulting `Generator` will be used to generate "
385+
"random numbers."
386+
) + cmn_msg
387+
warnings.warn(message, FutureWarning, stacklevel=2)
388+
389+
elif as_new_kwarg: # no warnings; this is the preferred use
390+
# After the removal of the decorator, normalization with
391+
# np.random.default_rng will be done inside the decorated function
392+
kwargs[NEW_NAME] = np.random.default_rng(kwargs[NEW_NAME])
393+
394+
elif global_seed_set and emit_warning:
395+
# Emit FutureWarning if `np.random.seed` was used and no PRNG was passed
396+
message = (
397+
"The NumPy global RNG was seeded by calling "
398+
f"`np.random.seed`. Beginning in {end_version}, this "
399+
"function will no longer use the global RNG."
400+
) + cmn_msg
401+
warnings.warn(message, FutureWarning, stacklevel=2)
402+
403+
return fun(*args, **kwargs)
404+
405+
return wrapper
406+
407+
return decorator
408+
409+
248410
# copy-pasted from scikit-learn utils/validation.py
249-
# change this to scipy.stats._qmc.check_random_state once numpy 1.16 is dropped
250411
def check_random_state(seed):
251412
"""Turn `seed` into a `np.random.RandomState` instance.
252413

scipy/optimize/_differentialevolution.py

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from scipy.optimize import OptimizeResult, minimize
99
from scipy.optimize._optimize import _status_message, _wrap_callback
1010
from scipy._lib._util import (check_random_state, MapWrapper, _FunctionWrapper,
11-
rng_integers)
11+
rng_integers, _transition_to_rng)
1212

1313
from scipy.optimize._constraints import (Bounds, new_bounds_to_old,
1414
NonlinearConstraint, LinearConstraint)
@@ -20,9 +20,10 @@
2020
_MACHEPS = np.finfo(np.float64).eps
2121

2222

23+
@_transition_to_rng("seed", position_num=9)
2324
def differential_evolution(func, bounds, args=(), strategy='best1bin',
2425
maxiter=1000, popsize=15, tol=0.01,
25-
mutation=(0.5, 1), recombination=0.7, seed=None,
26+
mutation=(0.5, 1), recombination=0.7, rng=None,
2627
callback=None, disp=False, polish=True,
2728
init='latinhypercube', atol=0, updating='immediate',
2829
workers=1, constraints=(), x0=None, *,
@@ -124,14 +125,30 @@ def differential_evolution(func, bounds, args=(), strategy='best1bin',
124125
denoted by CR. Increasing this value allows a larger number of mutants
125126
to progress into the next generation, but at the risk of population
126127
stability.
127-
seed : {None, int, `numpy.random.Generator`, `numpy.random.RandomState`}, optional
128-
If `seed` is None (or `np.random`), the `numpy.random.RandomState`
129-
singleton is used.
130-
If `seed` is an int, a new ``RandomState`` instance is used,
131-
seeded with `seed`.
132-
If `seed` is already a ``Generator`` or ``RandomState`` instance then
133-
that instance is used.
134-
Specify `seed` for repeatable minimizations.
128+
rng : {None, int, `numpy.random.Generator`}, optional
129+
If `rng` is passed by keyword, types other than `numpy.random.Generator` are
130+
passed to `numpy.random.default_rng` to instantiate a ``Generator``.
131+
If `rng` is already a ``Generator`` instance, then the provided instance is
132+
used. Specify `rng` for repeatable minimizations.
133+
134+
If this argument is passed by position or `seed` is passed by keyword,
135+
legacy behavior for the argument `seed` applies:
136+
137+
- If `seed` is None (or `numpy.random`), the `numpy.random.RandomState`
138+
singleton is used.
139+
- If `seed` is an int, a new ``RandomState`` instance is used,
140+
seeded with `seed`.
141+
- If `seed` is already a ``Generator`` or ``RandomState`` instance then
142+
that instance is used.
143+
144+
.. versionchanged:: 1.15.0
145+
As part of the `SPEC-007 <https://scientific-python.org/specs/spec-0007/>`_
146+
transition from use of `numpy.random.RandomState` to
147+
`numpy.random.Generator` this keyword was changed from `seed` to `rng`.
148+
For an interim period, both keywords will continue to work (only specify
149+
one of them). After the interim period using the `seed` keyword will emit
150+
warnings. The behavior of the `seed` and `rng` keywords is outlined above.
151+
135152
disp : bool, optional
136153
Prints the evaluated `func` at every iteration.
137154
callback : callable, optional
@@ -424,7 +441,7 @@ def differential_evolution(func, bounds, args=(), strategy='best1bin',
424441
425442
>>> bounds = Bounds([0., 0.], [2., 2.])
426443
>>> result = differential_evolution(rosen, bounds, constraints=lc,
427-
... seed=1)
444+
... rng=1)
428445
>>> result.x, result.fun
429446
(array([0.96632622, 0.93367155]), 0.0011352416852625719)
430447
@@ -436,7 +453,7 @@ def differential_evolution(func, bounds, args=(), strategy='best1bin',
436453
... arg2 = 0.5 * (np.cos(2. * np.pi * x[0]) + np.cos(2. * np.pi * x[1]))
437454
... return -20. * np.exp(arg1) - np.exp(arg2) + 20. + np.e
438455
>>> bounds = [(-5, 5), (-5, 5)]
439-
>>> result = differential_evolution(ackley, bounds, seed=1)
456+
>>> result = differential_evolution(ackley, bounds, rng=1)
440457
>>> result.x, result.fun
441458
(array([0., 0.]), 4.440892098500626e-16)
442459
@@ -445,7 +462,7 @@ def differential_evolution(func, bounds, args=(), strategy='best1bin',
445462
function evaluations.
446463
447464
>>> result = differential_evolution(
448-
... ackley, bounds, vectorized=True, updating='deferred', seed=1
465+
... ackley, bounds, vectorized=True, updating='deferred', rng=1
449466
... )
450467
>>> result.x, result.fun
451468
(array([0., 0.]), 4.440892098500626e-16)
@@ -491,7 +508,7 @@ def differential_evolution(func, bounds, args=(), strategy='best1bin',
491508
popsize=popsize, tol=tol,
492509
mutation=mutation,
493510
recombination=recombination,
494-
seed=seed, polish=polish,
511+
rng=rng, polish=polish,
495512
callback=callback,
496513
disp=disp, init=init, atol=atol,
497514
updating=updating,
@@ -594,14 +611,33 @@ class DifferentialEvolutionSolver:
594611
denoted by CR. Increasing this value allows a larger number of mutants
595612
to progress into the next generation, but at the risk of population
596613
stability.
597-
seed : {None, int, `numpy.random.Generator`, `numpy.random.RandomState`}, optional
598-
If `seed` is None (or `np.random`), the `numpy.random.RandomState`
599-
singleton is used.
600-
If `seed` is an int, a new ``RandomState`` instance is used,
601-
seeded with `seed`.
602-
If `seed` is already a ``Generator`` or ``RandomState`` instance then
603-
that instance is used.
604-
Specify `seed` for repeatable minimizations.
614+
615+
rng : {None, int, `numpy.random.Generator`}, optional
616+
617+
..versionchanged:: 1.15.0
618+
As part of the `SPEC-007 <https://scientific-python.org/specs/spec-0007/>`_
619+
transition from use of `numpy.random.RandomState` to
620+
`numpy.random.Generator` this keyword was changed from `seed` to `rng`.
621+
For an interim period both keywords will continue to work (only specify
622+
one of them). After the interim period using the `seed` keyword will emit
623+
warnings. The behavior of the `seed` and `rng` keywords is outlined below.
624+
625+
If `rng` is passed by keyword, types other than `numpy.random.Generator` are
626+
passed to `numpy.random.default_rng` to instantiate a `Generator`.
627+
If `rng` is already a `Generator` instance, then the provided instance is
628+
used.
629+
630+
If this argument is passed by position or `seed` is passed by keyword, the
631+
behavior is:
632+
633+
- If `seed` is None (or `np.random`), the `numpy.random.RandomState`
634+
singleton is used.
635+
- If `seed` is an int, a new `RandomState` instance is used,
636+
seeded with `seed`.
637+
- If `seed` is already a `Generator` or `RandomState` instance then
638+
that instance is used.
639+
640+
Specify `seed`/`rng` for repeatable minimizations.
605641
disp : bool, optional
606642
Prints the evaluated `func` at every iteration.
607643
callback : callable, optional
@@ -745,7 +781,7 @@ class DifferentialEvolutionSolver:
745781

746782
def __init__(self, func, bounds, args=(),
747783
strategy='best1bin', maxiter=1000, popsize=15,
748-
tol=0.01, mutation=(0.5, 1), recombination=0.7, seed=None,
784+
tol=0.01, mutation=(0.5, 1), recombination=0.7, rng=None,
749785
maxfun=np.inf, callback=None, disp=False, polish=True,
750786
init='latinhypercube', atol=0, updating='immediate',
751787
workers=1, constraints=(), x0=None, *, integrality=None,
@@ -864,7 +900,7 @@ def maplike_for_vectorized_func(func, x):
864900

865901
self.parameter_count = np.size(self.limits, 1)
866902

867-
self.random_number_generator = check_random_state(seed)
903+
self.random_number_generator = check_random_state(rng)
868904

869905
# Which parameters are going to be integers?
870906
if np.any(integrality):

0 commit comments

Comments
 (0)