Parameter sweeps

research_helpers.sweep runs a parameter grid as a scheduler job array. The grid is planned once, the array is submitted, each task works on its own slice, and the results are then collected together.

It fits the shape one parameter grid, one expensive function, independent tasks, meaning that it is not suitable for tasks that depend on each other, or situations where the grid is generated dynamically.

Planning, running, and checking status need only the standard library. The sweep extra (which depends on pandas and pyarrow) is needed for collect, which means a status check from a login node does not require any installs.

Define the sweep

"""A sweep over a toy objective."""

from research_helpers.sweep import Sweep

sweep = Sweep()


@sweep.context
def prepare(manifest, run_dir):
    """Load anything every combination in this task needs, once per task."""
    return {'dataset': manifest.metadata.get('dataset', 'demo')}


@sweep.evaluate
def evaluate(params, context):
    """Score one parameter combination."""
    objective = params['alpha'] * params['beta'] + len(context['dataset'])
    return {'objective': round(objective, 4), 'seeds': 5}


if __name__ == '__main__':
    raise SystemExit(sweep.main())

@sweep.evaluate is the expensive function: one combination in, a dict of measurements out. @sweep.context is optional and runs once per task (not once per combination).

Configure the grid

notes = "toy objective, alpha x beta"

[metadata]
dataset = "demo"

[grid]
alpha = [0.1, 0.5, 1.0]
beta = [1, 2, 4, 8]

[constants]
tolerance = 1e-6

grid is swept over, while constants are passed to every combination unchanged. Both TOML and JSON are accepted.

Note

constants are part of a combination’s identity. Two sweeps over the same grid with different constants produce different combination_ids and cannot collide in a shared results table.

Plan

$ python sweep_demo.py plan --config config.toml --run-dir runs/demo --tasks 4
planned 12 combinations over 4 array tasks (~3 per task)
manifest: runs/demo/manifest.json

submit with:
  sbatch --array=1-4 <your sbatch script> runs/demo

The manifest is the record of what the run is. It holds the grid, every expanded combination with its id, the array width, the metadata, and the notes. It is written once and tasks read it, nothing recomputes the partition.

That means that if tasks 17 and 42 are killed, and then resubmitted with --array=17,42, they reproduce exactly the slices they had originally, because the width comes from the manifest rather than from the size of the array.

Run

Each task writes its own part file, so no two tasks write to the same file, and no locking is needed:

$ python sweep_demo.py run --run-dir runs/demo --task 1
[task 1/4] combinations 0..2 (3 assigned, 0 already done, 3 to run)
[task 1] 1/3 7789d6bcd687 in 0.0s
[task 1] 2/3 3e8101ffc006 in 0.0s
[task 1] 3/3 fcbd23e1d4ff in 0.0s
wrote runs/demo/parts/task-00001.jsonl

--task is for running manually. Under a scheduler it is read from the environment—SLURM_ARRAY_TASK_ID, and the SGE, PBS, and LSF equivalents.

If a task that has already finished is resubmitted, it does nothing:

$ python sweep_demo.py run --run-dir runs/demo --task 1
[task 1/4] combinations 0..2 (3 assigned, 3 already done, 0 to run)

The ability to resume is per combination, not per task, so a task killed by the walltime keeps everything it had finished. Part files are JSON Lines written one at a time and flushed. A truncated final line from a task killed mid-write is skipped on read rather than corrupting the output file.

Status

$ python sweep_demo.py status --run-dir runs/demo
  planned                12
  completed              3
  remaining              9
  percent                25.0
  mean_runtime_seconds   0.01
  total_compute_seconds  0.0

Standard library only (runs on a login node with nothing installed).

Collect

$ python sweep_demo.py collect --run-dir runs/demo --leading combination_id alpha beta objective
collected 12 combinations -> runs/demo/results.csv
combination_id  alpha  beta  objective  tolerance  seeds  task_index  runtime_seconds
  7789d6bcd687    0.1     1        4.1   0.000001      5           1            0.011
  3e8101ffc006    0.1     2        4.2   0.000001      5           1            0.013
  fcbd23e1d4ff    0.1     4        4.4   0.000001      5           1            0.011
  c15e73c315a4    0.1     8        4.8   0.000001      5           2            0.011

Duplicate ids—e.g., from a task that ran twice—are deduplicated. --leading names columns to put first and a name that is not a column is ignored.

Note

The parameters are recorded by the engine. Note alpha, beta, and tolerance in the table above even though evaluate returns only objective and seeds. The runner merges the combination’s parameters into the record, so a table of results can be read on its own without joining it back to the manifest by id.

Sizing the array

$ python sweep_demo.py estimate --run-dir runs/demo --samples 4
mean 0.0s/combination over 4 random samples (min 0.0, median 0.0, max 0.0)
estimated serial total: 0.0 compute-hours, measured alone on a node

No contention factor is set, so these numbers are optimistic: a real array puts
many tasks on one node, competing for memory bandwidth, and each combination takes
longer than it does alone. Measure yours by timing the same combinations both ways,
then set contention-factor under [tool.research-helpers.sweep].

per-task wall time:
   100 tasks -> ~1 combinations/task -> 0.00 h
   500 tasks -> ~1 combinations/task -> 0.00 h
  1000 tasks -> ~1 combinations/task -> 0.00 h

Samples are drawn at random, not strided. A strided sample through a grid whose first axis is the expensive one gives a mean that is systematically wrong, and the error does not shrink with more samples.

With a factor configured, the projection is included:

projected in-array: ~0.0s/combination at 1.8x contention, ~0.0 compute-hours total

Submitting

A template is included with the package:

cp "$(python -c 'from importlib.resources import files; print(files("research_helpers")/"templates"/"sweep.sbatch")')" .

It has an EDIT THIS BLOCK fence around the parts that should be customized (i.e., account, partition, module loads) and sets OMP_NUM_THREADS, OPENBLAS_NUM_THREADS, MKL_NUM_THREADS, and NUMEXPR_NUM_THREADS to the cores the scheduler actually granted.