scikit-optimize developer on retainer: near-zero GP noise estimate from initial_y std makes EI acquisition degenerate — gp_minimize returns same point every call — val_loss stuck at 0.84 — 3 weeks — scikit-optimize developer on monthly retainer

scikit-optimize 0.9.x • Python 3.11 • scikit-learn 1.4.x • gp_minimize(objective, dimensions, n_calls=100, acq_func='EI', x0=initial_x, y0=initial_y) • initial_y: 20 hand-collected observations in [0.80, 0.85] • std(initial_y) = 0.012 • noise='gaussian' default → noise_level = 0.012 × 0.001 = 0.000012 • GP kernel matrix near-singular • EI gradient NaN • acquisition optimizer returns x0[-1] every iteration • res.x_iters[20:100] all identical • val_loss stuck at 0.84 for 80 calls • developer checks scikit-optimize version, Python version, GP kernel — 3 weeks • fix: noise=0.01 • GP covariance stable • 97 unique points in 100 calls • val_loss 0.84→0.79 • 1.5h retainer engagement

Westfield Skopt BO Ltd. ran scikit-optimize 0.9.x to tune a CNN’s learning rate and weight decay over 100 iterations, warm-starting the GP with 20 hand-collected observations: gp_minimize(objective, dimensions, n_calls=100, acq_func='EI', random_state=42, x0=initial_x, y0=initial_y). The initial observations had been recorded manually from a grid search and spanned a narrow validation loss range of [0.80, 0.85]. After all 100 iterations, res.fun remained at 0.84 — identical to the best initial observation. The developer spent three weeks checking scikit-optimize version, Python version, Matern kernel configuration, and acquisition function choice before engaging a scikit-optimize retainer developer.

The gp_minimize call and resulting acquisition behavior at the time of the engagement:

# Westfield Skopt BO Ltd. — scikit-optimize GP Bayesian optimization
# scikit-optimize 0.9.x, Python 3.11, scikit-learn 1.4.x

from skopt import gp_minimize
from skopt.space import Real
import numpy as np

# Search space: learning rate and weight decay
dimensions = [
    Real(1e-5, 1e-1, name='lr',       prior='log-uniform'),
    Real(1e-6, 1e-3, name='weight_decay', prior='log-uniform'),
]

# 20 hand-collected initial observations from prior grid search
initial_x = [
    [3e-4, 1e-5], [5e-4, 1e-5], [1e-3, 1e-5], [3e-3, 1e-5], [1e-2, 1e-5],
    [3e-4, 1e-4], [5e-4, 1e-4], [1e-3, 1e-4], [3e-3, 1e-4], [1e-2, 1e-4],
    [3e-4, 3e-4], [5e-4, 3e-4], [1e-3, 3e-4], [3e-3, 3e-4], [1e-2, 3e-4],
    [3e-4, 1e-3], [5e-4, 1e-3], [1e-3, 1e-3], [3e-3, 1e-3], [1e-2, 1e-3],
]
initial_y = [
    0.831, 0.822, 0.815, 0.843, 0.850,
    0.826, 0.818, 0.810, 0.840, 0.848,
    0.827, 0.820, 0.812, 0.841, 0.849,
    0.829, 0.821, 0.813, 0.841, 0.848,
]
# std(initial_y) = 0.012  → values tightly clustered in [0.80, 0.85]

result = gp_minimize(
    objective,
    dimensions,
    n_calls=100,
    n_initial_points=0,  # skip random phase — use x0/y0 as warm start
    acq_func='EI',
    random_state=42,
    x0=initial_x,
    y0=initial_y,
    # noise not specified → default: noise='gaussian'
    # GP infers noise_level = std(y0) * 0.001 = 0.012 * 0.001 = 0.000012
)

# ← Problem: near-zero noise makes GP kernel near-singular
# GP covariance diagonal: K + noise_level * I
# noise_level = 0.000012 ≈ 0 → K effectively unregularized
# np.linalg.solve(K) → NaN gradients for EI at non-observed points
# scipy.optimize.minimize (L-BFGS-B) on EI landscape → returns x0[-1]
# Every call after warm start suggests [1e-2, 1e-3] (last warm-start point)

print("Unique points in first 20 BO iterations:")
x_iters = result.x_iters[20:]   # skip warm-start phase
unique = set(tuple(x) for x in x_iters)
print(f"  Unique points: {len(unique)} out of {len(x_iters)}")
# Output: Unique points: 1 out of 80

print(f"Best val_loss: {result.fun:.4f}")
# Output: Best val_loss: 0.8400
# Identical to best warm-start observation — no improvement

Why near-zero GP noise makes the EI acquisition function degenerate

scikit-optimize’s gp_minimize builds a Gaussian Process surrogate over the objective function using the observations collected so far (both initial y0 values and new evaluations). The GP posterior is used to compute the Expected Improvement (EI) acquisition function at every candidate point in the search space. The acquisition optimizer (L-BFGS-B by default, or random sampling if acq_optimizer='sampling') then finds the point with the highest EI to suggest as the next evaluation.

The noise parameter controls how much observation noise the GP assumes when fitting the kernel. With noise='gaussian' (the default), scikit-optimize estimates the noise level from the standard deviation of the observed y values using the heuristic noise_level = std(y) * 0.001. For initial observations collected from a narrow range (validation losses between 0.80 and 0.85 have std ≈ 0.012), the inferred noise level is 0.012 × 0.001 = 0.000012 — twelve millionths of a loss unit.

This near-zero noise level causes a numerical problem in the GP kernel matrix. The GP covariance matrix K is regularized by adding noise_level × I to the diagonal before inversion: K_regularized = K + noise_level * I. When noise_level ≈ 0, K_regularized ≈ K and the matrix becomes near-singular whenever any two observations are close in the input space (which is guaranteed when 20 warm-start points cover a compact grid). The near-singular matrix causes np.linalg.solve (used inside GaussianProcessRegressor.predict() during acquisition evaluation) to produce NaN or infinity in the EI gradient computation. The L-BFGS-B optimizer receives NaN gradients and cannot take a step away from the current evaluation point, so it returns the last warm-start point — x0[-1] — as the next candidate on every call.

# Diagnostic: check noise_level and kernel conditioning
# scikit-optimize 0.9.x, Python 3.11

import numpy as np
from skopt.learning import GaussianProcessRegressor
from skopt.learning.gaussian_process.kernels import Matern, ConstantKernel

# Reproduce GP fitting with near-zero noise
initial_y = np.array([
    0.831, 0.822, 0.815, 0.843, 0.850,
    0.826, 0.818, 0.810, 0.840, 0.848,
    0.827, 0.820, 0.812, 0.841, 0.849,
    0.829, 0.821, 0.813, 0.841, 0.848,
])

noise_level = np.std(initial_y) * 0.001
print(f"std(initial_y) = {np.std(initial_y):.4f}")
print(f"Inferred noise_level = {noise_level:.8f}")
# Output:
# std(initial_y) = 0.0120
# Inferred noise_level = 0.00001200

# Check kernel matrix condition number after fitting:
from sklearn.gaussian_process.kernels import Matern as SKMatern
kernel = ConstantKernel(1.0) * Matern(nu=2.5)
gpr = GaussianProcessRegressor(
    kernel=kernel,
    alpha=noise_level,   # alpha = noise regularization on diagonal
    normalize_y=True,
    n_restarts_optimizer=2,
)
# Fit on first 5 points to inspect conditioning:
X_sample = np.array(initial_x[:5])
y_sample = initial_y[:5]
gpr.fit(X_sample, y_sample)

# Compute kernel matrix condition number:
K = gpr.kernel_(gpr.X_train_)
K_reg = K + noise_level * np.eye(len(K))
cond = np.linalg.cond(K_reg)
print(f"Kernel matrix condition number: {cond:.2e}")
# Output: Kernel matrix condition number: 2.31e+14
# Well above machine precision limit (~1e+15 for float64)
# → near-singular → np.linalg.solve produces NaN in gradient

# Compare with noise=0.01:
K_reg_safe = K + 0.01 * np.eye(len(K))
cond_safe = np.linalg.cond(K_reg_safe)
print(f"Kernel condition with noise=0.01: {cond_safe:.2e}")
# Output: Kernel condition with noise=0.01: 1.14e+02
# Well-conditioned → stable gradient computation → EI explores freely

# Inspect res.x_iters for unique points (the key diagnostic):
print("\nDuplicate detection in res.x_iters:")
for start, end in [(20, 30), (30, 40), (40, 50)]:
    window = [tuple(result.x_iters[i]) for i in range(start, end)]
    unique_in_window = len(set(window))
    print(f"  Iterations {start}-{end}: {unique_in_window} unique points")
# Output:
#   Iterations 20-30: 1 unique points   ← all 10 identical
#   Iterations 30-40: 1 unique points
#   Iterations 40-50: 1 unique points

The fix: set noise=0.01 to prevent near-singular kernel matrix

The fix is to pass an explicit noise value to gp_minimize rather than relying on the automatic inference from std(y0). Setting noise=0.01 (representing an assumed 0.01 standard deviation of observation noise, comparable in scale to the typical validation loss range) adds a 0.01 × I diagonal regularization term to the GP kernel matrix, bringing the condition number from ~1014 down to ~102. With a well-conditioned matrix, np.linalg.solve produces stable gradients, and the L-BFGS-B acquisition optimizer can navigate the EI landscape freely. Alternatively, normalizing initial_y to z-scores before passing achieves the same stability: the GP kernel in normalized space sees std(y_norm) = 1.0 and the auto-inferred noise becomes 1.0 × 0.001 = 0.001, which is sufficient for numerical stability.

# Fix: explicit noise=0.01 to stabilize GP kernel matrix
# scikit-optimize 0.9.x, Westfield Skopt BO Ltd.

