Repository: lebrice/SimpleParsing

Issue:
SimpleParsing has unlisted numpy dependency since 0.0.14
**Describe the bug**
SimpleParsing 0.0.14 added (among others) the module `simple_parsing.helpers.hdparams.hdparam` which imports `numpy`. Unfortunately, `numpy` is not listed as a dependency in `setup.py`, which causes all code that uses SimpleParsing to fail with an `ModuleNotFoundError`.

**Suggested Fix**
Either remove the dependency to `numpy` or explicitly state it as a dependency. Currently, `matplotlib` is a test dependency, which will install `numpy` if one installs SimpleParsing with those test dependencies.

Personal opinion: Don't use `numpy` as a dependency at all. SimpleParsing is quite lightweight and `numpy` is a very heavy library. Or at least implement the helper module in a way that it can have `numpy` as an optional dependency, such that one is not forced to install it.




Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index a703838..c9810f1 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,7 +4,7 @@ python:
 - '3.7'
 - '3.8'
 install:
-- pip install --no-cache --ignore-installed -e .[test]
+- pip install --no-cache --ignore-installed -e .
 - pip install pyyaml
 script:
 - pytest
diff --git a/examples/dataclasses/dataclass_example.py b/examples/dataclasses/dataclass_example.py
index d2264d5..adec300 100644
--- a/examples/dataclasses/dataclass_example.py
+++ b/examples/dataclasses/dataclass_example.py
@@ -1,7 +1,6 @@
-import dataclasses
-from dataclasses import Field, dataclass, fields
+import math
+from dataclasses import dataclass, fields
 
-import numpy as np
 
 @dataclass
 class Point:
@@ -11,7 +10,7 @@ class Point:
     z: float = 0.
 
     def distance(self, other: "Point") -> float:
