Lifts and aggregates#
A lift turns one parameter definition into a list of independent copies. When the count is itself a parameter, the configuration’s length becomes part of what is searched. This page builds the operator pipeline of a memetic algorithm, which interleaves evolutionary operators with local-search refinement.
Repeating a parameter#
.repeat(count) takes a literal count or a parameter reference.
import designspace as ds
space = ds.space(
ds.param("n_stages").integer(2, 4),
ds.param("intensity").real(0.01, 5.0).log_scale().repeat(ds.param("n_stages")),
ds.param("bias").real(-1.0, 1.0).repeat(3),
)
config = space.sample_one(seed=0)
print(ds.pretty(config, space))
Config: 9 params, 9 set, 0 inactive, valid
n_stages = 3 in [2, 4]
intensity count 3
[0] = 0.053474440345836954 in [0.01, 5.0]
[1] = 0.01289989906178027 in [0.01, 5.0]
[2] = 0.01108173071235913 in [0.01, 5.0]
bias count 3
[0] = 0.6265404784005448 in [-1.0, 1.0]
[1] = 0.8255111545554434 in [-1.0, 1.0]
[2] = 0.21327155153435973 in [-1.0, 1.0]
n_stages is drawn first, because the count references it and so joins the
dependency graph:
space.topological_order
['n_stages', 'intensity', 'bias']
for c in space.sample_dicts(50, seed=1):
assert len(c["intensity"]) == c["n_stages"]
assert len(c["bias"]) == 3
A literal count is static and a parameter-driven one is dynamic. The space reports which it has:
space.has_variable_length
True
Instance paths#
Elements are addressed by index. That is what a per-element constraint or an error message names.
sorted(ds.flatten(config, space))
['bias',
'bias[0]',
'bias[1]',
'bias[2]',
'intensity',
'intensity[0]',
'intensity[1]',
'intensity[2]',
'n_stages']
Indices may be negative, resolved against the realized length:
space = space.encourage(ds.param("intensity[-1]") <= 0.5, tags=("gentle-finish",))
ce = space.evaluate_constraints(config)[-1]
ce.constraint.kind, ce.satisfied, round(ce.margin, 4)
('encourage', True, 0.4889)
Aggregates over a scalar lift#
An aggregate collapses the whole list to one value, so a rule can range over elements without naming them.
space = ds.space(
ds.param("n_stages").integer(3, 3),
ds.param("intensity").real(0.01, 5.0).repeat(ds.param("n_stages")),
).require(
ds.param("intensity").distinct(),
).encourage(
ds.param("intensity").is_sorted(descending=True), tags=("cooling",),
).encourage(
ds.param("intensity").sum() <= 8.0, tags=("budget",),
)
config = space.sample_one(seed=0)
[round(x, 3) for x in config["intensity"]]
[1.356, 0.214, 0.092]
[(ce.constraint.kind, ", ".join(ce.constraint.tags) or "-", ce.satisfied)
for ce in space.evaluate_constraints(config)]
[('require', '-', True),
('encourage', 'cooling', True),
('encourage', 'budget', True)]
.length(), .min() and .max() complete the set:
probe = ds.space(
ds.param("n").integer(2, 5),
ds.param("xs").real(0.0, 1.0).repeat(ds.param("n")),
).encourage(
(ds.param("xs").max() <= 0.9) & (ds.param("xs").min() >= 0.1),
tags=("range",),
).require(ds.param("xs").length() >= 2)
probe.sample_one(seed=3)["n"]
2
Lifting a choice#
Repeating a choice gives a heterogeneous list: bare strings and payload dicts side by side.
op = ds.param("pipeline").choice(
"shuffle",
mutation=ds.space(ds.param("rate").real(0.01, 0.5)),
local_search=ds.space(ds.param("iters").integer(1, 100)),
)
space = ds.space(
ds.param("n_ops").integer(2, 5),
op.repeat(ds.param("n_ops")),
).forbid(ds.param("pipeline").count_of("local_search") < 1)
config = space.sample_one(seed=0)
config["pipeline"]
['shuffle', 'shuffle', 'shuffle', {'local_search': {'iters': 92}}]
.count_of(variant) counts matching variants across the lift, which is what
the forbid above uses to require at least one local-search step.
for c in space.sample_dicts(100, seed=2):
n_local = sum(
1 for op in c["pipeline"] if isinstance(op, dict) and "local_search" in op
)
assert n_local >= 1
Lifting a struct#
A repeated struct gives a list of records. .field(name) projects it to one
column, after which the scalar aggregates apply.
stage = ds.space(
ds.param("setpoint_c").real(15.0, 30.0),
ds.param("hold_min").integer(5, 120),
)
space = ds.space(
ds.param("n_stages").integer(2, 4),
ds.param("stages").space(stage).repeat(ds.param("n_stages")),
).encourage(
ds.param("stages").field("hold_min").sum() <= 200, tags=("dwell-budget",),
)
config = space.sample_one(seed=0)
config["stages"]
[{'setpoint_c': 19.046800706458054, 'hold_min': 9},
{'setpoint_c': 15.247914532927936, 'hold_min': 99},
{'setpoint_c': 28.691333659165828, 'hold_min': 75}]
ce = space.evaluate_constraints(config)[0]
ce.satisfied, ce.margin
(True, 17.0)
A mixed instance-then-field path addresses one field of one element:
sorted(ds.flatten(config, space))[:4]
['n_stages', 'stages', 'stages[0].hold_min', 'stages[0].setpoint_c']
Nested lifts#
.repeat(2, 3) is shape sugar for .repeat(3).repeat(2), read outermost
first.
grid = ds.space(ds.param("gain_grid").real(0.0, 1.0).repeat(2, 3))
config = grid.sample_one(seed=0)
[[round(x, 3) for x in row] for row in config["gain_grid"]]
[[0.637, 0.27, 0.041], [0.017, 0.813, 0.913]]
assert len(config["gain_grid"]) == 2
assert all(len(row) == 3 for row in config["gain_grid"])
Where to go next#
Custom types and properties covers values no built-in type can express.