parameterrun 0.2.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. parameterrun-0.4.0/.github/workflows/ci.yml +65 -0
  2. {parameterrun-0.2.1 → parameterrun-0.4.0}/.github/workflows/publish.yml +1 -1
  3. {parameterrun-0.2.1 → parameterrun-0.4.0}/CHANGELOG.md +24 -0
  4. {parameterrun-0.2.1 → parameterrun-0.4.0}/CONTRIBUTING.md +11 -2
  5. {parameterrun-0.2.1 → parameterrun-0.4.0}/PKG-INFO +25 -13
  6. {parameterrun-0.2.1 → parameterrun-0.4.0}/README.md +24 -12
  7. {parameterrun-0.2.1 → parameterrun-0.4.0}/examples/minimal_example.py +0 -1
  8. parameterrun-0.4.0/examples/minimal_example_mpi.py +21 -0
  9. {parameterrun-0.2.1 → parameterrun-0.4.0}/pyproject.toml +3 -0
  10. {parameterrun-0.2.1 → parameterrun-0.4.0}/src/parameterrun/__init__.py +1 -1
  11. {parameterrun-0.2.1 → parameterrun-0.4.0}/src/parameterrun/parallel_utils.py +105 -61
  12. parameterrun-0.4.0/tests/test_depths.py +36 -0
  13. {parameterrun-0.2.1 → parameterrun-0.4.0}/tests/test_format_input.py +22 -3
  14. parameterrun-0.4.0/tests/test_joblib.py +14 -0
  15. parameterrun-0.4.0/tests/test_mpi_integration.py +30 -0
  16. {parameterrun-0.2.1 → parameterrun-0.4.0}/tests/test_parameter_run.py +22 -0
  17. parameterrun-0.2.1/.github/workflows/ci.yml +0 -48
  18. {parameterrun-0.2.1 → parameterrun-0.4.0}/.github/workflows/release.yml +0 -0
  19. {parameterrun-0.2.1 → parameterrun-0.4.0}/.gitignore +0 -0
  20. {parameterrun-0.2.1 → parameterrun-0.4.0}/LICENSE +0 -0
  21. {parameterrun-0.2.1 → parameterrun-0.4.0}/scripts/bump_version.py +0 -0
  22. {parameterrun-0.2.1 → parameterrun-0.4.0}/tests/test_get_iteration.py +0 -0
  23. {parameterrun-0.2.1 → parameterrun-0.4.0}/tests/test_normalized_time.py +0 -0
@@ -0,0 +1,65 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [ main ]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ test:
12
+ name: non-mpi / ${{ matrix.os }} / py${{ matrix.python-version }}
13
+ runs-on: ${{ matrix.os }}
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ os: [ ubuntu-latest, macos-latest, windows-latest ]
18
+ python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ]
19
+
20
+ steps:
21
+ - uses: actions/checkout@v6
22
+
23
+ - uses: actions/setup-python@v6
24
+ with:
25
+ python-version: ${{ matrix.python-version }}
26
+
27
+ - name: Install uv
28
+ run: python -m pip install uv
29
+
30
+ - name: Sync environment
31
+ run: uv sync
32
+
33
+ - name: Tests
34
+ run: uv run pytest -m "not mpi"
35
+
36
+ - name: Lint
37
+ run: uv run ruff check .
38
+
39
+ - name: Type check
40
+ run: uv run mypy
41
+
42
+ test-mpi-linux:
43
+ name: mpi / ubuntu-latest / py3.12 / 4 ranks
44
+ runs-on: ubuntu-latest
45
+
46
+ steps:
47
+ - uses: actions/checkout@v6
48
+
49
+ - uses: actions/setup-python@v6
50
+ with:
51
+ python-version: "3.12"
52
+
53
+ - name: Setup MPI
54
+ uses: mpi4py/setup-mpi@v1
55
+ with:
56
+ mpi: 'openmpi'
57
+
58
+ - name: Install uv
59
+ run: python -m pip install uv
60
+
61
+ - name: Sync environment with MPI extra
62
+ run: uv sync --extra mpi
63
+
64
+ - name: MPI tests (4 ranks)
65
+ run: mpirun -n 4 uv run pytest -m mpi -o addopts="-q -m mpi"
@@ -19,7 +19,7 @@ jobs:
19
19
  with:
20
20
  ref: ${{ github.event.workflow_run.head_sha }}
21
21
 
22
- - uses: actions/setup-python@v5
22
+ - uses: actions/setup-python@v6
23
23
  with:
24
24
  python-version: "3.9"
25
25
 
@@ -6,6 +6,30 @@ The format is based on Keep a Changelog and the project follows Semantic Version
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-04-21
10
+
11
+ ### Added
12
+
13
+ - Added `tests/test_mpi_integration.py` with MPI numerical-equivalence coverage for `parameterrun(..., backend="mpi")`.
14
+ - Added a dedicated Linux PR CI job that runs MPI tests with 4 ranks.
15
+
16
+ ### Changed
17
+
18
+ - Updated default test commands in docs and CI to run non-MPI tests with `-m "not mpi"`.
19
+ - Registered a pytest `mpi` marker in `pyproject.toml` for explicit MPI test selection.
20
+ - Update how the indices to compute are shared among workers. Previously, all the indices where computed by all workers,
21
+ which caused a significan overhead in the memory usage. Now, the indices are computed on the fly.
22
+
23
+ ## [0.3.0] - 2026-04-01
24
+
25
+ ### Changed
26
+
27
+ - Added `result_as_array` to `parameterrun(...)`: outputs are numpy arrays by default and can be converted to Python
28
+ lists with `result_as_array=False`.
29
+ - Removed the `pbar_kwargs` optional argument from `parameterrun(...)`, as it was unused.
30
+ - Added more tests for the depth of the parameter grid.
31
+ - Added the possibility to add parameter values as numpy arrays, which are converted to lists internally.
32
+
9
33
  ## [0.2.1] - 2026-04-01
