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. TOML, JSON, and YAML are all 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.

Two further keys change how a run is recorded rather than what it sweeps:

tuple_params = ["window"]
artefact_key = "cover"

tuple_params names parameters whose values are tuples rather than lists. JSON has no tuple, so without this an evaluation receives a list where the planning process passed a tuple. Declaring a parameter does not change any combination_id, so it can be added to a sweep that is already part-way through.

artefact_key is described under artefacts.

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 wall-time 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.

Results are converted to JSON-compatible formats before they are written. Numpy scalars are unwrapped, and tuples and sets are recorded as lists, through nested containers:

@sweep.evaluate
def evaluate(params, data):
    return {'f1': np.float64(0.83), 'window': (4, 12)}   # recorded as 0.83 and [4, 12]

The conversion function is available as jsonable for writing files alongside the parts:

from research_helpers.sweep import jsonable

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
  manifest_n_tasks       4
  unfinished_tasks       2-4

Resubmit the unfinished slices; resuming skips what is already recorded:
  sbatch --array=2-4 <your sbatch script> runs/demo

The range comes from the MANIFEST (n_tasks=4), not from the
config. If the config's task cap changed after this run was planned the two disagree,
and the plan must be re-run to adopt the new value.

Standard library only (runs on a login node without anything installed).

An unfinished run names the array indices still holding work, collapsed into the syntax --array takes. A task counts as unfinished if any combination in its slice is missing, so a task killed by the wall-time part-way through comes back and resumes where it stopped.

The width matters as much as the range. Slices come from the manifest, not from the width of the array submitted, so an array narrower than the manifest plans leaves the tail simply unrun: nothing fails anywhere, and the sweep stops short. That is what happens when a config’s max_tasks changes after a run directory was planned, which is why status prints the width the run actually holds.

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. Without it the table leads with the run’s own configuration, taken from the manifest: the id, then the constants, then the swept parameters, followed by the measurements.

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.

Artefacts

Some evaluations produce objects too large for a results row—e.g., a community cover, a fitted model, or a per-item prediction dump. Returning this from evaluate puts it in the row, where it makes the table unreadable and the CSV huge.

If the name of the key it comes back under is provided, the engine moves it out of the row:

artefact_key = "cover"
@sweep.evaluate
def evaluate(params, graph):
    cover = detect(graph, **params)
    return {'n_communities': len(cover), 'modularity': score(graph, cover), 'cover': cover}

The row keeps n_communities and modularity, the cover is written to runs/demo/artefacts/<combination_id>.json and read back with read_artefact:

from research_helpers.sweep import read_artefact

cover = read_artefact('runs/demo', '7789d6bcd687')

The key is declared on the sweep config rather than passed at run time, so the manifest records that the run kept them (otherwise a run with no artefacts is indistinguishable from one whose artefacts were never written). --artefact-key overrides it for a single task.

Aggregating several runs

A benchmark is usually one draw from a family—a subsample of a graph, a synthetic instance, a split—and the draws differ from each other more than the parameters do. To run the same grid over several independent draws and aggregate use:

from research_helpers.sweep import aggregate_runs

frame = aggregate_runs(['runs/sub01', 'runs/sub02', 'runs/sub03', 'runs/sub04', 'runs/sub05'])
   alpha  beta  n_runs  objective_mean  objective_ci  objective_sem
     0.1     1       5           4.118         0.214          0.077
     0.1     2       5           4.232         0.187          0.067

Each metric gets a mean, the half-width of a 95% interval, and the standard error. The interval uses Student’s t rather than the normal approximation (aggregation is over a handful of runs and at that size the normal understates it). A single run reports NaN.

Metrics default to every numeric column that is not a grouping column or engine book-keeping. A metric missing from one run of several—a measurement added part-way through a series—aggregates over the runs that have it rather than voiding the row.

Unlike aggregate_runs, which needs the sweep extra for pandas, the statistic behind it does not:

from research_helpers.sweep import confidence_interval

mean, half_width, error = confidence_interval([0.82, 0.79, 0.88, 0.81, 0.85])

confidence_interval only requires the standard library, so it works in the same places plan, run, and status do. The Student’s t quantile it needs is computed in the package rather, see research_helpers.sweep.stats for details on its accuracy and limits.

Sizing the array

$ python sweep_demo.py estimate --run-dir runs/demo --samples 4
mean 12.4s/combination over 4 random samples (min 9.8, median 12.1, max 15.6)
  95% confidence interval on the mean: 8.7s to 16.1s
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.

The interval shows how well this sample pins the mean, and narrows as --samples rises. The spread shows how much the grid varies around that mean. A wide spread means that no single number describes the grid, regardless of how many samples are taken.

With a factor configured, the projection is included:

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

Submitting

Templates for sbatch ship with the package, along with the environment diagnostic for when a job fails before it reaches the code. See cluster jobs.

A campaign of many sweeps is better submitted through a few queues than as one array per sweep. See many sweeps through few arrays.