Structural operations#

A Space is immutable. Every operation on this page returns a new one, leaving the receiver untouched, which is what makes it safe to derive several variants from a single declaration. The running example is the simulated annealing space from declaring a space.

import designspace as ds

space = (
    ds.space(
        ds.param("initial_temp").real(1e-2, 1e3).log_scale().tag("schedule"),
        ds.param("min_temp").real(1e-4, 1.0).log_scale().tag("schedule"),
        ds.param("cooling_rate").real(0.80, 0.999).quantized(step=0.005).tag("schedule"),
        ds.param("steps_per_temp").integer(1, 500),
        ds.param("neighborhood").categorical("swap", "insert", "reverse").tag("operator"),
        ds.param("reheat").bool(),
        ds.param("reheat_factor").real(1.5, 5.0).when(ds.param("reheat")),
    )
    .forbid(ds.param("min_temp") >= ds.param("initial_temp"))
    # References `neighborhood` and `steps_per_temp`, neither of which carries
    # the "schedule" tag. That matters for `.filter()` below.
    .forbid(
        ds.param("neighborhood").is_in("insert", "reverse")
        & (ds.param("steps_per_temp") < 5),
    )
    .encourage(ds.param("cooling_rate") >= 0.95, tags=("slow-cooling",))
)
print(space)
Space: 7 params, 1 conditional, 3 constraints
  initial_temp    real         [0.01, 1000.0]  log
  min_temp        real         [0.0001, 1.0]  log
  cooling_rate    real         [0.8, 0.999]  step=0.005
  steps_per_temp  integer      [1, 500]
  neighborhood    categorical  {'swap', 'insert', 'reverse'}
  reheat          bool         {False, True}
  reheat_factor   real         [1.5, 5.0]  when reheat

  forbid     min_temp >= initial_temp
  forbid     neighborhood in {'insert', 'reverse'} and steps_per_temp < 5
  encourage  cooling_rate >= 0.95

Pinning a parameter#

.freeze() narrows a parameter’s domain to a single value and keeps the parameter. It stays present in every configuration, so a submitted config can never disagree with the pinned value.

tuned = space.freeze(initial_temp=50.0, cooling_rate=0.85)
tuned.n_params, tuned.params["initial_temp"].domain
(7, [50.0, 50.0])
[(c["initial_temp"], c["cooling_rate"]) for c in tuned.sample_dicts(3, seed=0)]
[(50.0, 0.85), (50.0, 0.85), (50.0, 0.85)]

The receiver is unchanged:

assert space.params["initial_temp"].domain == ds.RealDomain(1e-2, 1e3)
assert space.n_params == tuned.n_params

Removing a parameter#

.slice() does the opposite: it removes the parameter and substitutes its fixed value at every reference site.

no_reheat = space.slice(reheat=False)
no_reheat.n_params, "reheat" in no_reheat.params
(6, False)

reheat_factor stays declared, but its .when(reheat) condition has collapsed to a constant, so it can never be sampled:

assert "reheat_factor" in no_reheat.params
assert all("reheat_factor" not in c for c in no_reheat.sample_dicts(50, seed=0))

Slicing a count is how a variable-length space is fixed to one layout:

variable = ds.space(
    ds.param("n").integer(1, 5),
    ds.param("xs").real(0.0, 1.0).repeat(ds.param("n")),
)
fixed = variable.slice(n=3)
fixed.has_variable_length, len(fixed.sample_one(seed=0)["xs"])
(False, 3)

Carving out a subtree#

.select(*paths) keeps a definition-path prefix subtree. .filter(tags=) keeps the parameters carrying a tag.

import warnings

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    schedule_only = space.filter(tags=("schedule",))
list(schedule_only.params)
['initial_temp', 'min_temp', 'cooling_rate']

That emitted a warning, because one constraint references parameters outside the kept set:

str(caught[0].message)
".filter(): dropped 1 constraint(s) referencing excluded param(s) ['neighborhood', 'steps_per_temp']"

Dropping the constraint is the best-effort default. strict=True raises instead, for cases where silently losing a rule would be unacceptable:

try:
    space.filter(tags=("schedule",), strict=True)
except ds.ResolutionError as exc:
    print(exc)
.filter(): constraint(s) reference excluded param(s) ['neighborhood', 'steps_per_temp'] (strict=True)

Adding after the fact#

.extend() is additive and takes the same builders as ds.space().

with_logging = space.extend(ds.param("log_every_n").integer(1, 100))
with_logging.n_params, space.n_params
(8, 7)

Rewriting every parameter#

.map_params(fn) passes each resolved ParamDef through a function. It reaches parameters wherever they live, including inside a lifted choice’s variant payloads, so the caller does not need to know each path in advance.

from dataclasses import replace


def coarsen(pd):
    if isinstance(pd.domain, ds.RealDomain) and pd.quantized is None:
        return replace(pd, quantized=ds.QuantizedSpec(step=0.05, factor=None))
    return pd


coarsened = space.map_params(coarsen)
[p for p, pd in coarsened.params.items()
 if space.params[p].quantized is None and pd.quantized is not None]
['initial_temp', 'min_temp', 'reheat_factor']

Dropping constraints by tag#

.without_constraints(tags=) removes declared constraints.

relaxed = space.without_constraints(tags=("slow-cooling",))
len(relaxed.constraints), len(space.constraints)
(2, 3)

Where to go next#

Partial configs and driver loops fills a configuration one parameter at a time.