Conditionality and structure#

In a flat space every parameter is present in every configuration. Once the structure of a configuration depends on the values drawn, three mechanisms apply, and they are not interchangeable. This page assembles a genetic algorithm from operators, each of which brings sub-parameters that exist only when that operator is chosen.

Conditional parameters#

.when(condition) makes a parameter active only where the condition holds. An inactive parameter is absent from the configuration, not present and null.

import designspace as ds

space = ds.space(
    ds.param("elitism").bool(),
    ds.param("elite_fraction").real(0.0, 0.3).when(ds.param("elitism")),
)
space.sample_one(seed=2)
{'elitism': True, 'elite_fraction': 0.08954734302423699}
space.sample_one(seed=0)
{'elitism': False}

Conditionality is structural. Across a batch, the key appears exactly where the flag is set:

[(c["elitism"], "elite_fraction" in c) for c in space.sample_dicts(6, seed=1)]
[(False, False),
 (False, False),
 (True, True),
 (True, True),
 (False, False),
 (True, True)]
assert all(
    ("elite_fraction" in c) == c["elitism"] for c in space.sample_dicts(200, seed=3)
)

Alternatives#

.choice() names variants, each with its own payload. A bare string is a parameterless variant; a keyword argument carries a nested ds.space(...).

space = ds.space(
    ds.param("population_size").integer(20, 500).quantized(step=10),
    ds.param("selection").choice(
        "roulette",
        tournament=ds.space(ds.param("tournament_size").integer(2, 10)),
        rank=ds.space(ds.param("rank_pressure").real(1.0, 2.0)),
    ).prior(weights=[1, 3, 2]),
    ds.param("crossover").choice(
        "one_point",
        "two_point",
        uniform=ds.space(ds.param("swap_prob").real(0.1, 0.9)),
    ),
)
config = space.sample_one(seed=0)
config
{'population_size': 330,
 'selection': {'tournament': {'tournament_size': 2}},
 'crossover': 'one_point'}

A choice nests one level further than a plain parameter: the variant name is the key and its payload the value. weights= biases which variant is drawn.

Reading a choice back#

Indexing into that nesting couples the calling code to the convention. Three helpers read it apart instead.

ds.variant(config, "selection")
'tournament'
ds.payload(config, "selection")
{'tournament_size': 2}
ds.destructure(config, "crossover")
('one_point', None)

ds.payload returns None for a bare variant, which is how a parameterless variant is distinguished from one whose payload happens to be empty.

Grouping without a discriminator#

Where parameters are always active together and only need a namespace, a struct groups them. .space() on a parameter adds no conditionality and introduces no discriminator.

space = ds.space(
    ds.param("pid").space(
        ds.param("kp").real(0.1, 10.0).log_scale(),
        ds.param("ki").real(0.001, 0.999),
    ),
    ds.param("seed").integer(0, 100),
)
space.sample_one(seed=0)
{'pid': {'kp': 1.878985266149949, 'ki': 0.27024714033634256}, 'seed': 4}

Grouping and gating at the same time is a choice with one variant, and writing it as one keeps the gate visible.

What the space reports#

A payload-bearing choice is what makes a space hierarchical. Each variant relocates its descendants under a definition-path prefix, and .subspaces lists those prefixes.

space = ds.space(
    ds.param("selection").choice(
        "roulette",
        tournament=ds.space(ds.param("tournament_size").integer(2, 10)),
    ),
    ds.param("mutation_rate").real(1e-4, 0.5).log_scale(),
    ds.param("adaptive").bool(),
    ds.param("decay").real(0.9, 0.999).when(ds.param("adaptive")),
)
space.is_conditional, space.is_hierarchical
(True, True)
sorted(space.subspaces)
['selection.tournament.']

Each entry describes which parameters live inside that region and under what condition they are active:

info = space.subspaces["selection.tournament."]
info.kind, info.variant_name, info.member_paths
('variant', 'tournament', ('selection.tournament.tournament_size',))

The flattened view shows how a payload is addressed by path:

config = space.sample_one(seed=5)
sorted(ds.flatten(config, space))
['adaptive',
 'decay',
 'mutation_rate',
 'selection',
 'selection.tournament.tournament_size']

The active subspace of one draw#

.active_subspace(config) reports the parameters one concrete draw actually used. Unchosen variants and inactive conditionals disappear from the returned Space entirely.

active = space.active_subspace(config)
active.n_params, list(active.params)
(5,
 ['selection',
  'selection.tournament.tournament_size',
  'mutation_rate',
  'adaptive',
  'decay'])
assert active.n_params <= space.n_params
assert set(active.params) <= set(space.params)

Where to go next#

Constraints and feasibility adds rules across parameters.