10
34
 
11
35
  ### Added
@@ -13,13 +13,22 @@ If you do not use `uv`, create a virtual environment and install equivalent dev
13
13
  ## Run quality checks
14
14
 
15
15
  ```bash
16
- uv run pytest
16
+ uv run pytest -m "not mpi"
17
17
  uv run ruff check .
18
- uv run mypy src/parameterrun
18
+ uv run mypy
19
19
  uv run python -m build
20
20
  uv run python -m twine check dist/*
21
21
  ```
22
22
 
23
+ ## Run MPI checks (required on PR CI)
24
+
25
+ The PR workflow includes a dedicated Linux MPI job that runs with 4 ranks.
26
+
27
+ ```bash
28
+ uv sync --extra mpi
29
+ mpirun -n 4 uv run pytest -m mpi -o addopts="-q -m mpi"
30
+ ```
31
+
23
32
  ## Pull request guidelines
24
33
 
25
34
  - Keep PRs focused and small when possible.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parameterrun
3
- Version: 0.2.1
3
+ Version: 0.4.0
4
4
  Summary: Parallel parameter sweeps with joblib/MPI and tqdm progress bars.
5
5
  Project-URL: Homepage, https://github.com/Davtax/ParameterRun
6
6
  Project-URL: Repository, https://github.com/Davtax/ParameterRun
@@ -146,9 +146,16 @@ print(mean_like)
146
146
  # array([2, 3, 4])
147
147
 
148
148
  print(square_like) # array([1, 4, 9])
149
+
150
+ # Ask for plain Python lists instead of numpy arrays
151
+ mean_like, square_like = parameterrun(stats, param_names="x", param_values=[1, 2, 3], n_workers=1,
152
+ result_as_array=False, )
153
+
154
+ print(mean_like) # [2, 3, 4]
149
155
  ```
150
156
 
151
157
  If the function returns a single object, the result is returned as a single array when reshaping is possible.
158
+ Set `result_as_array=False` to convert outputs to Python lists.
152
159
 
153
160
  For multi-dimensional sweeps, reshaping is automatic when `reshape=True` (default): the first dimensions always match
154
161
  the parameter grid shape.
@@ -203,12 +210,6 @@ By default, progress bars are enabled:
203
210
  result = parameterrun(my_function, param_names="x", param_values=[1, 2, 3], pbar_bool=True, )
204
211
  ```
205
212
 
206
- You can also pass extra keyword arguments to `tqdm` through `pbar_kwargs`:
207
-
208
- ```python
209
- result = parameterrun(my_function, param_names="x", param_values=[1, 2, 3], pbar_kwargs={"leave": False}, )
210
- ```
211
-
212
213
  Verbose logging can be enabled with:
213
214
 
214
215
  ```python
@@ -223,18 +224,18 @@ The progress-bar description is generated automatically from the function name a
223
224
  `parameterrun`
224
225
 
225
226
  ```python
226
- parameterrun(fun, param_names, param_values, n_workers=-1, pbar_bool=True, verbose=False, pbar_kwargs=None,
227
- reshape=True, backend=None, desc=None, **kwargs, )
227
+ parameterrun(fun, param_names, param_values, n_workers=-1, pbar_bool=True, verbose=False,
228
+ reshape=True, result_as_array=True, backend=None, desc=None, **kwargs, )
228
229
  ```
229
230
 
230
231
  * `fun`: function to evaluate
231
232
  * `param_names`: parameter name or grouped parameter names
232
- * `param_values`: values corresponding to `param_names`
233
+ * `param_values`: values corresponding to `param_names` (any iterable, e.g. list, tuple, range, numpy array)
233
234
  * `n_workers`: number of workers for `joblib`
234
235
  * `pbar_bool`: enable or disable progress bars
235
236
  * `verbose`: print backend and timing information
236
- * `pbar_kwargs`: additional keyword arguments passed to tqdm
237
237
  * `reshape`: reshape results into arrays when possible
238
+ * `result_as_array`: return numpy arrays by default; if `False`, convert outputs to Python lists
238
239
  * `backend`: `"joblib"`, `"mpi"`, or `None`
239
240
  * `desc`: custom progress-bar label
240
241
  * `**kwargs`: extra fixed keyword arguments forwarded to `fun`
@@ -243,6 +244,9 @@ parameterrun(fun, param_names, param_values, n_workers=-1, pbar_bool=True, verbo
243
244
 
244
245
  `parameterrun` accepts three input styles.
245
246
 
247
+ At every style, value containers can be any iterable (for example lists, tuples, ranges, generators, or numpy
248
+ arrays).
249
+
246
250
  ### A. Single parameter
247
251
 
248
252
  ```python
@@ -269,9 +273,17 @@ param_values = [[[1, 2], [10, 20]], [[100, 200, 300]], ]
269
273
  Before opening a PR or publishing a release, run:
270
274
 
271
275
  ```bash
272
- pytest
276
+ pytest -m "not mpi"
273
277
  ruff check .
274
- mypy src/parameterrun
278
+ mypy
275
279
  python -m build
276
280
  python -m twine check dist/*
277
281
  ```
