Repository: projectmesa/mesa

Issue:
BatchRunner's "variable parameters" is not strictly optional
I was trying to use `BatchRunner` to run multiple iterations of my model that had only fixed parameter sets. Initially, when reading the [docs](http://mesa.readthedocs.io/en/master/apis/batchrunner.html), I saw that the `variable_parameters` is specified as an optional keyword argument. However, if I do not pass in any variable parameters, then BatchRunner fails with the error:

```
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-54-793336723b8d> in <module>()
      8                         iterations=10,
      9                         max_steps=12 * 20,
---> 10                         model_reporters={'Projects': stage_counts})
     11 batch_run.run_all()

~/anaconda/envs/prog_forecast/lib/python3.6/site-packages/mesa/batchrunner.py in __init__(self, model_cls, variable_parameters, fixed_parameters, iterations, max_steps, model_reporters, agent_reporters, display_progress)
     81         """
     82         self.model_cls = model_cls
---> 83         self.variable_parameters = self._process_parameters(variable_parameters)
     84         self.fixed_parameters = fixed_parameters or {}
     85         self._include_fixed = len(self.fixed_parameters.keys()) > 0

~/anaconda/envs/prog_forecast/lib/python3.6/site-packages/mesa/batchrunner.py in _process_parameters(self, params)
    101         params = copy.deepcopy(params)
    102         bad_names = []
--> 103         for name, values in params.items():
    104             if (isinstance(values, str) or
    105                     not hasattr(values, "__iter__")):

AttributeError: 'NoneType' object has no attribute 'items'
```

I felt confused when I first encountered this error, as I was expecting `BatchRunner` to gracefully handle the `variable_parameters=None` scenario. It thus appeared as a bug to me. 

If this is truly a bug, I think there's two different ways fixing this could go. If the intent is to make `variable_parameters` an optional keyword argument, then adding code to deal with its optionality would be good, e.g. some conditionals that deal with it. Otherwise, if the intent is to make `variable_parameters` a non-optional argument, it should not have a default value of `None`. 

May I ask which of these design choices is the intended one? If it's the latter, I'm happy to put in a PR to make it non-optional. If it's the former, then I'd like to ask for guidance on putting in a PR, as there's probably a few places I'll need to add in conditionals that I wouldn't be fully aware of.


Discussion:

Resolving patch:
diff --git a/mesa/batchrunner.py b/mesa/batchrunner.py
--- a/mesa/batchrunner.py
+++ b/mesa/batchrunner.py
@@ -77,8 +77,9 @@ def __init__(self, model_cls, variable_parameters=None,
 
         """
         self.model_cls = model_cls
-        self.variable_parameters = self._process_parameters(
-            variable_parameters)
+        if variable_parameters is None:
+            variable_parameters = {}
+        self.variable_parameters = self._process_parameters(variable_parameters)
         self.fixed_parameters = fixed_parameters or {}
         self._include_fixed = len(self.fixed_parameters.keys()) > 0
         self.iterations = iterations
@@ -105,42 +106,58 @@ def _process_parameters(self, params):
             raise VariableParameterError(bad_names)
         return params
 
-    def run_all(self):
-        """ Run the model at all parameter combinations and store results. """
-        param_names, param_ranges = zip(*self.variable_parameters.items())
+    def _make_model_args(self):
+        """Prepare all combinations of parameter values for `run_all`
+
+        Returns:
+            Tuple with the form:
+            (total_iterations, all_kwargs, all_param_values)
+        """
         total_iterations = self.iterations
-        for param_range in param_ranges:
-            total_iterations *= len(param_range)
-        with tqdm(total_iterations, disable=not self.display_progress) as pbar:
+        all_kwargs = []
+        all_param_values = []
+
+        if len(self.variable_parameters) > 0:
+            param_names, param_ranges = zip(*self.variable_parameters.items())
+            for param_range in param_ranges:
+                total_iterations *= len(param_range)
+
             for param_values in product(*param_ranges):
                 kwargs = dict(zip(param_names, param_values))
                 kwargs.update(self.fixed_parameters)
+                all_kwargs.append(kwargs)
+                all_param_values.append(param_values)
+        else:
+            kwargs = self.fixed_parameters
+            param_values = None
+            all_kwargs = [kwargs]
+            all_param_values = [None]
 
-                for i in range(self.iterations):
-                    kwargscopy = copy.deepcopy(kwargs)
-                    model_vars, agent_vars = self._run_single_model(
-                        param_values, i, kwargscopy)
-
-                    if self.model_reporters:
-                        for model_key, model_val in model_vars.items():
-                            self.model_vars[model_key] = model_val
-                    if self.agent_reporters:
-                        for agent_key, reports in agent_vars.items():
-                            self.agent_vars[agent_key] = reports
-                    pbar.update()
+        return (total_iterations, all_kwargs, all_param_values)
 
-    def _run_single_model(self, param_values, run_count, kwargs):
-        """
-        Run a single model for one parameter combination and one iteration,
-        returning a tuple of the model vars and agent vars.
-        """
-        model = self.model_cls(**kwargs)
+    def run_all(self):
+        """ Run the model at all parameter combinations and store results. """
+        run_count = count()
+        total_iterations, all_kwargs, all_param_values = self._make_model_args()
+
+        with tqdm(total_iterations, disable=not self.display_progress) as pbar:
+            for i, kwargs in enumerate(all_kwargs):
+                param_values = all_param_values[i]
+                for _ in range(self.iterations):
+                    self.run_iteration(kwargs, param_values, next(run_count))
+                    pbar.update()
 
-        # run the model
+    def run_iteration(self, kwargs, param_values, run_count):
+        kwargscopy = copy.deepcopy(kwargs)
+        model = self.model_cls(**kwargscopy)
         self.run_model(model)
 
         # Collect and store results:
-        model_key = param_values + (run_count, )
+        if param_values is not None:
+            model_key = param_values + (run_count,)
+        else:
+            model_key = (run_count,)
+
         if self.model_reporters:
             self.model_vars[model_key] = self.collect_model_vars(model)
         if self.agent_reporters:
@@ -148,9 +165,7 @@ def _run_single_model(self, param_values, run_count, kwargs):
             for agent_id, reports in agent_vars.items():
                 agent_key = model_key + (agent_id,)
                 self.agent_vars[agent_key] = reports
-
-        return (getattr(self, "model_vars", None),
-                getattr(self, "agent_vars", None))
+        return (getattr(self, "model_vars", None), getattr(self, "agent_vars", None))
 
     def run_model(self, model):
         """ Run a model object to completion, or until reaching max steps.
@@ -224,8 +239,8 @@ def _prepare_report_table(self, vars_dict, extra_cols=None):
 
 class MPSupport(Exception):
     def __str__(self):
-        return """The BatchRunnerMP depends on pathos, which is either
-                not installed or the path can not be found."""
+        return ("BatchRunnerMP depends on pathos, which is either not "
+               "installed, or the path can not be found. ")
 
 
 class BatchRunnerMP(BatchRunner):
@@ -251,26 +266,20 @@ def run_all(self):
         Run the model at all parameter combinations and store results,
         overrides run_all from BatchRunner.
         """
+        run_count = count()
+        total_iterations, all_kwargs, all_param_values = self._make_model_args()
+
         # register the process pool and init a queue
         job_queue = []
-
-        param_names, param_ranges = zip(*self.variable_parameters.items())
-        run_count = count()
-        total_iterations = self.iterations
-        for param_range in param_ranges:
-            total_iterations *= len(param_range)
         with tqdm(total_iterations, disable=not self.display_progress) as pbar:
-            for param_values in product(*param_ranges):
-                kwargs = dict(zip(param_names, param_values))
-                kwargs.update(self.fixed_parameters)
-
-                # make a new process and add it to the queue
-                for i in range(self.iterations):
-                    job_queue.append(self.pool.uimap(self._run_single_model,
+            for i, kwargs in enumerate(all_kwargs):
+                param_values = all_param_values[i]
+                for _ in range(self.iterations):
+                    # make a new process and add it to the queue
+                    job_queue.append(self.pool.uimap(self.run_iteration,
+                                                     (kwargs,),
                                                      (param_values,),
-                                                     (next(run_count),),
-                                                     (kwargs,)))
-
+                                                     (next(run_count),)))
             # empty the queue
             results = []
             for task in job_queue:


Test patch: