Skip to content

Extending HGP

The built-in factories cover the common case. To change how rules are initialized or mutated, subclass a factory and override its construction hook. For the built-in factories and hierarchical settings, see Configuring HGP.

Custom population strategies

The PopulationGenerator creates the initial set of rules. It uses a strategy pattern to allow different initialization methods.

When using BooleanGPConfig, pass a PopulationGeneratorFactory rather than a PopulationGenerator directly. Override create_strategies to use custom strategies.

from hgp_lib.populations import (
    PopulationGeneratorFactory,
    RandomStrategy,
    BestLiteralStrategy,
)

class MyFactory(PopulationGeneratorFactory):
    def create_strategies(self, num_literals, score_fn, train_data, train_labels):
        random = RandomStrategy(num_literals=num_literals)
        best = BestLiteralStrategy(
            num_literals=num_literals,
            score_fn=score_fn,
            train_data=train_data,
            train_labels=train_labels,
            sample_size=100,
            feature_size=None,
        )
        return [random, best]

factory = MyFactory(population_size=100)

You can also create a PopulationGenerator directly for standalone use, outside of BooleanGPConfig.

from hgp_lib.populations import PopulationGenerator, RandomStrategy

random_strategy = RandomStrategy(num_literals=10)
generator = PopulationGenerator(
    strategies=[random_strategy],
    population_size=100,
)
initial_population = generator.generate()

Custom mutations

A mutation subclasses Mutation and edits a rule node in place inside apply. The base class needs two flags that say whether the mutation can apply to literals, to operators, or to both.

The example below adds a RandomNegate mutation that flips a node's negation only some of the time, unlike the built-in NegateMutation that always flips it. It works on both literals and operators, so both flags are True.

import random
from hgp_lib.mutations import Mutation
from hgp_lib.rules import Rule

class RandomNegate(Mutation):
    def __init__(self, negate_p: float = 0.5):
        super().__init__(is_literal_mutation=True, is_operator_mutation=True)
        self.negate_p = negate_p

    def apply(self, rule: Rule):
        if random.random() < self.negate_p:
            rule.negated = not rule.negated

To use it, subclass MutationExecutorFactory and add the mutation in the relevant hook. create_literal_mutations returns the mutations applied to literal nodes, and create_operator_mutations returns those applied to operator nodes. Since RandomNegate handles both, add it to each.

from hgp_lib.mutations import MutationExecutorFactory

class MyMutationFactory(MutationExecutorFactory):
    def create_literal_mutations(self, num_literals):
        return super().create_literal_mutations(num_literals) + (RandomNegate(),)

    def create_operator_mutations(self, num_literals):
        return super().create_operator_mutations(num_literals) + (RandomNegate(),)

mutation_factory = MyMutationFactory(mutation_p=0.1)

Pass mutation_factory to BooleanGPConfig as shown in Configuring HGP. The factory builds the executor at runtime, once the number of features is known.

Low-level use of BooleanGP

For full control over the training loop, use BooleanGP directly. Training data is passed in the config, and num_features is derived from the data shape. The number of features is then passed to the configured factories for runtime construction.

from hgp_lib.configs import BooleanGPConfig
from hgp_lib.algorithms import BooleanGP
from hgp_lib.utils.validation import ComplexityCheck

check_valid = ComplexityCheck(100)

gp_config = BooleanGPConfig(
    train_data=train_data.to_numpy(dtype=bool),
    train_labels=train_labels,
    score_fn=score_fn,
    population_factory=population_factory,
    mutation_factory=mutation_factory,
    crossover_factory=crossover_factory,
    selection=selection,
    check_valid=check_valid,
    regeneration=True,
    regeneration_patience=100,
)
gp_algo = BooleanGP(gp_config)

for i in range(num_epochs):
    gen_metrics = gp_algo.step()
    if i % 100 == 0:
        val_score = gp_algo.evaluate_best(val_data.to_numpy(dtype=bool), val_labels)
        print(f"Epoch {i} -> val_best: {val_score:.4f}")

test_score = gp_algo.evaluate_best(test_data.to_numpy(dtype=bool), test_labels)
print(f"Test result: {test_score:.4f}")