282
+
283
+ For MPI numerical-equivalence checks (Linux/OpenMPI, 4 ranks):
284
+
285
+ ```bash
286
+ uv sync --extra mpi
287
+ mpirun -n 4 uv run pytest -m mpi -o addopts="-q -m mpi"
288
+ ```
289
+
@@ -114,9 +114,16 @@ print(mean_like)
114
114
  # array([2, 3, 4])
115
115
 
116
116
  print(square_like) # array([1, 4, 9])
117
+
118
+ # Ask for plain Python lists instead of numpy arrays
119
+ mean_like, square_like = parameterrun(stats, param_names="x", param_values=[1, 2, 3], n_workers=1,
120
+ result_as_array=False, )
121
+
122
+ print(mean_like) # [2, 3, 4]
117
123
  ```
118
124
 
119
125
  If the function returns a single object, the result is returned as a single array when reshaping is possible.
126
+ Set `result_as_array=False` to convert outputs to Python lists.
120
127
 
121
128
  For multi-dimensional sweeps, reshaping is automatic when `reshape=True` (default): the first dimensions always match
122
129
  the parameter grid shape.
@@ -171,12 +178,6 @@ By default, progress bars are enabled:
171
178
  result = parameterrun(my_function, param_names="x", param_values=[1, 2, 3], pbar_bool=True, )
172
179
  ```
173
180
 
174
- You can also pass extra keyword arguments to `tqdm` through `pbar_kwargs`:
175
-
176
- ```python
177
- result = parameterrun(my_function, param_names="x", param_values=[1, 2, 3], pbar_kwargs={"leave": False}, )
178
- ```
179
-
180
181
  Verbose logging can be enabled with:
181
182
 
182
183
  ```python
@@ -191,18 +192,18 @@ The progress-bar description is generated automatically from the function name a
191
192
  `parameterrun`
192
193
 
193
194
  ```python
194
- parameterrun(fun, param_names, param_values, n_workers=-1, pbar_bool=True, verbose=False, pbar_kwargs=None,
195
- reshape=True, backend=None, desc=None, **kwargs, )
195
+ parameterrun(fun, param_names, param_values, n_workers=-1, pbar_bool=True, verbose=False,
196
+ reshape=True, result_as_array=True, backend=None, desc=None, **kwargs, )
196
197
  ```
197
198
 
198
199
  * `fun`: function to evaluate
199
200
  * `param_names`: parameter name or grouped parameter names
200
- * `param_values`: values corresponding to `param_names`
201
+ * `param_values`: values corresponding to `param_names` (any iterable, e.g. list, tuple, range, numpy array)
201
202
  * `n_workers`: number of workers for `joblib`
202
203
  * `pbar_bool`: enable or disable progress bars
203
204
  * `verbose`: print backend and timing information
204
- * `pbar_kwargs`: additional keyword arguments passed to tqdm
205
205
  * `reshape`: reshape results into arrays when possible
206
+ * `result_as_array`: return numpy arrays by default; if `False`, convert outputs to Python lists
206
207
  * `backend`: `"joblib"`, `"mpi"`, or `None`
207
208
  * `desc`: custom progress-bar label
208
209
  * `**kwargs`: extra fixed keyword arguments forwarded to `fun`
@@ -211,6 +212,9 @@ parameterrun(fun, param_names, param_values, n_workers=-1, pbar_bool=True, verbo
211
212
 
212
213
  `parameterrun` accepts three input styles.
213
214
 
215
+ At every style, value containers can be any iterable (for example lists, tuples, ranges, generators, or numpy
216
+ arrays).
217
+
214
218
  ### A. Single parameter
215
219
 
216
220
  ```python
@@ -237,9 +241,17 @@ param_values = [[[1, 2], [10, 20]], [[100, 200, 300]], ]
237
241
  Before opening a PR or publishing a release, run:
238
242
 
239
243
  ```bash
240
- pytest
244
+ pytest -m "not mpi"
241
245
  ruff check .
242
- mypy src/parameterrun
246
+ mypy
243
247
  python -m build
244
248
  python -m twine check dist/*
245
249
  ```
250
+
251
+ For MPI numerical-equivalence checks (Linux/OpenMPI, 4 ranks):
252
+
253
+ ```bash
254
+ uv sync --extra mpi
255
+ mpirun -n 4 uv run pytest -m mpi -o addopts="-q -m mpi"
256
+ ```
257
+
@@ -11,4 +11,3 @@ xs = np.linspace(0, 10, 100)
11
11
  ys = np.linspace(0, 20, 50)
12
12
 
13
13
  result = parameterrun(f, param_names=[["x"], ["y"]], param_values=[[xs], [ys]], n_workers=1, scale=2, )
14
- result = result.reshape(len(xs), len(ys))
@@ -0,0 +1,21 @@
1
+ import numpy as np
2
+ from mpi4py import MPI
3
+
4
+ from parameterrun import parameterrun
5
+
6
+
7
+ def f(x, y, scale=1):
8
+ return scale * (x + y)
9
+
10
+
11
+ comm = MPI.COMM_WORLD
12
+ rank = comm.Get_rank()
13
+ size = comm.Get_size()
14
+
15
+ xs = np.linspace(0, 10, 100)
16
+ ys = np.linspace(0, 20, 50)
17
+
18
+ result = parameterrun(f, param_names=[["x"], ["y"]], param_values=[[xs], [ys]], scale=2, backend='mpi')
19
+
20
+ if rank == 0:
21
+ print(np.all(result == 2 * (xs[:, None] + ys[None, :])))
@@ -64,6 +64,9 @@ packages = ["src/parameterrun"]
64
64
  [tool.pytest.ini_options]
