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.