-        return np.sqrt(
+        return math.sqrt(
             (self.x - other.x) ** 2 +
             (self.y - other.y) ** 2 +
             (self.z - other.z) ** 2
diff --git a/setup.py b/setup.py
index 7be9edf..b6ca577 100644
--- a/setup.py
+++ b/setup.py
@@ -27,9 +27,4 @@ setuptools.setup(
         "typing_inspect",
         "dataclasses;python_version<'3.7'",
     ],
-    extras_require={
-        "test": [
-            "matplotlib"
-        ],
-    }
 )
diff --git a/simple_parsing/helpers/hparams/hparam.py b/simple_parsing/helpers/hparams/hparam.py
index af033a1..f83e153 100644
--- a/simple_parsing/helpers/hparams/hparam.py
+++ b/simple_parsing/helpers/hparams/hparam.py
@@ -1,46 +1,10 @@
-import copy
 import dataclasses
-import inspect
-import itertools
-import logging
-import math
-import pickle
-import random
-import sys
-from abc import ABC, abstractmethod
-from collections import OrderedDict, defaultdict
-from contextlib import contextmanager
-from dataclasses import Field, InitVar, dataclass, fields
-from functools import singledispatch, total_ordering, wraps
-from pathlib import Path
-from typing import (
-    Any,
-    Callable,
-    ClassVar,
-    Dict,
-    List,
-    NamedTuple,
-    Optional,
-    Tuple,
-    Type,
-    TypeVar,
-    Union,
-    cast,
-    overload,
-)
-
-import numpy as np
-from simple_parsing.helpers import Serializable, encode
+from functools import wraps
+from typing import Any, Dict, List, Optional, Type, TypeVar, Union, overload
+
 from simple_parsing.helpers.fields import choice as _choice
 from simple_parsing.helpers.fields import field
-from simple_parsing.helpers.serialization import register_decoding_fn
 from simple_parsing.logging_utils import get_logger
-from simple_parsing.utils import (
-    compute_identity,
-    dict_intersection,
-    field_dict,
-    zip_dicts,
-)
 
 from .priors import CategoricalPrior, LogUniformPrior, NormalPrior, Prior, UniformPrior
 
@@ -130,9 +94,10 @@ def uniform(
     """
     # TODO: what about uniform over a "choice"?
     if "default_value" in kwargs:
-        assert default in {None, dataclasses.MISSING}, (
-            "can't pass both `default` and `default_value`"
-        )
+        assert default in {
+            None,
+            dataclasses.MISSING,
+        }, "can't pass both `default` and `default_value`"
         default = kwargs.pop("default_value")
 
     if discrete is None:
@@ -179,9 +144,10 @@ def log_uniform(
     **kwargs,
 ) -> Union[int, float]:
     if "default_value" in kwargs:
-        assert default in {None, dataclasses.MISSING}, (
-            "can't pass both `default` and `default_value`"
-        )
+        assert default in {
+            None,
+            dataclasses.MISSING,
+        }, "can't pass both `default` and `default_value`"
         default = kwargs.pop("default_value")
 
     default_v = default
@@ -226,9 +192,10 @@ def categorical(
         T: the result of the usual `dataclasses.field()` function (a dataclass field).
     """
     if "default_value" in kwargs:
-        assert default in {None, dataclasses.MISSING}, (
-            "can't pass both `default` and `default_value`"
-        )
+        assert default in {
+            None,
+            dataclasses.MISSING,
+        }, "can't pass both `default` and `default_value`"
         default = kwargs.pop("default_value")
 
     metadata = kwargs.get("metadata", {})
@@ -247,9 +214,6 @@ def categorical(
         # IDEA: Adding some kind of 'hook' to be used by simple-parsing?
 
         def postprocess(value):
-            if isinstance(value, (list, np.ndarray)):
-                # TODO: Weird behaviour, this gets called with list?
-                assert False, (value, choice_dict)
             if value in choice_dict:
                 return choice_dict[value]
             return value
@@ -263,11 +227,11 @@ def categorical(
         options = list(choices)
 
     if isinstance(probabilities, dict):
-        if not np.isclose(sum(probabilities.values()), 1):
+        if abs(sum(probabilities.values()) - 1) > 1e-5:
             raise RuntimeError("Probabilities should sum to 1!")
         probs = []
         for option in options:
-            probability = probabilities.get(option, 0.)
+            probability = probabilities.get(option, 0.0)
             probs.append(probability)
         probabilities = probs
 
@@ -281,12 +245,14 @@ def categorical(
         default_value=default_v,
     )
     metadata["prior"] = prior
-    
+
     if strict:
+
         def postprocess(value):
             if value not in prior:
                 raise ValueOutsidePriorException(value=value, prior=prior)
             return value
+
         if "postprocessing" not in metadata:
             metadata["postprocessing"] = postprocess
         else:
diff --git a/simple_parsing/helpers/hparams/hyperparameters.py b/simple_parsing/helpers/hparams/hyperparameters.py
index c227329..485cee4 100644
--- a/simple_parsing/helpers/hparams/hyperparameters.py
+++ b/simple_parsing/helpers/hparams/hyperparameters.py
@@ -1,20 +1,14 @@
-import copy
 import dataclasses
 import inspect
-import itertools
-import logging
-import math
 import pickle
+import math
 import random
-from abc import ABC, abstractmethod
-from collections import OrderedDict, defaultdict
-from contextlib import contextmanager
-from dataclasses import Field, InitVar, dataclass, fields
+from collections import OrderedDict
+from dataclasses import Field, dataclass, fields
 from functools import singledispatch, total_ordering
 from pathlib import Path
 from typing import (
     Any,
-    Callable,
     ClassVar,
     Dict,
     List,
@@ -23,31 +17,31 @@ from typing import (
     Tuple,
     Type,
     TypeVar,
-    Union,
-    cast,
-    overload,
 )
 
-import numpy as np
-from simple_parsing.helpers.fields import field
-from simple_parsing.helpers import Serializable, encode
-from simple_parsing.helpers.serialization import register_decoding_fn
+from simple_parsing.helpers import Serializable
 from simple_parsing.logging_utils import get_logger
 from simple_parsing.utils import (
     compute_identity,
-    dict_intersection,
     dict_union,
     field_dict,
-    zip_dicts,
 )
 
-from .hparam import hparam, log_uniform, uniform, ValueOutsidePriorException
-from .priors import LogUniformPrior, NormalPrior, Prior, UniformPrior
+from .hparam import ValueOutsidePriorException, hparam, uniform, log_uniform
+from .priors import Prior
+
 
 logger = get_logger(__file__)
 T = TypeVar("T")
 HP = TypeVar("HP", bound="HyperParameters")
 
+numpy_installed = False
+try:
+    import numpy as np
+    numpy_installed = True
+except ImportError:
+    pass
+
 
 @dataclass
 class BoundInfo(Serializable):
@@ -56,7 +50,7 @@ class BoundInfo(Serializable):
     name: str
     # One of 'continuous', 'discrete' or 'bandit' (unsuported).
     type: str = "continuous"
-    domain: Tuple[float, float] = (np.NINF, np.Infinity)
+    domain: Tuple[float, float] = (-math.inf, math.inf)
 
 
 @dataclass
@@ -65,7 +59,10 @@ class HyperParameters(Serializable, decode_into_subclasses=True):  # type: ignor
 
     # Class variable holding the random number generator used to create the
     # samples.
-    rng: ClassVar[np.random.RandomState] = np.random
+    if numpy_installed:
+        np_rng: ClassVar[np.random.RandomState] = np.random
+    else:
+        rng: ClassVar[random.Random] = random.Random()
 
     def __post_init__(self):
         for name, f in field_dict(self).items():
@@ -74,7 +71,7 @@ class HyperParameters(Serializable, decode_into_subclasses=True):  # type: ignor
             value = getattr(self, name)
             # Apply any post-processing function, if applicable.
             if "postprocessing" in f.metadata:
-                print(f"Post-processing of field {name}")
+                logger.debug(f"Post-processing of field {name}")
                 try:
                     new_value = f.metadata["postprocessing"](value)
                 except ValueOutsidePriorException as e:
@@ -190,9 +187,12 @@ class HyperParameters(Serializable, decode_into_subclasses=True):  # type: ignor
             else:
                 prior: Optional[Prior] = field.metadata.get("prior")
                 if prior is not None:
-                    prior.rng = cls.rng
+                    if numpy_installed:
+                        prior.np_rng = cls.np_rng
+                    else:
+                        prior.rng = cls.rng
                     value = prior.sample()
-                    if isinstance(value, np.ndarray):
+                    if numpy_installed and isinstance(value, np.ndarray):
                         value = value.item()
                         assert False, value
                     kwargs[field.name] = value
@@ -211,37 +211,38 @@ class HyperParameters(Serializable, decode_into_subclasses=True):  # type: ignor
     #     yield
     #     cls.sample_from_priors = temp
 
-    def to_array(self, dtype=np.float32) -> np.ndarray:
-        values: List[float] = []
-        for k, v in self.to_dict(dict_factory=OrderedDict).items():
-            try:
-                v = float(v)
-            except Exception as e:
-                logger.warning(
-                    f"Ignoring field {k} because we can't make a float out of it."
-                )
-            else:
-                values.append(v)
-        return np.array(values, dtype=dtype)
-
-    @classmethod
-    def from_array(cls: Type[HP], array: np.ndarray) -> HP:
-        if len(array.shape) == 2 and array.shape[0] == 1:
-            array = array[0]
-
-        keys = list(field_dict(cls))
-        # idea: could use to_dict and to_array together to determine how many
-        # values to get for each field. For now we assume that each field is one
-        # variable.
-        # cls.sample().to_dict()
-        # assert len(keys) == len(array), "assuming that each field is dim 1 for now."
-        assert len(keys) == len(array), "assuming that each field is dim 1 for now."
-        d = OrderedDict(zip(keys, array))
-        logger.debug(f"Creating an instance of {cls} using args {d}")
-        d = OrderedDict(
-            (k, v.item() if isinstance(v, np.ndarray) else v) for k, v in d.items()
-        )
-        return cls.from_dict(d)
+    if numpy_installed:
+        def to_array(self, dtype=np.float32) -> np.ndarray:
+            values: List[float] = []
+            for k, v in self.to_dict(dict_factory=OrderedDict).items():
+                try:
+                    v = float(v)
+                except Exception:
+                    logger.warning(
+                        f"Ignoring field {k} because we can't make a float out of it."
+                    )
+                else:
+                    values.append(v)
+            return np.array(values, dtype=dtype)
+
+        @classmethod
+        def from_array(cls: Type[HP], array: np.ndarray) -> HP:
+            if len(array.shape) == 2 and array.shape[0] == 1:
+                array = array[0]
+
+            keys = list(field_dict(cls))
+            # idea: could use to_dict and to_array together to determine how many
+            # values to get for each field. For now we assume that each field is one
+            # variable.
+            # cls.sample().to_dict()
+            # assert len(keys) == len(array), "assuming that each field is dim 1 for now."
+            assert len(keys) == len(array), "assuming that each field is dim 1 for now."
+            d = OrderedDict(zip(keys, array))
+            logger.debug(f"Creating an instance of {cls} using args {d}")
+            d = OrderedDict(
+                (k, v.item() if isinstance(v, np.ndarray) else v) for k, v in d.items()
+            )
+            return cls.from_dict(d)
 
     def clip_within_bounds(self: HP) -> HP:
         d = self.to_dict()
diff --git a/simple_parsing/helpers/hparams/priors.py b/simple_parsing/helpers/hparams/priors.py
index a639d77..2981eb6 100644
--- a/simple_parsing/helpers/hparams/priors.py
+++ b/simple_parsing/helpers/hparams/priors.py
@@ -2,16 +2,26 @@ import math
 from abc import abstractmethod
 from dataclasses import dataclass
 from typing import Generic, List, Optional, TypeVar, Union, overload, Any
-import numpy as np
+import random
+
+numpy_installed = False
+try:
+    import numpy as np
+    numpy_installed = True
+except ImportError:
+    pass
+
 
 T = TypeVar("T")
 
 
 @dataclass  # type: ignore
 class Prior(Generic[T]):
-    
     def __post_init__(self):
-        self.rng = np.random
+        if numpy_installed:
+            self.np_rng = np.random
+        else:
+            self.rng: random.Random = random.Random()
 
     @abstractmethod
     def sample(self) -> T:
@@ -19,7 +29,10 @@ class Prior(Generic[T]):
 
     def seed(self, seed: Optional[int]) -> None:
         # Should this seed this individual prior?
-        self.rng = np.random.RandomState(seed)
+        if numpy_installed:
+            self.np_rng = np.random.RandomState(seed)
+        else:
+            self.rng = random.Random(seed)
 
     @abstractmethod
     def get_orion_space_string(self) -> str:
@@ -38,7 +51,10 @@ class NormalPrior(Prior):
     default: Optional[float] = None
 
     def sample(self) -> Union[float, int]:
-        value = self.rng.normal(self.mu, self.sigma)
+        if numpy_installed:
+            value = self.np_rng.normal(self.mu, self.sigma)
+        else:
+            value = self.rng.normalvariate(self.mu, self.sigma)
         if self.discrete:
             return round(value)
         return value
@@ -66,7 +82,10 @@ class UniformPrior(Prior):
 
     def sample(self) -> Union[float, int]:
         # TODO: add suport for enums?
-        value = self.rng.uniform(self.min, self.max)
+        if numpy_installed:
+            value = self.np_rng.uniform(self.min, self.max)
+        else:
+            value = self.rng.uniform(self.min, self.max)
         if self.discrete:
             return round(value)
         return value
@@ -121,9 +140,12 @@ class CategoricalPrior(Prior[T]):
             probabilities = self.probabilities
 
         print(choices, n, probabilities)
+        if numpy_installed:
+            s = self.np_rng.choice(choices, size=n, p=probabilities)
+            samples = [(s_i.item() if isinstance(s_i, np.ndarray) else s_i) for s_i in s]
+        else:
+            samples = self.rng.choices(choices, weights=probabilities, k=n or 1)
 
-        s = self.rng.choice(choices, size=n, p=probabilities)
-        samples = [(s_i.item() if isinstance(s_i, np.ndarray) else s_i) for s_i in s]
         return samples[0] if n in {None, 1} else samples
 
     def get_orion_space_string(self) -> str:
@@ -155,7 +177,7 @@ class CategoricalPrior(Prior[T]):
 class LogUniformPrior(Prior):
     min: float = 1e-3
     max: float = 1e+3
-    base: float = np.e
+    base: float = math.e
     discrete: bool = False
     default: Optional[float] = None
 
@@ -163,8 +185,10 @@ class LogUniformPrior(Prior):
         # TODO: Might not be 100% numerically stable.
         assert self.min > 0, "min of LogUniform can't be negative!"
         assert self.min < self.max, "max should be greater than min!"
-
-        log_val = self.rng.uniform(self.log_min, self.log_max)
+        if numpy_installed:
+            log_val = self.np_rng.uniform(self.log_min, self.log_max)
+        else:
+            log_val = self.rng.uniform(self.log_min, self.log_max)
         value = math.pow(self.base, log_val)
         if self.discrete:
             return round(value)
@@ -172,19 +196,31 @@ class LogUniformPrior(Prior):
 
     @property
     def log_min(self) -> Union[int, float]:
-        if self.base is np.e:
-            log_min = np.log(self.min)
+        if numpy_installed:
+            if self.base in {np.e, math.e}:
+                log_min = np.log(self.min)
+            else:
+                log_min = np.log(self.min)
         else:
-            log_min = math.log(self.min, self.base)
+            if self.base is math.e:
+                log_min = math.log(self.min)
+            else:
+                log_min = math.log(self.min, self.base)
         assert isinstance(log_min, (int, float))
         return log_min
 
     @property
     def log_max(self) -> Union[int, float]:
-        if self.base is np.e:
-            log_max = np.log(self.max)
+        if numpy_installed:
+            if self.base in {math.e, np.e}:
+                log_max = np.log(self.max)
+            else:
+                log_max = np.log(self.max) / np.log(self.base)
         else:
-            log_max = math.log(self.max, self.base)
+            if self.base is math.e:
+                log_max = math.log(self.max)
+            else:
+                log_max = math.log(self.max, self.base)
         assert isinstance(log_max, (int, float))
         return log_max
 
diff --git a/simple_parsing/helpers/hparams/utils.py b/simple_parsing/helpers/hparams/utils.py
index a2d2ea0..299f3d1 100644
--- a/simple_parsing/helpers/hparams/utils.py
+++ b/simple_parsing/helpers/hparams/utils.py
@@ -1,9 +1,14 @@
-import numpy as np
 import random
 
+
 def set_seed(seed: int) -> None:
-    np.random.seed(seed)
     random.seed(seed)
+    try:
+        import numpy as np
+        np.random.seed(seed)
+    except ImportError:
+        pass
+
     try:
         import torch
     except ImportError:
diff --git a/simple_parsing/helpers/serialization/encoding.py b/simple_parsing/helpers/serialization/encoding.py
index ec575a8..8477982 100644
--- a/simple_parsing/helpers/serialization/encoding.py
+++ b/simple_parsing/helpers/serialization/encoding.py
@@ -76,6 +76,7 @@ def encode(obj: Any) -> Union[Dict, List, int, str, bool, None]:
         logger.debug(f"Cannot encode object {obj}: {e}")
         raise e
 
+
 @encode.register(list)
 @encode.register(tuple)
 # @encode.register(Sequence) # Would also encompass `str!`


Test patch:
diff --git a/simple_parsing/helpers/hparams/hyperparameters_test.py b/simple_parsing/helpers/hparams/hyperparameters_test.py
index a19cadd..6652c0f 100644
--- a/simple_parsing/helpers/hparams/hyperparameters_test.py
+++ b/simple_parsing/helpers/hparams/hyperparameters_test.py
@@ -1,12 +1,18 @@
 from dataclasses import dataclass
 from typing import Union
-
-import numpy as np
 import pytest
 
 from .hyperparameters import HyperParameters, hparam, log_uniform, uniform
 
 
+numpy_installed = False
+try:
+    import numpy as np
+    numpy_installed = True
+except ImportError:
+    pass
+
+
 @dataclass
 class A(HyperParameters):
     learning_rate: float = uniform(0.0, 1.0)
@@ -17,6 +23,7 @@ class B(A):
     momentum: float = uniform(0.0, 1.0)
 
 
+@pytest.mark.skipif(not numpy_installed, reason="Test requires numpy.")
 def test_to_array():
     b: B = B.sample()
     array = b.to_array()
@@ -24,6 +31,7 @@ def test_to_array():
     assert np.isclose(array[1], b.momentum)
 
 
+@pytest.mark.skipif(not numpy_installed, reason="Test requires numpy.")
 def test_from_array():
     array = np.arange(2)
     b: B = B.from_array(array)
diff --git a/simple_parsing/helpers/hparams/priors_test.py b/simple_parsing/helpers/hparams/priors_test.py
index 07101f1..efa5eca 100644
--- a/simple_parsing/helpers/hparams/priors_test.py
+++ b/simple_parsing/helpers/hparams/priors_test.py
@@ -1,19 +1,35 @@
-
+import pytest
 from collections import Counter
 from dataclasses import dataclass
-
-import numpy as np
+import math
 
 from .hyperparameters import HyperParameters, hparam
 from .priors import CategoricalPrior, LogUniformPrior, UniformPrior
 from .utils import set_seed
 
+numpy_installed = False
+try:
+    import numpy as np
+    numpy_installed = True
+except ImportError:
+    pass
+
+
+matplotlib_installed = False
+try:
+    import matplotlib.pyplot as plt
+    matplotlib_installed = True
+except ImportError:
+    pass
+
 
 @dataclass
 class A(HyperParameters):
     learning_rate: float = hparam(default=0.001, prior=LogUniformPrior(min=1e-6, max=1))
 
 
+@pytest.mark.skipif(not matplotlib_installed, reason="Test requires matplotlib.")
+@pytest.mark.skipif(not numpy_installed, reason="Test requires numpy.")
 def test_log_uniform():
     n_bins = 5
     n_points = 200
@@ -21,7 +37,6 @@ def test_log_uniform():
     x = [
         A.sample().to_array() for i in range(n_points)
     ]
-    import matplotlib.pyplot as plt
     hist, bins, _ = plt.hist(x, bins=n_bins)
     # histogram on log scale.
     # Use non-equal bin sizes, such that they look equal on log scale.
@@ -46,6 +61,7 @@ class B(A):
     momentum: float = hparam(default=0., prior=UniformPrior(min=-2., max=2.))
 
 
+@pytest.mark.skipif(not numpy_installed, reason="Test requires numpy.")
 def test_to_array():
     b = B.sample()
     array = b.to_array()
@@ -53,6 +69,7 @@ def test_to_array():
     assert np.isclose(array[1], b.momentum)
 
 
+@pytest.mark.skipif(not numpy_installed, reason="Test requires numpy.")
 def test_log_uniform_and_uniform():
     n_points = 100
     set_seed(123)
@@ -67,8 +84,8 @@ def test_log_uniform_and_uniform():
         x.to_array() for x in x_samples
     ])
 
-    x0 = x[:, 0] # learning rate
-    x1 = x[:, 1] # momentum
+    x0 = x[:, 0]  # learning rate
+    x1 = x[:, 1]  # momentum
     print(x0.mean(), x0.std())
     print(x1.mean(), x1.std())
 
@@ -81,8 +98,8 @@ def test_loguniform_prior():
     prior = LogUniformPrior(min=1, max=1e5, base=10)
     samples = [prior.sample() for _ in range(1000)]
     assert all(1 < x < 1e5 for x in samples)
-    log_samples = np.log10(samples)
-    mean = np.mean(log_samples)
+    log_samples = [math.log10(s) for s in samples]
+    mean = sum(log_samples) / len(log_samples)
     # mean base-10 exponent should be around 2.5
     assert 2.4 <= mean <= 2.6