Locking the search during generation updates
This notebook shows how Brush’s locking mechanism works when you want to preserve part of a program while continuing evolution on new data.
The sequence below demonstrates three phases:
Fit an initial model on a base dataset.
Lock the upper part of the tree and continue with
partial_fit(...).Unlock the model and run another
partial_fit(...)to resume unrestricted search.
import numpy as np
import graphviz
from pybrush import BrushRegressor
1. Create a base problem and a shifted follow-up problem
The second dataset changes the coefficient pattern so that the model benefits from reuse, but still needs adaptation.
def mse(y_true, y_pred):
y_true = np.asarray(y_true)
y_pred = np.asarray(y_pred)
return float(np.mean((y_true - y_pred) ** 2))
rng = np.random.default_rng(7)
n_samples = 500
X_base = rng.normal(size=(n_samples, 3))
y_base = np.array([
4.0 * row[0] + 0.5 * row[1] if row[2] > 0 else -3.0 * row[1] + 0.2 * row[0]
for row in X_base
])
# Shifts both in feature and target!
X_shifted = rng.normal(size=(n_samples, 3))
X_shifted[:, 0] = X_shifted[:, 0] * 1.4 + 1.0
X_shifted[:, 1] = X_shifted[:, 1] - 0.5
y_shifted = np.array([
4.0 * row[0] - 0.25 * row[1] if row[2] > 0 else -5.0 * row[1] + 0.4 * row[0]
for row in X_shifted
])
print('base shape:', X_base.shape, y_base.shape)
print('shifted shape:', X_shifted.shape, y_shifted.shape)
base shape: (500, 3) (500,)
shifted shape: (500, 3) (500,)
2. Fit the initial generation window
This first fit builds a population on the base data. The compact tree structure is the part we will selectively preserve in the next step.
est = BrushRegressor(
functions=['SplitOn', 'SplitBest', 'Mul', 'Add', 'Sub'],
pop_size=100,
max_gens=25,
max_depth=10,
max_size=24,
start_from_decision_trees=True,
constants_simplification=True,
inexact_simplification=False,
verbosity=1,
random_state=7,
)
est.fit(X_base, y_base)
print('stage 1 base mse:', mse(y_base, est.predict(X_base)))
print('stage 1 shifted mse:', mse(y_shifted, est.predict(X_shifted)))
print('stage 1 model:')
print(est.best_estimator_.get_model())
Completed 100% [====================]
stage 1 base mse: 2.024100915882261
stage 1 shifted mse: 6.257131345427466
stage 1 model:
Sub(Mul(Add(x_1,If(x_1>=-2.61,x_0,-4.43)),Sub(1.47*x_2,1.31)),-3.38*x_0)
3. Lock the upper part of the tree
Locking the top levels keeps the learned structure fixed while lower nodes and weights can still adapt. This is the key step for controlled transfer between generation windows.
est.best_estimator_.program.lock_nodes(
3,
keep_leaves_unlocked=True,
keep_current_weights=True,
)
print('locked the top two levels of the current best estimator')
print('locked model:')
print(est.best_estimator_.get_model())
print('locked tree:')
print(est.best_estimator_.get_model('tree'))
graphviz.Source(est.best_estimator_.get_model('dot'))
locked the top two levels of the current best estimator
locked model:
Sub(Mul(Add(x_1,If(x_1>=-2.61,x_0,-4.43)),Sub(1.47*x_2,1.31)),-3.38*x_0)
locked tree:
Sub
|- Mul
| |- Add
| | |- x_1
| | |- If(x_1>=-2.61)
| | | |- x_0
| | | |- -4.43
| |- Sub
| | |- 1.47*x_2
| | |- 1.31
|- -3.38*x_0
4. Continue evolution with the lock in place
partial_fit(...) reuses the current engine and population, then applies the requested lock depth before continuing the search on the new data.
est.partial_fit(
X_shifted,
y_shifted,
lock_nodes_depth=3,
keep_leaves_unlocked=True,
keep_current_weights=True,
)
print('stage 2 base mse:', mse(y_base, est.predict(X_base)))
print('stage 2 shifted mse:', mse(y_shifted, est.predict(X_shifted)))
print('stage 2 model:')
print(est.best_estimator_.get_model())
print('stage 2 tree:')
print(est.best_estimator_.get_model('tree'))
graphviz.Source(est.best_estimator_.get_model('dot'))
Completed 100% [====================]
stage 2 base mse: 3.6734543201338834
stage 2 shifted mse: 3.8054243085275217
stage 2 model:
Sub(Mul(Add(0.85*Sub(1.81*x_0,-2.40*x_1),0.01),Sub(x_2,-1.40)),0.88*Sub(-0.22,-6.02*x_1))
stage 2 tree:
Sub
|- Mul
| |- Add
| | |- 0.85*Sub
| | | |- 1.81*x_0
| | | |- -2.40*x_1
| | |- 0.01
| |- Sub
| | |- x_2
| | |- -1.40
|- 0.88*Sub
| |- -0.22
| |- -6.02*x_1
5. Stop locking and resume unrestricted search
Passing end_depth=0 unlocks the tree again. The last call shows that generation updates can resume without preserving the prior lock state.
est.best_estimator_.program.lock_nodes(0, keep_leaves_unlocked=True, keep_current_weights=False)
est.max_gens = 50
est.partial_fit(
X_shifted,
y_shifted,
lock_nodes_depth=0,
keep_leaves_unlocked=True,
keep_current_weights=False,
)
print('stage 3 base mse:', mse(y_base, est.predict(X_base)))
print('stage 3 shifted mse:', mse(y_shifted, est.predict(X_shifted)))
print('stage 3 unlocked model:')
print(est.best_estimator_.get_model())
print('stage 3 unlocked tree:')
print(est.best_estimator_.get_model('tree'))
graphviz.Source(est.best_estimator_.get_model('dot'))
Completed 100% [====================]
stage 3 base mse: 3.648386877972349
stage 3 shifted mse: 3.7962339333011497
stage 3 unlocked model:
Sub(Mul(Add(Sub(1.57*x_0,-2.03*x_1),-0.05*x_2),Sub(x_2,-1.38)),0.93*Sub(-0.29,-5.58*x_1))
stage 3 unlocked tree:
Sub
|- Mul
| |- Add
| | |- Sub
| | | |- 1.57*x_0
| | | |- -2.03*x_1
| | |- -0.05*x_2
| |- Sub
| | |- x_2
| | |- -1.38
|- 0.93*Sub
| |- -0.29
| |- -5.58*x_1