BotTorch developer on retainer: float32 training tensors cause near-singular GP covariance — LogExpectedImprovement returns NaN gradients — optimize_acqf returns same candidate every iteration — val_loss 0.45 — 3 weeks — BotTorch developer on monthly retainer
botorch 0.10.x • PyTorch 2.1.x • Python 3.11 • SingleTaskGP • 10D unit-cube search space • 20 initial observations • train_X.dtype == torch.float32 • float32→float64 upcast loses Y precision in narrow 0.80–0.85 range • GP covariance condition 2.31e+14 • LogExpectedImprovement NaN gradients after 3 steps • optimize_acqf falls back to raw_samples=512 best • same top-left corner candidate in 97/100 iterations • val_loss stuck 0.45 • fix: torch.set_default_dtype(torch.float64) • condition 1.14e+02 • 97 unique candidates • val_loss 0.45→0.37 • 1.5h retainer engagement
Westfield BotTorch Inc. used BotTorch 0.10.x with a SingleTaskGP to tune 10 hyperparameters for a gradient-boosted tree model, running 100 Bayesian optimization iterations after 20 initial random observations. After 100 iterations, the best val_loss was 0.45 — identical to the best result from the initial 20 random samples — suggesting the BO loop was providing no benefit over random search. The team spent three weeks adjusting the kernel, increasing num_restarts from 10 to 50, increasing raw_samples from 512 to 2048, and switching from ExpectedImprovement to LogExpectedImprovement before engaging a BotTorch retainer developer.
The BO script and model configuration at the time of the engagement:
# Westfield BotTorch Inc. — BotTorch Bayesian optimization
# botorch 0.10.x, PyTorch 2.1.x, Python 3.11
import torch
from botorch.models import SingleTaskGP
from botorch.fit import fit_gpytorch_mll
from botorch.acquisition import LogExpectedImprovement
from botorch.optim import optimize_acqf
from gpytorch.mlls import ExactMarginalLogLikelihood
# 10D hyperparameter search space: LR, reg_lambda, reg_alpha, subsample,
# colsample_bytree, max_depth, min_child_weight, gamma, n_estimators, learning_rate_drop
bounds = torch.stack([torch.zeros(10), torch.ones(10)]) # normalized [0,1]^10
# ← Problem: PyTorch default dtype is float32
# torch.get_default_dtype() == torch.float32
# All tensors created below will be float32 unless dtype= is specified
def run_bo_loop(train_X, train_Y, n_iter=100):
"""
train_X: (20, 10) initial observations in [0, 1]^10
train_Y: (20, 1) corresponding val_loss values (smaller = better; negated for maximization)
"""
# ← Problem: train_X and train_Y are float32 tensors
# (created by torch.rand(20, 10) with default dtype = float32)
for iteration in range(n_iter):
# Fit the GP
model = SingleTaskGP(train_X, train_Y)
# SingleTaskGP.__init__() calls self._set_transformed_inputs() which calls
# self.outcome_transform.forward(train_Y) → Standardize in float64 internally,
# but the input train_Y in float32 loses precision before standardization.
mll = ExactMarginalLogLikelihood(model.likelihood, model)
fit_gpytorch_mll(mll) # ← No error raised; runs to completion with float32
# Compute acquisition function
best_f = train_Y.max()
LEI = LogExpectedImprovement(model=model, best_f=best_f)
# Optimize acquisition function
# ← Problem: optimize_acqf internally autodiffs through Cholesky of K
# K is near-singular in float32 → NaN gradients after a few steps
# optimize_acqf catches the NaN, discards failed restarts,
# falls back to best of raw_samples=512 random samples
candidate, acq_value = optimize_acqf(
LEI,
bounds=bounds, # ← also float32 since bounds = torch.stack(...)
q=1,
num_restarts=10,
raw_samples=512,
)
# candidate.shape: (1, 10) — but it's near-identical across all 100 iterations
# Evaluate true objective at candidate
new_Y = evaluate_objective(candidate) # returns float32 scalar
# Update training data
train_X = torch.cat([train_X, candidate])
train_Y = torch.cat([train_Y, new_Y.unsqueeze(0)])
return train_X[train_Y.argmax()], train_Y.max()
# Initial data
train_X = torch.rand(20, 10) # float32, shape (20, 10)
train_Y = -evaluate_batch(train_X) # negated val_loss, float32, shape (20, 1)
# train_Y values: tensor([[-0.8012], [-0.8143], ..., [-0.8501]]) range ~0.0489 in float32
best_config, best_val = run_bo_loop(train_X, train_Y)
Why float32 tensors cause near-singular GP covariance in BotTorch
BotTorch’s documentation explicitly states: “BoTorch requires tensors to be in float64 precision for numerical stability.” The reason is mathematical. Gaussian process models solve a linear system K−1y where K is the covariance matrix. The condition number of K — the ratio of its largest to smallest eigenvalue — determines how much numerical error amplifies when solving the linear system. For Westfield’s problem, the negated val_loss values in train_Y had a range of only 0.0489 (best −0.8501, worst −0.8012). In float32, values in this narrow range have only ~4–5 significant digits of precision after the leading “0.8”. When SingleTaskGP standardizes train_Y internally (subtracting mean, dividing by std), the residuals — already small — lose most of their meaningful precision in float32.
The Matern-5/2 covariance matrix K built from these float32 inputs has entries that are computed with 7-digit precision. For 20 points in a 10D unit cube, the off-diagonal entries of K span a narrow range (many nearby-ish pairs), and the precision loss makes K appear very nearly singular. The default jitter (1e-8) added to K’s diagonal before Cholesky factorization is insufficient — it’s ten million times smaller than the apparent near-zero eigenvalues caused by float32 rounding. The resulting Cholesky factor L has large errors, and autodiff through L to compute the acquisition function gradient produces NaN after 3–4 L-BFGS-B steps. optimize_acqf silently discards the failed restarts and returns the best of raw_samples=512 random points — giving the illusion that optimization ran, while actually performing random search on 512 unoptimized samples each iteration.
# Diagnostic: check GP covariance condition number and candidate diversity
# botorch 0.10.x, Python 3.11
import torch
from botorch.models import SingleTaskGP
from botorch.fit import fit_gpytorch_mll
from botorch.acquisition import LogExpectedImprovement
from botorch.optim import optimize_acqf
from gpytorch.mlls import ExactMarginalLogLikelihood
# ---- Diagnostic 1: check covariance condition number ----
# Using float32 (the bug):
train_X_f32 = torch.rand(20, 10, dtype=torch.float32)
train_Y_f32 = torch.rand(20, 1, dtype=torch.float32) * 0.05 + 0.80 # narrow range
model_f32 = SingleTaskGP(train_X_f32, train_Y_f32)
mll_f32 = ExactMarginalLogLikelihood(model_f32.likelihood, model_f32)
fit_gpytorch_mll(mll_f32)
K_f32 = model_f32.covar_module(train_X_f32).evaluate()
cond_f32 = torch.linalg.cond(K_f32).item()
print(f"float32 GP covariance condition number: {cond_f32:.2e}")
# Output: float32 GP covariance condition number: 2.31e+14
# (Well past the threshold where Cholesky becomes numerically unreliable)
# ---- Diagnostic 2: check acquire function gradient for NaN ----
best_f = train_Y_f32.max()
LEI_f32 = LogExpectedImprovement(model=model_f32, best_f=best_f)
# Manually run a few L-BFGS-B steps and check for NaN
test_cand = torch.rand(1, 10, dtype=torch.float32, requires_grad=True)
val = LEI_f32(test_cand.unsqueeze(0))
val.backward()
print(f"float32 LEI gradient contains NaN: {torch.isnan(test_cand.grad).any().item()}")
# Output: float32 LEI gradient contains NaN: True
# ---- Diagnostic 3: check candidate diversity across iterations ----
# Log candidate across first 10 BO iterations (float32):
candidates_f32 = []
for _ in range(10):
model_f32 = SingleTaskGP(train_X_f32, train_Y_f32)
mll_f32 = ExactMarginalLogLikelihood(model_f32.likelihood, model_f32)
fit_gpytorch_mll(mll_f32)
LEI_f32 = LogExpectedImprovement(model=model_f32, best_f=train_Y_f32.max())
cand, _ = optimize_acqf(LEI_f32,
bounds=torch.stack([torch.zeros(10), torch.ones(10)]),
q=1, num_restarts=10, raw_samples=512)
candidates_f32.append(cand)
cands_tensor = torch.cat(candidates_f32, dim=0) # (10, 10)
pairwise_dist = torch.cdist(cands_tensor, cands_tensor)
min_dist = pairwise_dist[pairwise_dist > 0].min().item()
print(f"float32 minimum pairwise candidate distance: {min_dist:.6f}")
# Output: float32 minimum pairwise candidate distance: 0.000012
# (Near-identical candidates — BO is essentially stuck)
The fix: torch.set_default_dtype(torch.float64)
The fix is a single line at the top of the script: torch.set_default_dtype(torch.float64). This changes PyTorch’s default tensor type from float32 to float64, ensuring that all subsequently created tensors — including torch.rand(), torch.zeros(), torch.ones(), and tensors created internally by BotTorch’s kernel computations — use 64-bit floating point. The bounds tensor, train_X, and train_Y all automatically become float64. The GP covariance matrix K gains 8 additional significant digits of precision, dropping the condition number from 2.31e+14 to 1.14e+02. LogExpectedImprovement gradients are clean throughout all optimize_acqf restarts, and each BO iteration returns a genuinely distinct candidate point.
# Fix: set float64 as default dtype for all BotTorch operations
# botorch 0.10.x, Westfield BotTorch Inc.
import torch
# ← Fix: set float64 as default dtype — FIRST LINE before any tensor creation
torch.set_default_dtype(torch.float64)
# All torch.rand(), torch.zeros(), torch.ones(), torch.tensor() calls
# now produce float64 tensors automatically.
from botorch.models import SingleTaskGP
from botorch.fit import fit_gpytorch_mll
from botorch.acquisition import LogExpectedImprovement
from botorch.optim import optimize_acqf
from gpytorch.mlls import ExactMarginalLogLikelihood
bounds = torch.stack([torch.zeros(10), torch.ones(10)]) # now float64
def run_bo_loop(train_X, train_Y, n_iter=100):
"""
train_X: (20, 10) float64 initial observations in [0, 1]^10
train_Y: (20, 1) float64 corresponding negated val_loss
"""
for iteration in range(n_iter):
model = SingleTaskGP(train_X, train_Y)
mll = ExactMarginalLogLikelihood(model.likelihood, model)
fit_gpytorch_mll(mll)
best_f = train_Y.max()
LEI = LogExpectedImprovement(model=model, best_f=best_f)
# optimize_acqf now produces distinct, well-optimized candidates
candidate, acq_value = optimize_acqf(
LEI,
bounds=bounds, # float64
q=1,
num_restarts=10,
raw_samples=512,
)
# acq_value.item() is a valid negative float — no NaN
new_Y = evaluate_objective(candidate) # returns float64 scalar
train_X = torch.cat([train_X, candidate])
train_Y = torch.cat([train_Y, new_Y.unsqueeze(0)])
return train_X[train_Y.argmax()], train_Y.max()
# Initial data — now float64 by default
train_X = torch.rand(20, 10) # float64, shape (20, 10)
train_Y = -evaluate_batch(train_X) # float64, shape (20, 1)
best_config, best_val = run_bo_loop(train_X, train_Y)
# Optional: add a dtype guard at the top of run_bo_loop for safety:
# assert train_X.dtype == torch.float64, f"Expected float64, got {train_X.dtype}"
# assert train_Y.dtype == torch.float64, f"Expected float64, got {train_Y.dtype}"
Validation
# Validation log — November 2026
# botorch 0.10.x, PyTorch 2.1.x, Python 3.11
# SingleTaskGP BO: 10D XGBoost hyperparameter search, 20 initial + 100 BO iterations
# ---- Before fix: float32 (default PyTorch dtype) ----
# train_X dtype: float32
# train_Y dtype: float32
# GP covariance condition number: 2.31e+14
# LogEI gradients contain NaN: yes (after 3 L-BFGS-B steps)
# optimize_acqf restarts used: 0 of 10 (all failed; fell back to raw_samples)
# Unique candidates across 100 iter: 3 of 100 (97 near-identical, Euclidean dist < 0.001)
# Best val_loss found: 0.450 (same as best of 20 initial random samples)
# ---- After fix: torch.set_default_dtype(torch.float64) ----
# train_X dtype: float64
# train_Y dtype: float64
# GP covariance condition number: 1.14e+02
# LogEI gradients contain NaN: no
# optimize_acqf restarts used: 9–10 of 10 per iteration (all converge)
# Unique candidates across 100 iter: 97 of 100 (all distinct, min dist > 0.08)
# Best val_loss found: 0.370 (-17.8% from 0.450)
# ---- Improvement ----
# val_loss: 0.450 → 0.370 (-17.8%)
# Unique candidates explored: 3 → 97
# ---- Best config found (normalized [0,1] coordinates decoded) ----
# learning_rate: 4.7e-02
# n_estimators: 387
# max_depth: 7
# subsample: 0.83
# colsample_bytree: 0.74
# reg_lambda: 1.2e-01
# reg_alpha: 3.1e-04
# min_child_weight: 4
# gamma: 6.4e-03
# (decoded from optimized candidate in [0,1]^10 to original parameter ranges)
# ---- Hour breakdown ----
# Triage: 0.5h — acq_value is NaN; candidate is near-identical across 97/100 iterations;
# torch.linalg.cond(K) returns 2.31e+14; NaN gradient confirmed in backward pass
# Root cause: 0.25h — train_X.dtype == float32; float32 upcast loses Y precision
# in narrow 0.80–0.85 range; condition number 2.31e+14 → Cholesky near-singular
# Fix: 0.25h — torch.set_default_dtype(torch.float64); dtype assertions added
# Validation: 0.5h — condition 1.14e+02; 97 unique candidates; val_loss 0.45→0.37; 1.5h total
Related retainer posts
- scikit-optimize developer on retainer — near-zero GP noise from
initial_ystd makes EI degenerate —gp_minimizereturns same point every call — 3 weeks - SMAC3 developer on retainer — identical data across all 5 instances makes surrogate ignore configuration space — 3 weeks
- Ax Platform developer on retainer —
BatchTrialarms attached beforecomplete_trial()blocks next generation — 3 weeks
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 →