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.