65
65
  testpaths = ["tests"]
66
66
  addopts = "-q --cov=parameterrun --cov-report=term-missing --cov-fail-under=70"
67
+ markers = [
68
+ "mpi: tests that require mpi4py and an MPI launcher",
69
+ ]
67
70
 
68
71
  [tool.ruff]
69
72
  target-version = "py39"
@@ -1,4 +1,4 @@
1
- __version__ = "0.2.1"
1
+ __version__ = "0.4.0"
2
2
 
3
3
  from .parallel_utils import parameterrun
4
4
 
@@ -1,10 +1,12 @@
1
1
  import contextlib
2
2
  import inspect
3
3
  import sys
4
+ from collections.abc import Iterable
4
5
  from datetime import datetime
5
6
  from itertools import product
7
+ from math import prod
6
8
  from time import time
7
- from typing import Any, Callable, List, Optional, Tuple, Union
9
+ from typing import Any, Callable, List, Optional, Tuple, Union, cast
8
10
 
9
11
  import joblib
10
12
  import numpy as np
@@ -57,14 +59,10 @@ def tqdm_joblib(tqdm_object: tqdm):
57
59
 
58
60
 
59
61
  def _parameterrun_joblib(fun: Callable[..., Any], param_names: List[List[str]], param_values: List[List[List]],
60
- indices_iterate: list, desc, n_workers: Optional[int] = -1, pbar_bool: bool = True,
62
+ indices_iterate: Iterable, desc, n_workers: Optional[int] = -1, pbar_bool: bool = True,
61
63
  leave: Optional[bool] = True, **fun_kwargs) -> Tuple[List[List], int]:
62
- n_total = len(indices_iterate)
63
- if n_total == 0:
64
- raise ValueError('No parameter combinations generated. Please provide at least one value per group.')
65
-
64
+ result = []
66
65
  if n_workers == 1: # If only one worker, do not use joblib
67
- result = []
68
66
  pbar = tqdm(indices_iterate, desc=desc, leave=leave, disable=not pbar_bool)
69
67
  for index in pbar:
70
68
  result.append(fun(**{**_get_iteration(index, param_names, param_values), **fun_kwargs}))
@@ -74,6 +72,8 @@ def _parameterrun_joblib(fun: Callable[..., Any], param_names: List[List[str]],
74
72
  delayed(fun)(**{**_get_iteration(index, param_names, param_values), **fun_kwargs}) for index in
75
73
  indices_iterate)
76
74
 
75
+ n_total = len(result)
76
+
77
77
  n_output = len(result[0]) if isinstance(result[0], tuple) else 1
78
78
  if n_output == 1:
79
79
  result = [(result_i,) for result_i in result]
@@ -85,28 +85,45 @@ def _parameterrun_joblib(fun: Callable[..., Any], param_names: List[List[str]],
85
85
  return result_temp, n_output
86
86
 
87
87
 
88
+ def _product_element_from_index(lists: List[Any], k: int) -> List:
89
+ lengths = [len(L) for L in lists]
90
+
91
+ indices = [0] * len(lists)
92
+ for i in range(len(lists) - 1, -1, -1):
93
+ k, indices[i] = divmod(k, lengths[i])
94
+
95
+ return list(L[j] for L, j in zip(lists, indices))
96
+
97
+
88
98
  def _parameterrun_mpi(fun: Callable[..., Any], param_names: List[List[str]], param_values: List[List[List]],
89
- groups_iterate: list, desc: str, pbar_bool: bool = True, verbose: Optional[bool] = False,
90
- leave: Optional[bool] = True, **fun_kwargs) -> Tuple[List[List], int]: # pragma: no cover
99
+ desc: Optional[str], pbar_bool: bool = True, verbose: Optional[bool] = False,
100
+ leave: Optional[bool] = True, seed=None, **fun_kwargs) -> Tuple[
101
+ List[List], int]: # pragma: no cover
91
102
  from mpi4py import MPI
92
103
 
93
104
  comm = MPI.COMM_WORLD
94
105
  rank = comm.Get_rank()
95
106
  size = comm.Get_size()
96
107
 
97
- n_total = len(groups_iterate)
108
+ n_values = [len(group[0]) for group in param_values]
109
+ indices = [list(range(n_value)) for n_value in n_values]
110
+ n_total = prod(n_values) # Compute the total number of iterations
98
111
 
99
112
  # Shuffle the indices to avoid bias in the computation
100
113
  if rank == 0:
114
+ rng = np.random.default_rng(seed=seed)
101
115
  total_indices = np.arange(n_total)
102
- np.random.shuffle(total_indices)
116
+ rng.shuffle(total_indices)
103
117
  else:
104
118
  total_indices = None
105
119
 
106
120
  total_indices = comm.bcast(total_indices, root=0)
107
121
  indices_compute = np.array_split(total_indices, size)[rank]
108
122
 
109
- groups_compute = np.array(groups_iterate)[indices_compute]
123
+ # groups_compute = np.array(groups_iterate)[indices_compute]
124
+ groups_compute = []
125
+ for index in indices_compute:
126
+ groups_compute.append(_product_element_from_index(indices, index))
110
127
 
111
128
  pbar = tqdm(groups_compute, desc=desc, leave=leave, disable=not (pbar_bool and rank == 0), file=sys.stdout)
112
129
 