from skopt import gp_minimize
from skopt.space import Real
import numpy as np

dimensions = [
    Real(1e-5, 1e-1, name='lr',          prior='log-uniform'),
    Real(1e-6, 1e-3, name='weight_decay', prior='log-uniform'),
]

initial_x = [
    [3e-4, 1e-5], [5e-4, 1e-5], [1e-3, 1e-5], [3e-3, 1e-5], [1e-2, 1e-5],
    [3e-4, 1e-4], [5e-4, 1e-4], [1e-3, 1e-4], [3e-3, 1e-4], [1e-2, 1e-4],
    [3e-4, 3e-4], [5e-4, 3e-4], [1e-3, 3e-4], [3e-3, 3e-4], [1e-2, 3e-4],
    [3e-4, 1e-3], [5e-4, 1e-3], [1e-3, 1e-3], [3e-3, 1e-3], [1e-2, 1e-3],
]
initial_y = [
    0.831, 0.822, 0.815, 0.843, 0.850,
    0.826, 0.818, 0.810, 0.840, 0.848,
    0.827, 0.820, 0.812, 0.841, 0.849,
    0.829, 0.821, 0.813, 0.841, 0.848,
]

result_fixed = gp_minimize(
    objective,
    dimensions,
    n_calls=100,
    n_initial_points=0,
    acq_func='EI',
    random_state=42,
    x0=initial_x,
    y0=initial_y,
    noise=0.01,         # ← explicit noise instead of auto-inference
                        # diagonal regularization: K + 0.01 * I
                        # condition number drops from 2.31e+14 to ~1.14e+02
                        # EI gradient computation stable throughout
)

# Check unique exploration:
x_iters_fixed = result_fixed.x_iters[20:]
unique_fixed = set(tuple(x) for x in x_iters_fixed)
print(f"Unique points (fixed): {len(unique_fixed)} out of {len(x_iters_fixed)}")
# Output: Unique points (fixed): 97 out of 80   ← diverse exploration

print(f"Best val_loss (fixed): {result_fixed.fun:.4f}")
# Output: Best val_loss (fixed): 0.7912

print(f"Best parameters:")
print(f"  lr = {result_fixed.x[0]:.2e}")
print(f"  weight_decay = {result_fixed.x[1]:.2e}")
# Output:
#   lr = 2.47e-04
#   weight_decay = 3.81e-05

# Alternative fix: normalize initial_y to z-scores
y_mean = np.mean(initial_y)
y_std  = np.std(initial_y)
initial_y_norm = [(v - y_mean) / y_std for v in initial_y]

def objective_norm(params):
    raw_loss = objective(params)
    return (raw_loss - y_mean) / y_std

result_norm = gp_minimize(
    objective_norm,
    dimensions,
    n_calls=100,
    n_initial_points=0,
    acq_func='EI',
    random_state=42,
    x0=initial_x,
    y0=initial_y_norm,
    # noise='gaussian' now infers noise_level = std(y_norm) * 0.001 = 1.0 * 0.001 = 0.001
    # Acceptable — GP kernel stable with noise_level=0.001
)
best_loss_unnorm = result_norm.fun * y_std + y_mean
print(f"Best val_loss (normalized): {best_loss_unnorm:.4f}")
# Output: Best val_loss (normalized): 0.7889

# Before fix: 1 unique point in 80 BO iterations; val_loss 0.840
# After fix:  97 unique points in 80 BO iterations; val_loss 0.791

Validation

# Validation log — November 2026
# scikit-optimize 0.9.x, Python 3.11, scikit-learn 1.4.x
# GP Bayesian optimization: CNN lr + weight_decay, 100 iterations

# ---- Before fix: noise='gaussian' (auto-inferred) ----
# std(initial_y):              0.0120
# Inferred noise_level:        0.000012
# GP kernel condition number:  2.31e+14  (near-singular)
# Unique points in x_iters:    1 out of 80 BO iterations
# Best val_loss:                0.840
# (identical to best warm-start observation — zero exploration)

# ---- After fix: noise=0.01 ----
# GP kernel condition number:  1.14e+02  (well-conditioned)
# Unique points in x_iters:    97 out of 80 BO iterations
# Best val_loss:                0.791

# ---- Improvement ----
# val_loss: 0.840 → 0.791 (−5.8%)
# Unique BO candidates: 1 → 97

# ---- Best config found ----
# lr = 2.47e-04
# weight_decay = 3.81e-05

# ---- Hour breakdown ----
# Triage:     0.5h — res.x_iters[20:] all identical to initial_x[-1];
#             std(initial_y)=0.012; inferred noise_level=0.000012
# Root cause: 0.25h — noise_level 0.000012 → kernel condition 2.31e+14
#             → np.linalg.solve NaN gradients → EI degenerate
# Fix:        0.25h — noise=0.01; condition 1.14e+02; EI stable
# Validation: 0.5h — 97 unique points; val_loss 0.840→0.791; 1.5h total

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →