Declaring a space#
A design space is declared once and then queried. This page builds the
configuration surface of a simulated annealing metaheuristic, covering its
cooling schedule, its move operator and its acceptance rule, and shows what the
resulting Space reports about itself.
Parameter types#
ds.param(name) opens a declaration and the next call fixes the type. Five
scalar types cover most spaces.
import designspace as ds
space = ds.space(
ds.param("initial_temp").real(1e-2, 1e3),
ds.param("steps_per_temp").integer(1, 500),
ds.param("neighborhood").categorical("swap", "insert", "reverse"),
ds.param("acceptance").ordinal("greedy", "boltzmann", "metropolis"),
ds.param("reheat").bool(),
)
print(space)
Space: 5 params, 0 conditional, 0 constraints
initial_temp real [0.01, 1000.0]
steps_per_temp integer [1, 500]
neighborhood categorical {'swap', 'insert', 'reverse'}
acceptance ordinal ('greedy' < 'boltzmann' < 'metropolis')
reheat bool {False, True}
real and integer take numeric bounds. categorical is unordered and
compared by equality only; ordinal is ordered by declaration, which is what
makes a comparison such as >= "boltzmann" meaningful.
The space reports its own shape without being sampled:
space.n_params, space.is_conditional, space.is_finite
(5, False, False)
Resolved parameters#
ds.space(...) resolves the builders into ParamDef records. That resolved
form is the introspection surface, and it is what a solver walks.
space.params["acceptance"]
acceptance: ordinal ('greedy' < 'boltzmann' < 'metropolis')Each field is worth knowing. type_kind is the discriminator, domain holds
the declared values, and chart is the coordinate system covered below. The
rest carry the modifiers, which are all unset here.
space.params["acceptance"].domain
('greedy' < 'boltzmann' < 'metropolis')Modifiers#
Modifiers chain onto a typed parameter. Most of them change its coordinate system rather than its domain.
.log_scale() gives a quantity spanning orders of magnitude a multiplicative
geometry, so uniform sampling is uniform per decade. .quantized(step=) snaps a
continuous parameter to a linear grid, and .quantized(factor=) to a geometric
one. .tag() labels a parameter for later filtering.
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("acceptance").ordinal("greedy", "boltzmann", "metropolis").tag("operator"),
)
space.params["cooling_rate"].quantized
quantized(step=0.005)
sorted(space.params["initial_temp"].tags)
['schedule']
Charts#
Every generative scalar parameter resolves to a chart, a monotone map from
[0, 1] onto the domain. The chart is what carries the prior, and it is what
gives a solver type-appropriate perturbation: mutate in [0, 1], then decode.
temp = space.params["initial_temp"].chart
[round(temp.from_unit(u), 4) for u in (0.0, 0.25, 0.5, 0.75, 1.0)]
[0.01, 0.1778, 3.1623, 56.2341, 1000.0]
The midpoint is the geometric mean rather than the arithmetic one, because the parameter declared a log scale. An unscaled parameter over the same bounds splits the interval evenly instead:
linear = ds.space(ds.param("x").real(1e-2, 1e3)).params["x"].chart
[round(linear.from_unit(u), 4) for u in (0.0, 0.25, 0.5, 0.75, 1.0)]
[0.01, 250.0075, 500.005, 750.0025, 1000.0]
The map runs both ways, so an existing configuration can be lifted back into coordinate space to seed a search:
round(temp.to_unit(1.0), 6)
0.4
# A log chart decodes its endpoints to within float error of the declared
# bounds rather than exactly, since it round-trips through log10.
assert abs(temp.from_unit(0.0) - 1e-2) < 1e-9
assert abs(temp.from_unit(1.0) - 1e3) < 1e-9
# The round-trip in the other direction is exact.
assert temp.to_unit(temp.from_unit(0.25)) == 0.25
Priors#
.log_scale() is sugar for .prior(ds.Log()). The three built-in families can
also be named directly, and weights= biases a discrete parameter.
priors = ds.space(
ds.param("kp").real(0.1, 10.0).prior(ds.Log()),
ds.param("ki").real(0.001, 0.999).prior(ds.Logit()),
ds.param("gain").real(1.0, 1024.0).prior(ds.Power(2.0)),
ds.param("mode").categorical("fast", "balanced", "thorough").prior(weights=[1, 3, 2]),
)
{name: pd.prior for name, pd in priors.params.items()}
{'kp': log, 'ki': logit, 'gain': power(2.0), 'mode': weights(1, 3, 2)}
A prior changes the chart, and therefore where uniform coordinate draws land:
{
name: round(priors.params[name].chart.from_unit(0.5), 4)
for name in ("kp", "ki", "gain")
}
{'kp': 1.0, 'ki': 0.5, 'gain': 724.0777}
Where to go next#
Sampling and validation draws from this space and checks configurations against it.