@@ -156,7 +173,7 @@ def _parameterrun_mpi(fun: Callable[..., Any], param_names: List[List[str]], par
156
173
 
157
174
  pbar.close()
158
175
  else:
159
- comm.send(1, dest=0, tag=0) # Notify root that the work is done
176
+ comm.send(True, dest=0, tag=0) # Notify root that the work is done
160
177
 
161
178
  n_outputs = comm.bcast(n_outputs, root=0)
162
179
 
@@ -202,6 +219,12 @@ def _flatten_param_names(param_names: List[List[str]]) -> List[str]:
202
219
  return [name for group in param_names for name in group]
203
220
 
204
221
 
222
+ def _to_list_iterable(values: Any, error_message: str) -> List[Any]:
223
+ if isinstance(values, (str, bytes)) or not isinstance(values, Iterable):
224
+ raise ValueError(error_message)
225
+ return list(values)
226
+
227
+
205
228
  def _validate_function_arguments(fun: Callable[..., Any], param_names: List[List[str]], fun_kwargs: dict) -> None:
206
229
  signature = inspect.signature(fun)
207
230
  parameters = signature.parameters
@@ -231,21 +254,18 @@ def _validate_function_arguments(fun: Callable[..., Any], param_names: List[List
231
254
  raise ValueError(f'Unknown keyword arguments for function {fun.__name__}: {unknown_kwargs}')
232
255
 
233
256
 
234
- def _format_input(param_names: Union[str, list], param_values: list) -> Tuple[List[List[str]], List[List[List]]]:
257
+ def _format_input(param_names: Union[str, List[str], List[List[str]]], param_values: Any) -> Tuple[
258
+ List[List[str]], List[List[List[Any]]]]:
235
259
  # Check the depth of the parameters
236
260
 
237
261
  if isinstance(param_names, str):
238
262
  if not param_names:
239
263
  raise ValueError('Parameter names must be non-empty strings')
240
- if not isinstance(param_values, list):
241
- raise ValueError('The parameters values must be a list of values')
242
264
  depth = 1
243
265
  elif isinstance(param_names, list) and len(param_names) > 0 and all(isinstance(name, str) for name in param_names):
244
266
  if any(not name for name in param_names):
245
267
  raise ValueError('Parameter names must be non-empty strings')
246
268
  depth = 2
247
- if not isinstance(param_values, list):
248
- raise ValueError('The parameters values must be a list of values')
249
269
  elif isinstance(param_names, list) and len(param_names) > 0 and all(
250
270
  isinstance(group, list) for group in param_names):
251
271
  if any(len(group) == 0 for group in param_names):
@@ -253,44 +273,53 @@ def _format_input(param_names: Union[str, list], param_values: list) -> Tuple[Li
253
273
  if any(any(not isinstance(name, str) or not name for name in group) for group in param_names):
254
274
  raise ValueError('Parameter names must be non-empty strings')
255
275
  depth = 3
256
- if not isinstance(param_values, list) or len(param_values) == 0 or not isinstance(param_values[0], list):
257
- raise ValueError('The parameters values must be a list of values')
258
276
  else:
259
277
  raise ValueError('Unknown input format for the parameters names')
260
278
 
279
+ normalized_param_names: List[List[str]]
280
+ normalized_param_values: List[List[List[Any]]]
281
+
261
282
  # Correct the input if the depth is not equal to 3
262
283
  if depth == 1:
263
284
  # Only one parameter is provided
264
- param_names = [[param_names]]
265
- param_values = [[param_values]]
285
+ values = _to_list_iterable(param_values, 'The parameters values must be an iterable of values')
286
+ normalized_param_names = [[cast(str, param_names)]]
287
+ normalized_param_values = [[values]]
266
288
  elif depth == 2:
267
289
  # Groups with a single parameters are provided
268
- param_names = [[param_name_i] for param_name_i in param_names]
269
- param_values = [[param_values_i] for param_values_i in param_values]
290
+ values = _to_list_iterable(param_values, 'The parameters values must be an iterable of values')
291
+ normalized_param_names = [[param_name_i] for param_name_i in cast(List[str], param_names)]
292
+ normalized_param_values = [
293
+ [_to_list_iterable(param_values_i, 'Each parameter values entry must be an iterable')] for param_values_i in
294
+ values]
295
+ else:
296
+ groups_values = _to_list_iterable(param_values, 'The parameters values must be an iterable of values')
297
+ groups_values = [_to_list_iterable(values_group, 'Each parameter group values must be provided as an iterable')
298
+ for values_group in groups_values]
299
+ normalized_param_names = cast(List[List[str]], param_names)
300
+ normalized_param_values = [
301
+ [_to_list_iterable(parameter_values, 'Each parameter values entry must be an iterable') for parameter_values
302
+ in values_group] for values_group in groups_values]
270
303
 
271
304
  # Check that the number of groups of parameters is the same as the number of groups of values
272
- if len(param_names) != len(param_values):
305
+ if len(normalized_param_names) != len(normalized_param_values):
273
306
  raise ValueError('The number of groups of parameters must be the same as the number of groups of values')
274
307
 
275
- for names_group, values_group in zip(param_names, param_values):
276
- if not isinstance(values_group, list):
277
- raise ValueError('Each parameter group values must be provided as a list')
308
+ for names_group, values_group in zip(normalized_param_names, normalized_param_values):
278
309
  if len(names_group) != len(values_group):
279
310
  raise ValueError('Each parameter group must include one values list per parameter')
280
- if any(not isinstance(parameter_values, list) for parameter_values in values_group):
281
- raise ValueError('Each parameter values entry must be a list')
282
311
 
283
312
  # Check that all parameters in the same group have the same length
284
- for group in param_values:
313
+ for group in normalized_param_values:
285
314
  if len(set([len(param) for param in group])) != 1:
286
315
  raise ValueError('All parameters in the same group must have the same length')
287
316
 
288
- all_names = _flatten_param_names(param_names) # type: ignore[arg-type]
317
+ all_names = _flatten_param_names(normalized_param_names)
289
318
  duplicated = sorted({name for name in all_names if all_names.count(name) > 1})
290
319
  if duplicated:
291
320
  raise ValueError(f'Duplicate parameter names are not allowed: {duplicated}')
292
321
 
293
- return param_names, param_values # type: ignore[return-value]
322
+ return normalized_param_names, normalized_param_values
294
323
 
295
324
 
296
325
  def _reshape_channel_result(channel_values: List[Any], n_values: List[int]) -> np.ndarray:
@@ -302,11 +331,19 @@ def _reshape_channel_result(channel_values: List[Any], n_values: List[int]) -> n
302
331
  return array_result.reshape(target_shape)
303
332
 
304
333
 
334
+ def _convert_result_to_lists(result: Any) -> Any:
335
+ if isinstance(result, np.ndarray):
336
+ return result.tolist()
337
+ if isinstance(result, list):
338
+ return [_convert_result_to_lists(item) for item in result]
339
+ return result
340
+
341
+
305
342
  def parameterrun(fun: Callable[..., Any], param_names: Union[str, List[str], List[List[str]]],
306
- param_values: Union[List, List[List], List[List[List]]], n_workers: Optional[int] = -1,
307
- pbar_bool: bool = True, verbose: Optional[bool] = False, pbar_kwargs: Optional[dict] = None,
308
- reshape: Optional[bool] = True, backend: Optional[str] = None, desc: Optional[str] = None, **kwargs) -> \
309
- Union[list, np.ndarray, None]: # noqa: E501
343
+ param_values: Union[Iterable[Any], Iterable[Iterable[Any]], Iterable[Iterable[Iterable[Any]]]],
344
+ n_workers: Optional[int] = -1, pbar_bool: bool = True, verbose: Optional[bool] = False,
345
+ reshape: bool = True, result_as_array: bool = True, backend: Optional[str] = None,
346
+ desc: Optional[str] = None, **kwargs) -> Union[list, np.ndarray, None]: # noqa: E501
310
347
  """
311
348
  Run a function with multiple parameters in parallel. To indentify the parameters of interest, user must provide its
312
349
  name as is written in the function definition. If more parameters should be pass to the function, they can be
@@ -332,8 +369,9 @@ def parameterrun(fun: Callable[..., Any], param_names: Union[str, List[str], Lis
332
369
  Function to run in parallel.
333
370
  param_names : str or list
334
371
  Name of the parameters to run in parallel.
335
- param_values : list
336
- Values of the parameters to run in parallel.
372
+ param_values : iterable
373
+ Values of the parameters to run in parallel. Lists, tuples, ranges, generators, and numpy arrays are
374
+ accepted as long as they follow one of the supported input shapes.
337
375
  n_workers : int, optional (default=-1)
338
376
  Number of workers to use in parallel. If -1, the number of workers is the maximum number of cores available.
339
377
  pbar_bool : bool, optional (default=True)
@@ -342,11 +380,12 @@ def parameterrun(fun: Callable[..., Any], param_names: Union[str, List[str], Lis
342
380
  Backend to use for the parallelization. If None, the backend is chosen automatically between joblib and mpi.
343
381
  verbose : bool, optional (default=False)
344
382
  If True, print information about the parallelization.
345
- pbar_kwargs : dict, optional (default=None)
346
- Dictionary with the parameters to pass to the tqdm such as desc and leave.
347
383
  reshape : bool, optional (default=True)
348
384
  If True, reshape the result as a (hyper)matrix. Sometimes, due to the nature of the output, the reshape is not
349
385
  possible, in this case, the result is not reshaped.
386
+ result_as_array : bool, optional (default=True)
387
+ If True, return numpy arrays when possible (default behavior). If False, convert numpy-array outputs to Python
388
+ lists.
350
389
  desc: str, optional (default=None)
351
390
  Description of the progress bar. If None, the description denotes the function and parameters names.
352
391
  kwargs :
@@ -359,22 +398,16 @@ def parameterrun(fun: Callable[..., Any], param_names: Union[str, List[str], Lis
359
398
  n_valuesN), where n_valuesI is the number of values in the I-th group.
360
399
  """
361
400
 
362
- if pbar_kwargs is None:
363
- pbar_kwargs = {}
364
-
365
401
  time_start = time()
366
402
 
367
- param_names, param_values = _format_input(param_names, param_values)
368
- _validate_function_arguments(fun, param_names, kwargs)
403
+ formatted_param_names, formatted_param_values = _format_input(param_names, param_values)
404
+ _validate_function_arguments(fun, formatted_param_names, kwargs)
369
405
 
370
406
  # Create the list of dictionaries with the parameters in a nested loop
371
- n_values = [len(group[0]) for group in param_values]
407
+ n_values = [len(group[0]) for group in formatted_param_values]
372
408
  if any(n_value == 0 for n_value in n_values):
373
409
  raise ValueError('Parameter groups must contain at least one value.')
374
410
 
375
- indices = [list(range(n_value)) for n_value in n_values]
376
- indices_iterate = product(*indices)
377
-
378
411
  # Choose the backed
379
412
  if backend is not None:
380
413
  available_backends = ['joblib', 'mpi']
@@ -402,43 +435,54 @@ def parameterrun(fun: Callable[..., Any], param_names: Union[str, List[str], Lis
402
435
  backend = 'mpi'
403
436
 
404
437
  if desc is None:
405
- param_names_pbar = [parameters_names_i for parameters_names_i in param_names]
406
- desc = f'{fun.__name__}: {param_names_pbar}'
438
+ param_names_pbar = [parameters_names_i for parameters_names_i in formatted_param_names]
439
+ auto_desc = f'{fun.__name__}: {param_names_pbar}'
440
+
441
+ if len(auto_desc) > 40:
442
+ auto_desc = auto_desc[:40] + '(...)]'
407
443
 
408
- if len(desc) > 40:
409
- desc = desc[:40] + '(...)'
444
+ desc = auto_desc
410
445
 
411
446
  # Execute the parallel run
447
+ result: List[List[Any]]
448
+
412
449
  if backend == 'joblib':
413
450
  _log('Running under joblib', verbose)
414
451
 
415
- result, n_outputs = _parameterrun_joblib(fun, param_names, param_values, list(indices_iterate), desc,
416
- n_workers=n_workers, pbar_bool=pbar_bool, **pbar_kwargs, **kwargs)
452
+ indices = [list(range(n_value)) for n_value in n_values]
453
+ indices_iterate = product(*indices)
454
+ result, n_outputs = _parameterrun_joblib(fun, formatted_param_names, formatted_param_values, indices_iterate,
455
+ desc, n_workers=n_workers, pbar_bool=pbar_bool, **kwargs)
417
456
 
418
457
  elif backend == 'mpi':
419
458
  _log(f'Running under mpi with {size} workers', verbose and rank == 0,
420
459
  hostname=mpi_module.Get_processor_name() if mpi_module is not None else None, )
421
460
 
422
- result, n_outputs = _parameterrun_mpi(fun, param_names, param_values, list(indices_iterate), desc,
423
- pbar_bool=pbar_bool, verbose=verbose, **pbar_kwargs, **kwargs)
461
+ result, n_outputs = _parameterrun_mpi(fun, formatted_param_names, formatted_param_values, desc,
462
+ pbar_bool=pbar_bool, verbose=verbose, **kwargs)
424
463
 
425
464
  else:
426
465
  raise ValueError('Unknown backend')
427
466
 
428
467
  if rank == 0:
468
+ final_result: Union[List[List[Any]], np.ndarray, List[np.ndarray]] = result
469
+
429
470
  # Reshape the result to get the (hyper)matrix
430
471
  if reshape:
431
472
  try:
432
473
  if n_outputs == 1:
433
- result = _reshape_channel_result(result[0], n_values)
474
+ final_result = _reshape_channel_result(result[0], n_values)
434
475
  else:
435
- result = [_reshape_channel_result(result[i], n_values) for i in range(n_outputs)]
476
+ final_result = [_reshape_channel_result(result[i], n_values) for i in range(n_outputs)]
436
477
  except ValueError:
437
478
  print('Could not reshape the result')
438
479
 
439
480
  total_time = time() - time_start
440
481
  _log(f'Total time: {_normalized_time(total_time)}', verbose)
441
482
 
442
- return result
483
+ if not result_as_array:
484
+ final_result = _convert_result_to_lists(final_result)
485
+
486
+ return final_result
443
487
  else:
444
488
  return None
@@ -0,0 +1,36 @@
1
+ import numpy as np
2
+
3
+ from parameterrun import parameterrun
4
+
5
+
6
+ def test_depth_1():
7
+ def fun(x):
8
+ return x ** 2
9
+
10
+ xs = np.linspace(0, 10, 5)
11
+ result = parameterrun(fun, 'x', xs, n_workers=1, pbar_bool=False)
12
+
13
+ assert isinstance(result, np.ndarray)
14
+ np.testing.assert_array_equal(result, xs ** 2)
15
+
16
+
17
+ def test_depth_2():
18
+ def fun(x):
19
+ return x ** 2
20
+
21
+ xs = np.linspace(0, 10, 5)
22
+ result = parameterrun(fun, ['x'], [xs], n_workers=1, pbar_bool=False)
23
+
24
+ assert isinstance(result, np.ndarray)
25
+ np.testing.assert_array_equal(result, xs ** 2)
26
+
27
+
28
+ def test_depth_3():
29
+ def fun(x):
30
+ return x ** 2
31
+
32
+ xs = np.linspace(0, 10, 5)
33
+ result = parameterrun(fun, [['x']], [[xs]], n_workers=1, pbar_bool=False)
34
+
35
+ assert isinstance(result, np.ndarray)
36
+ np.testing.assert_array_equal(result, xs ** 2)
@@ -1,3 +1,4 @@
1
+ import numpy as np
1
2
  import pytest
2
3
 
3
4
  from parameterrun.parallel_utils import _format_input
@@ -25,9 +26,27 @@ def test_format_input_rejects_empty_parameter_name():
25
26
  _format_input("", [1, 2])
26
27
 
27
28
 
28
- def test_format_input_rejects_non_list_group_values():
29
- with pytest.raises(ValueError, match="values entry must be a list"):
30
- _format_input([["x"]], [[(1, 2)]])
29
+ def test_format_input_accepts_numpy_array_values():
30
+ names, values = _format_input("x", np.array([1, 2, 3]))
31
+ assert names == [["x"]]
32
+ assert values == [[[1, 2, 3]]]
33
+
34
+
35
+ def test_format_input_accepts_iterables_in_multiple_parameter_format():
36
+ names, values = _format_input(["x", "y"], (range(3), (10, 20, 30)))
37
+ assert names == [["x"], ["y"]]
38
+ assert values == [[[0, 1, 2]], [[10, 20, 30]]]
39
+
40
+
41
+ def test_format_input_accepts_tuple_parameter_values_inside_group():
42
+ names, values = _format_input([["x"]], [[(1, 2)]])
43
+ assert names == [["x"]]
44
+ assert values == [[[1, 2]]]
45
+
46
+
47
+ def test_format_input_rejects_string_values():
48
+ with pytest.raises(ValueError, match="iterable of values"):
49
+ _format_input("x", "123")
31
50
 
32
51
 
33
52
  def test_format_input_rejects_duplicate_names():
@@ -0,0 +1,14 @@
1
+ import numpy as np
2
+
3
+ from parameterrun import parameterrun
4
+
5
+
6
+ def test_joblib_2_workers():
7
+ def f(x):
8
+ return x ** 2
9
+
10
+ xs = np.linspace(0, 10, 5)
11
+ result = parameterrun(f, 'x', xs, n_workers=2, pbar_bool=False, backend="joblib")
12
+
13
+ assert isinstance(result, np.ndarray)
14
+ np.testing.assert_array_equal(result, xs ** 2)
@@ -0,0 +1,30 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ import parameterrun.parallel_utils as pu
5
+
6
+
7
+ @pytest.mark.mpi
8
+ def test_parameterrun_mpi_numerical_equivalence_and_root_return():
9
+ MPI = pytest.importorskip("mpi4py.MPI")
10
+ comm = MPI.COMM_WORLD
11
+
12
+ if comm.Get_size() != 4:
13
+ pytest.skip("Run this test with mpirun -n 4")
14
+
15
+ xs = np.array([0.0, 1.0, 2.0])
16
+ ys = np.array([10.0, 20.0])
17
+
18
+ def f(x, y, scale=1.0):
19
+ return scale * (x + y)
20
+
21
+ result = pu.parameterrun(f, param_names=[["x"], ["y"]], param_values=[[xs], [ys]], scale=2.0, backend="mpi",
22
+ pbar_bool=False, )
23
+
24
+ expected = 2.0 * (xs[:, None] + ys[None, :])
25
+
26
+ if comm.Get_rank() == 0:
27
+ assert isinstance(result, np.ndarray)
28
+ np.testing.assert_allclose(result, expected)
29
+ else:
30
+ assert result is None
@@ -15,6 +15,17 @@ def test_parameterrun_single_parameter_returns_1d_array():
15
15
  np.testing.assert_array_equal(result, np.array([0, 1, 4, 9]))
16
16
 
17
17
 
18
+ def test_parameterrun_single_parameter_can_return_list():
19
+ def square(x):
20
+ return x ** 2
21
+
22
+ result = pu.parameterrun(square, param_names="x", param_values=[0, 1, 2, 3], n_workers=1, pbar_bool=False,
23
+ backend="joblib", result_as_array=False, )
24
+
25
+ assert isinstance(result, list)
26
+ assert result == [0, 1, 4, 9]
27
+
28
+
18
29
  def test_parameterrun_grouped_parameters_builds_expected_grid():
19
30
  def f(x, y, z, offset=0):
20
31
  return x + y + z + offset
@@ -47,6 +58,17 @@ def test_parameterrun_multiple_outputs_returns_list_of_arrays():
47
58
  np.testing.assert_array_equal(result[1], np.array([1, 4, 9]))
48
59
 
49
60
 
61
+ def test_parameterrun_multiple_outputs_can_return_list_of_lists():
62
+ def f(x):
63
+ return x + 1, x ** 2
64
+
65
+ result = pu.parameterrun(f, param_names="x", param_values=[1, 2, 3], n_workers=1, pbar_bool=False,
66
+ backend="joblib", result_as_array=False, )
67
+
68
+ assert isinstance(result, list)
69
+ assert result == [[2, 3, 4], [1, 4, 9]]
70
+
71
+
50
72
  def test_parameterrun_multiple_outputs_keep_grid_shape():
51
73
  def f(x, y):
52
74
  return x + y, x * y
@@ -1,48 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- pull_request:
5
- branches: [ main ]
6
- # push:
7
- # branches: [ main ]
8
- # Optional only if you use GitHub merge queue:
9
- # merge_group:
10
- # types: [checks_requested]
11
-
12
- permissions:
13
- contents: read
14
-
15
- jobs:
16
- test:
17
- name: ${{ matrix.os }} / py${{ matrix.python-version }}
18
- runs-on: ${{ matrix.os }}
19
- strategy:
20
- fail-fast: false
21
- matrix:
22
- os: [ ubuntu-latest, macos-latest, windows-latest ]
23
- python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ]
24
-
25
- steps:
26
- - uses: actions/checkout@v6
27
-
28
- - uses: actions/setup-python@v5
29
- with:
30
- python-version: ${{ matrix.python-version }}
31
-
32
- - name: Install uv
33
- run: python -m pip install uv
34
-
35
- - name: Sync environment
36
- run: uv sync
37
-
38
- - name: Lint
39
- run: uv run ruff check .
40
-
41
- # - name: Format check
42
- # run: uv run ruff format --check .
43
-
44
- # - name: Type check
45
- # run: uv run mypy
46
-
47
- - name: Tests
48
- run: uv run pytest
File without changes
File without changes