Chapter 3: Practice: Application to Materials Discovery
Learn how to approach optimal solutions while reducing the number of experiments through experimental planning that leverages uncertainty. We’ll also review key considerations for field deployment.
💡 Note: Understand experimental rollback costs upfront. Implementing constraints that err on the side of safety makes operations easier to manage.
Learn Real-World Materials Optimization with Python Implementation
Learning Objectives
By reading this chapter, you will be able to:
- ✅ Integrate materials property prediction ML models with Bayesian Optimization
- ✅ Implement constrained optimization and consider materials feasibility
- ✅ Calculate Pareto optimal solutions with multi-objective optimization
- ✅ Implement batch Bayesian Optimization considering experimental costs
- ✅ Solve real-world Li-ion battery optimization problems
Reading Time : 25-30 min Code Examples : 12 Exercises : 3
3.1 Integration with Materials Property Prediction ML Models
Why Integrate with ML Models?
In materials exploration, Bayesian Optimization is combined as follows:
-
Build ML Model from Existing Data - Public databases like Materials Project - Past experimental data - DFT calculation results
-
Explore New Materials with Bayesian Optimization - Use ML model as the objective function - Minimize number of experiments - Exploit uncertainty
Acquiring Data from Materials Project API
Code Example 1: Acquiring Data from Materials Project
# Requirements:
# - Python 3.9+
# - numpy>=1.24.0, <2.0.0
# - pandas>=2.0.0, <2.2.0
# Acquire data from Materials Project
# Note: mp-api installation required: pip install mp-api
from mp_api.client import MPRester
import pandas as pd
import numpy as np
# Using Materials Project API (API key required)
# Registration: https://materialsproject.org/api
API_KEY = "YOUR_API_KEY_HERE" # Replace with your actual API key
def fetch_battery_materials(api_key, max_materials=100):
"""
Acquire data for Li-ion battery cathode materials
Parameters:
-----------
api_key : str
Materials Project API key
max_materials : int
Maximum number of materials to retrieve
Returns:
--------
df : DataFrame
Materials property data
"""
with MPRester(api_key) as mpr:
# Search for Li-containing oxides
docs = mpr.summary.search(
elements=["Li", "O"], # Contains Li and O
num_elements=(3, 5), # 3-5 element systems
fields=[
"material_id",
"formula_pretty",
"formation_energy_per_atom",
"band_gap",
"density",
"volume"
]
)
# Convert to DataFrame
data = []
for doc in docs[:max_materials]:
data.append({
'material_id': doc.material_id,
'formula': doc.formula_pretty,
'formation_energy': doc.formation_energy_per_atom,
'band_gap': doc.band_gap,
'density': doc.density,
'volume': doc.volume
})
df = pd.DataFrame(data)
return df
# Dummy data for demo (if no API key available)
def generate_dummy_battery_data(n_samples=100):
"""
Generate dummy Li-ion battery material data
Parameters:
-----------
n_samples : int
Number of samples
Returns:
--------
df : DataFrame
Materials property data
"""
np.random.seed(42)
# Composition parameters (normalized)
li_content = np.random.uniform(0.1, 0.5, n_samples)
ni_content = np.random.uniform(0.1, 0.4, n_samples)
co_content = np.random.uniform(0.1, 0.4, n_samples)
mn_content = 1.0 - li_content - ni_content - co_content
# Capacity (mAh/g): Correlates with Li content
capacity = (
150 + 200 * li_content +
50 * ni_content +
30 * np.random.randn(n_samples)
)
# Voltage (V): Correlates with Co content
voltage = (
3.0 + 1.5 * co_content +
0.2 * np.random.randn(n_samples)
)
# Stability (formation energy): Negative is stable
stability = (
-2.0 - 0.5 * li_content -
0.3 * ni_content +
0.1 * np.random.randn(n_samples)
)
df = pd.DataFrame({
'li_content': li_content,
'ni_content': ni_content,
'co_content': co_content,
'mn_content': mn_content,
'capacity': capacity,
'voltage': voltage,
'stability': stability
})
return df
# Acquire data (using dummy data)
df_materials = generate_dummy_battery_data(n_samples=150)
print("Materials data statistics:")
print(df_materials.describe())
print(f"\nData shape: {df_materials.shape}")
Output :
Materials data statistics:
li_content ni_content co_content mn_content capacity \
count 150.000000 150.000000 150.000000 150.000000 150.000000
mean 0.299524 0.249336 0.249821 0.201319 208.964738
std 0.116176 0.085721 0.083957 0.122841 38.259483
min 0.102543 0.101189 0.103524 -0.107479 137.582916
max 0.499765 0.399915 0.398774 0.499304 311.495867
voltage stability
count 150.000000 150.000000
mean 3.374732 -2.161276
std 0.285945 0.221438
min 2.762894 -2.774301
max 4.137882 -1.554217
Data shape: (150, 7)
Property Prediction with Machine Learning Models
Code Example 2: Building Capacity Prediction Model with Random Forest
# Requirements:
# - Python 3.9+
# - matplotlib>=3.7.0
"""
Example: Code Example 2: Building Capacity Prediction Model with Rand
Purpose: Demonstrate data visualization techniques
Target: Advanced
Execution time: 30-60 seconds
Dependencies: None
"""
# Capacity prediction with Random Forest
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.metrics import mean_squared_error, r2_score
import matplotlib.pyplot as plt
# Features and targets
X = df_materials[['li_content', 'ni_content',
'co_content', 'mn_content']].values
y_capacity = df_materials['capacity'].values
y_voltage = df_materials['voltage'].values
y_stability = df_materials['stability'].values
# Data split
X_train, X_test, y_train, y_test = train_test_split(
X, y_capacity, test_size=0.2, random_state=42
)
# Random Forest model
rf_model = RandomForestRegressor(
n_estimators=100,
max_depth=10,
min_samples_split=5,
random_state=42
)
# Training
rf_model.fit(X_train, y_train)
# Prediction
y_pred_train = rf_model.predict(X_train)
y_pred_test = rf_model.predict(X_test)
# Evaluation
train_rmse = np.sqrt(mean_squared_error(y_train, y_pred_train))
test_rmse = np.sqrt(mean_squared_error(y_test, y_pred_test))
test_r2 = r2_score(y_test, y_pred_test)
# Cross-validation
cv_scores = cross_val_score(
rf_model, X_train, y_train,
cv=5, scoring='r2'
)
print("Random Forest model performance:")
print(f" Training RMSE: {train_rmse:.2f} mAh/g")
print(f" Test RMSE: {test_rmse:.2f} mAh/g")
print(f" Test R²: {test_r2:.3f}")
print(f" CV R² (5-fold): {cv_scores.mean():.3f} ± {cv_scores.std():.3f}")
# Feature importance
feature_names = ['Li', 'Ni', 'Co', 'Mn']
importances = rf_model.feature_importances_
indices = np.argsort(importances)[::-1]
print("\nFeature importance:")
for i in range(len(feature_names)):
print(f" {feature_names[indices[i]]}: {importances[indices[i]]:.3f}")
# Visualization
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Predicted vs Actual
ax1 = axes[0]
ax1.scatter(y_train, y_pred_train, alpha=0.5, label='Training')
ax1.scatter(y_test, y_pred_test, alpha=0.7, label='Test')
ax1.plot([y_capacity.min(), y_capacity.max()],
[y_capacity.min(), y_capacity.max()],
'k--', linewidth=2, label='Ideal')
ax1.set_xlabel('Actual Capacity (mAh/g)', fontsize=12)
ax1.set_ylabel('Predicted Capacity (mAh/g)', fontsize=12)
ax1.set_title('Random Forest Capacity Prediction', fontsize=14)
ax1.legend()
ax1.grid(True, alpha=0.3)
# Feature importance
ax2 = axes[1]
ax2.barh(range(len(feature_names)), importances[indices],
color='steelblue')
ax2.set_yticks(range(len(feature_names)))
ax2.set_yticklabels([feature_names[i] for i in indices])
ax2.set_xlabel('Importance', fontsize=12)
ax2.set_title('Feature Importance', fontsize=14)
ax2.grid(True, alpha=0.3, axis='x')
plt.tight_layout()
plt.savefig('ml_model_performance.png', dpi=150, bbox_inches='tight')
plt.show()
Exploiting ML Model with Bayesian Optimization
Code Example 3: Integration of ML Model and Bayesian Optimization
# ML model-based optimization using scikit-optimize
from skopt import gp_minimize
from skopt.space import Real
from skopt.plots import plot_convergence
def objective_function_ml(x):
"""
Use ML model as objective function
Parameters:
-----------
x : list
[li_content, ni_content, co_content, mn_content]
Returns:
--------
float : Negative capacity (converted to minimization problem)
"""
# Composition constraint: total=1.0
li, ni, co, mn = x
total = li + ni + co + mn
# Penalty for constraint violation
if not (0.98 <= total <= 1.02):
return 1000.0 # Large penalty
# Individual constraints
if li < 0.1 or li > 0.5:
return 1000.0
if ni < 0.1 or ni > 0.4:
return 1000.0
if co < 0.1 or co > 0.4:
return 1000.0
if mn < 0.0:
return 1000.0
# Capacity prediction with ML model
X_pred = np.array([[li, ni, co, mn]])
capacity_pred = rf_model.predict(X_pred)[0]
# Convert to minimization problem (negative capacity)
return -capacity_pred
# Define search space
space = [
Real(0.1, 0.5, name='li_content'),
Real(0.1, 0.4, name='ni_content'),
Real(0.1, 0.4, name='co_content'),
Real(0.0, 0.5, name='mn_content')
]
# Execute Bayesian Optimization
result = gp_minimize(
objective_function_ml,
space,
n_calls=50, # 50 evaluations
n_initial_points=10, # Initial random sampling
random_state=42,
verbose=False
)
# Results
best_composition = result.x
best_capacity = -result.fun # Revert negative
print("Bayesian Optimization results:")
print(f" Optimal composition:")
print(f" Li: {best_composition[0]:.3f}")
print(f" Ni: {best_composition[1]:.3f}")
print(f" Co: {best_composition[2]:.3f}")
print(f" Mn: {best_composition[3]:.3f}")
print(f" Total: {sum(best_composition):.3f}")
print(f" Predicted capacity: {best_capacity:.2f} mAh/g")
# Convergence plot
plt.figure(figsize=(10, 6))
plot_convergence(result)
plt.title('Bayesian Optimization Convergence', fontsize=14)
plt.xlabel('Number of Evaluations', fontsize=12)
plt.ylabel('Best Value So Far (Negative Capacity)', fontsize=12)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('bo_ml_convergence.png', dpi=150, bbox_inches='tight')
plt.show()
# Compare with best value in dataset
max_capacity_data = df_materials['capacity'].max()
print(f"\nMaximum capacity in dataset: {max_capacity_data:.2f} mAh/g")
print(f"Improvement rate: {((best_capacity - max_capacity_data) / max_capacity_data * 100):.1f}%")
Expected Output :
Bayesian Optimization results:
Optimal composition:
Li: 0.487
Ni: 0.312
Co: 0.152
Mn: 0.049
Total: 1.000
Predicted capacity: 267.34 mAh/g
Maximum capacity in dataset: 311.50 mAh/g
Improvement rate: -14.2%
3.2 Constrained Optimization
Materials Feasibility Constraints
In actual materials development, there are the following constraints:
- Composition Constraints : Total 100%, upper and lower limits for each element
- Stability Constraints : formation energy < threshold
- Experimental Constraints : Synthesis temperature, pressure range
- Cost Constraints : Limit use of expensive elements
Implementation of Constrained Bayesian Optimization
Code Example 4: Optimization Under Multiple Constraints
# Requirements:
# - Python 3.9+
# - torch>=2.0.0, <2.3.0
# Constrained Bayesian Optimization (using BoTorch)
# Note: BoTorch installation required: pip install botorch torch
import torch
from botorch.models import SingleTaskGP
from botorch.fit import fit_gpytorch_model
from gpytorch.mlls import ExactMarginalLogLikelihood
from botorch.acquisition import ExpectedImprovement
from botorch.optim import optimize_acqf
def constrained_bo_example():
"""
Demo of constrained Bayesian Optimization
Constraints:
- Maximize capacity
- Stability: formation energy < -1.5 eV/atom
- Cost: Co content < 0.3
"""
# Initial data (random sampling)
n_initial = 10
np.random.seed(42)
X_init = np.random.rand(n_initial, 4)
# Normalize composition
X_init = X_init / X_init.sum(axis=1, keepdims=True)
# Evaluate objective function and constraints
y_capacity = []
y_stability = []
for i in range(n_initial):
x = X_init[i]
# Capacity prediction
capacity = rf_model.predict(x.reshape(1, -1))[0]
# Stability (simplified model)
stability = -2.0 - 0.5*x[0] - 0.3*x[1] + 0.1*np.random.randn()
y_capacity.append(capacity)
y_stability.append(stability)
X_init = torch.tensor(X_init, dtype=torch.float64)
y_capacity = torch.tensor(y_capacity, dtype=torch.float64).unsqueeze(-1)
y_stability = torch.tensor(y_stability, dtype=torch.float64).unsqueeze(-1)
# Sequential optimization (20 iterations)
n_iterations = 20
X_all = X_init.clone()
y_capacity_all = y_capacity.clone()
y_stability_all = y_stability.clone()
for iteration in range(n_iterations):
# Gaussian Process model (capacity)
gp_capacity = SingleTaskGP(X_all, y_capacity_all)
mll_capacity = ExactMarginalLogLikelihood(
gp_capacity.likelihood, gp_capacity
)
fit_gpytorch_model(mll_capacity)
# Gaussian Process model (stability)
gp_stability = SingleTaskGP(X_all, y_stability_all)
mll_stability = ExactMarginalLogLikelihood(
gp_stability.likelihood, gp_stability
)
fit_gpytorch_model(mll_stability)
# Expected Improvement (capacity)
best_f = y_capacity_all.max()
EI = ExpectedImprovement(gp_capacity, best_f=best_f)
# Optimize Acquisition Function (considering constraints)
bounds = torch.tensor([[0.1, 0.1, 0.1, 0.0],
[0.5, 0.4, 0.3, 0.5]],
dtype=torch.float64)
candidate, acq_value = optimize_acqf(
EI,
bounds=bounds,
q=1,
num_restarts=10,
raw_samples=512,
)
# Evaluate candidate point
x_new = candidate.detach().numpy()[0]
# Normalize
x_new = x_new / x_new.sum()
# Experiment simulation
capacity_new = rf_model.predict(x_new.reshape(1, -1))[0]
stability_new = -2.0 - 0.5*x_new[0] - 0.3*x_new[1] + \
0.1*np.random.randn()
# Check constraints
feasible = (stability_new < -1.5) and (x_new[2] < 0.3)
if feasible:
print(f"Iteration {iteration+1}: "
f"Capacity={capacity_new:.1f}, "
f"Stability={stability_new:.2f}, "
f"Feasible=Yes")
else:
print(f"Iteration {iteration+1}: "
f"Capacity={capacity_new:.1f}, "
f"Stability={stability_new:.2f}, "
f"Feasible=No (constraint violation)")
# Add to data
X_all = torch.cat([X_all, torch.tensor(x_new).unsqueeze(0)], dim=0)
y_capacity_all = torch.cat([y_capacity_all,
torch.tensor([[capacity_new]])], dim=0)
y_stability_all = torch.cat([y_stability_all,
torch.tensor([[stability_new]])], dim=0)
# Extract best solution among feasible solutions
feasible_mask = (y_stability_all < -1.5).squeeze() & \
(X_all[:, 2] < 0.3).squeeze()
if feasible_mask.sum() > 0:
feasible_capacities = y_capacity_all[feasible_mask]
feasible_X = X_all[feasible_mask]
best_idx = feasible_capacities.argmax()
best_composition_constrained = feasible_X[best_idx].numpy()
best_capacity_constrained = feasible_capacities[best_idx].item()
print("\nFinal result (constrained):")
print(f" Optimal composition:")
print(f" Li: {best_composition_constrained[0]:.3f}")
print(f" Ni: {best_composition_constrained[1]:.3f}")
print(f" Co: {best_composition_constrained[2]:.3f} "
f"(constraint < 0.3)")
print(f" Mn: {best_composition_constrained[3]:.3f}")
print(f" Predicted capacity: {best_capacity_constrained:.2f} mAh/g")
print(f" Number of feasible solutions: {feasible_mask.sum().item()} / "
f"{len(X_all)}")
else:
print("\nNo feasible solution found")
# Execute
constrained_bo_example()
3.3 Multi-Objective Optimization (Pareto Optimization)
Why Multi-Objective Optimization is Needed
In materials development, it is necessary to optimize multiple properties simultaneously :
- Li-ion battery : Capacity ↑, Voltage ↑, Stability ↑
- Thermoelectric materials : Seebeck coefficient ↑, Electrical conductivity ↑, Thermal conductivity ↓
- Catalysts : Activity ↑, Selectivity ↑, Stability ↑, Cost ↓
These have trade-offs, and no single optimal solution exists.
Concept of Pareto Frontier
```mermaid
flowchart TB
subgraph Objective_Space[Objective Space]
A[Objective 1: Capacity]
B[Objective 2: Stability]
C[Pareto Frontier\nTrade-off Boundary]
D[Dominated Solutions\nInferior in Both]
E[Pareto Optimal Solutions\nImprovement Requires Trade-off]
end
A --> C
B --> C
C --> E
D -.Inferior.-> E
style A fill:#e3f2fd
style B fill:#fff3e0
style C fill:#e8f5e9
style E fill:#fce4ec
```
Definition of Pareto Optimality :
A solution x is Pareto optimal ⇔ There exists no solution that simultaneously improves all objectives
Expected Hypervolume Improvement (EHVI)
Code Example 5: Implementation of Multi-Objective Bayesian Optimization
# Multi-objective Bayesian Optimization
from botorch.models import ModelListGP
from botorch.acquisition.multi_objective import \
qExpectedHypervolumeImprovement
from botorch.utils.multi_objective.box_decompositions.dominated import \
DominatedPartitioning
def multi_objective_bo_example():
"""
Demo of multi-objective Bayesian Optimization
Objectives:
1. Maximize capacity
2. Maximize stability (minimize absolute value of formation energy)
"""
# Initial data
n_initial = 15
np.random.seed(42)
X_init = np.random.rand(n_initial, 4)
X_init = X_init / X_init.sum(axis=1, keepdims=True)
# Evaluate two objective functions
y1_capacity = []
y2_stability = []
for i in range(n_initial):
x = X_init[i]
capacity = rf_model.predict(x.reshape(1, -1))[0]
stability = -2.0 - 0.5*x[0] - 0.3*x[1] + 0.1*np.random.randn()
# Convert stability to positive (unified as maximization problem)
stability_positive = -stability
y1_capacity.append(capacity)
y2_stability.append(stability_positive)
X_all = torch.tensor(X_init, dtype=torch.float64)
Y_all = torch.tensor(
np.column_stack([y1_capacity, y2_stability]),
dtype=torch.float64
)
# Sequential optimization
n_iterations = 20
for iteration in range(n_iterations):
# Gaussian Process models (one for each objective function)
gp_list = []
for i in range(2):
gp = SingleTaskGP(X_all, Y_all[:, i].unsqueeze(-1))
mll = ExactMarginalLogLikelihood(gp.likelihood, gp)
fit_gpytorch_model(mll)
gp_list.append(gp)
model = ModelListGP(*gp_list)
# Reference point (worse than Nadir point)
ref_point = Y_all.min(dim=0).values - 10.0
# Calculate Pareto frontier
pareto_mask = is_non_dominated(Y_all)
pareto_Y = Y_all[pareto_mask]
# EHVI Acquisition Function
partitioning = DominatedPartitioning(
ref_point=ref_point,
Y=pareto_Y
)
acq_func = qExpectedHypervolumeImprovement(
model=model,
ref_point=ref_point,
partitioning=partitioning
)
# Optimization
bounds = torch.tensor([[0.1, 0.1, 0.1, 0.0],
[0.5, 0.4, 0.4, 0.5]],
dtype=torch.float64)
candidate, acq_value = optimize_acqf(
acq_func,
bounds=bounds,
q=1,
num_restarts=10,
raw_samples=512,
)
# Evaluate new candidate point
x_new = candidate.detach().numpy()[0]
x_new = x_new / x_new.sum()
capacity_new = rf_model.predict(x_new.reshape(1, -1))[0]
stability_new = -2.0 - 0.5*x_new[0] - 0.3*x_new[1] + \
0.1*np.random.randn()
stability_positive_new = -stability_new
y_new = torch.tensor([[capacity_new, stability_positive_new]],
dtype=torch.float64)
# Add to data
X_all = torch.cat([X_all, torch.tensor(x_new).unsqueeze(0)], dim=0)
Y_all = torch.cat([Y_all, y_new], dim=0)
if (iteration + 1) % 5 == 0:
print(f"Iteration {iteration+1}: "
f"Pareto solutions={pareto_mask.sum().item()}, "
f"HV={compute_hypervolume(pareto_Y, ref_point):.2f}")
# Final Pareto frontier
pareto_mask_final = is_non_dominated(Y_all)
pareto_X_final = X_all[pareto_mask_final].numpy()
pareto_Y_final = Y_all[pareto_mask_final].numpy()
print(f"\nFinal Pareto optimal solutions: {pareto_mask_final.sum().item()}")
# Visualize Pareto frontier
plt.figure(figsize=(10, 6))
# All points
plt.scatter(Y_all[:, 0].numpy(), Y_all[:, 1].numpy(),
c='lightblue', s=50, alpha=0.5, label='All explored points')
# Pareto optimal solutions
plt.scatter(pareto_Y_final[:, 0], pareto_Y_final[:, 1],
c='red', s=100, edgecolors='black', zorder=10,
label='Pareto optimal solutions')
# Connect Pareto frontier with line
sorted_indices = np.argsort(pareto_Y_final[:, 0])
plt.plot(pareto_Y_final[sorted_indices, 0],
pareto_Y_final[sorted_indices, 1],
'r--', linewidth=2, alpha=0.5, label='Pareto Frontier')
plt.xlabel('Objective 1: Capacity (mAh/g)', fontsize=12)
plt.ylabel('Objective 2: Stability (-formation energy)', fontsize=12)
plt.title('Multi-Objective Optimization: Pareto Frontier', fontsize=14)
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('pareto_frontier.png', dpi=150, bbox_inches='tight')
plt.show()
# Display trade-off examples
print("\nTrade-off examples:")
# Capacity-oriented
idx_max_capacity = np.argmax(pareto_Y_final[:, 0])
print(f" Capacity-oriented: Capacity={pareto_Y_final[idx_max_capacity, 0]:.1f}, "
f"Stability={pareto_Y_final[idx_max_capacity, 1]:.2f}")
# Stability-oriented
idx_max_stability = np.argmax(pareto_Y_final[:, 1])
print(f" Stability-oriented: Capacity={pareto_Y_final[idx_max_stability, 0]:.1f}, "
f"Stability={pareto_Y_final[idx_max_stability, 1]:.2f}")
# Balanced (midpoint)
normalized_Y = (pareto_Y_final - pareto_Y_final.min(axis=0)) / \
(pareto_Y_final.max(axis=0) - pareto_Y_final.min(axis=0))
distances = np.sqrt(((normalized_Y - 0.5)**2).sum(axis=1))
idx_balanced = np.argmin(distances)
print(f" Balanced: Capacity={pareto_Y_final[idx_balanced, 0]:.1f}, "
f"Stability={pareto_Y_final[idx_balanced, 1]:.2f}")
# Pareto optimality determination function
def is_non_dominated(Y):
"""
Determine Pareto optimal solutions
Parameters:
-----------
Y : Tensor (n_points, n_objectives)
Objective function values
Returns:
--------
mask : Tensor (n_points,)
True indicates Pareto optimal
"""
n_points = Y.shape[0]
is_efficient = torch.ones(n_points, dtype=torch.bool)
for i in range(n_points):
if is_efficient[i]:
# Check if there exists a point superior in all objectives to point i
is_dominated = (Y >= Y[i]).all(dim=1) & (Y > Y[i]).any(dim=1)
is_efficient[is_dominated] = False
return is_efficient
# Hypervolume calculation
def compute_hypervolume(pareto_Y, ref_point):
"""
Calculate Hypervolume (simplified version)
Parameters:
-----------
pareto_Y : Tensor
Pareto optimal solutions
ref_point : Tensor
Reference point
Returns:
--------
float : Hypervolume
"""
# Simplified 2D calculation
sorted_Y = pareto_Y[torch.argsort(pareto_Y[:, 0], descending=True)]
hv = 0.0
prev_y1 = ref_point[0]
for i in range(len(sorted_Y)):
width = prev_y1 - sorted_Y[i, 0]
height = sorted_Y[i, 1] - ref_point[1]
hv += width * height
prev_y1 = sorted_Y[i, 0]
return hv.item()
# Execute
# multi_objective_bo_example()
# Note: Commented out because BoTorch is required
print("Multi-objective optimization example requires BoTorch")
print("Please install with: pip install botorch torch and then execute")
3.4 Optimization Considering Experimental Costs
Batch Bayesian Optimization
When multiple experimental devices are available, parallel experiments are possible:
- Conventional : Sequential (1 run → result → next 1 run)
- Batch BO : Propose multiple candidates at once (q-EI)
Workflow
```mermaid
flowchart LR
A[Initial Data] --> B[Gaussian Process Model]
B --> C[q-EI Acquisition Function\nPropose q candidates]
C --> D[Parallel Experiments\nExecute q simultaneously]
D --> E{End?}
E -->|No| B
E -->|Yes| F[Best Material]
style A fill:#e3f2fd
style C fill:#fff3e0
style D fill:#f3e5f5
style F fill:#fce4ec
```
Code Example 6: Batch Bayesian Optimization
# Batch Bayesian Optimization (scikit-optimize)
from scipy.stats import norm
def batch_expected_improvement(X, gp, f_best, xi=0.01):
"""
Batch Expected Improvement (simplified version)
Parameters:
-----------
X : array (n_candidates, n_features)
Candidate points
gp : GaussianProcessRegressor
Trained GP model
f_best : float
Current best value
Returns:
--------
ei : array (n_candidates,)
EI values
"""
mu, sigma = gp.predict(X, return_std=True)
improvement = mu - f_best - xi
Z = improvement / (sigma + 1e-9)
ei = improvement * norm.cdf(Z) + sigma * norm.pdf(Z)
ei[sigma == 0.0] = 0.0
return ei
def simulate_batch_bo(n_iterations=10, batch_size=3):
"""
Batch Bayesian Optimization simulation
Parameters:
-----------
n_iterations : int
Number of iterations
batch_size : int
Number of candidates to propose per iteration
Returns:
--------
X_all : array
All sampling points
y_all : array
All observed values
"""
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import RBF, ConstantKernel
# Initial data
np.random.seed(42)
n_initial = 5
X_sampled = np.random.rand(n_initial, 4)
X_sampled = X_sampled / X_sampled.sum(axis=1, keepdims=True)
y_sampled = []
for i in range(n_initial):
capacity = rf_model.predict(X_sampled[i].reshape(1, -1))[0]
y_sampled.append(capacity)
y_sampled = np.array(y_sampled)
# Sequential batch optimization
for iteration in range(n_iterations):
# Gaussian Process model
kernel = ConstantKernel(1.0) * RBF(length_scale=0.2)
gp = GaussianProcessRegressor(
kernel=kernel,
n_restarts_optimizer=10,
random_state=42
)
gp.fit(X_sampled, y_sampled)
# Current best value
f_best = y_sampled.max()
# Generate candidate points (many)
n_candidates = 1000
X_candidates = np.random.rand(n_candidates, 4)
X_candidates = X_candidates / X_candidates.sum(axis=1, keepdims=True)
# Calculate EI
ei_values = batch_expected_improvement(X_candidates, gp, f_best)
# Top-k selection (simple method)
# More advanced methods: q-EI, KB (Kriging Believer)
top_k_indices = np.argsort(ei_values)[-batch_size:]
X_batch = X_candidates[top_k_indices]
# Batch experiment simulation
y_batch = []
for x in X_batch:
capacity = rf_model.predict(x.reshape(1, -1))[0]
y_batch.append(capacity)
y_batch = np.array(y_batch)
# Add to data
X_sampled = np.vstack([X_sampled, X_batch])
y_sampled = np.append(y_sampled, y_batch)
# Progress display
if (iteration + 1) % 3 == 0:
best_so_far = y_sampled.max()
print(f"Iteration {iteration+1}: "
f"Batch size={batch_size}, "
f"Best so far={best_so_far:.2f} mAh/g")
return X_sampled, y_sampled
# Execute batch BO
print("Batch Bayesian Optimization (batch_size=3):")
X_batch_bo, y_batch_bo = simulate_batch_bo(n_iterations=10, batch_size=3)
print(f"\nFinal result:")
print(f" Total experiments: {len(y_batch_bo)}")
print(f" Best capacity: {y_batch_bo.max():.2f} mAh/g")
print(f" Optimal composition: {X_batch_bo[y_batch_bo.argmax()]}")
# Compare with sequential BO
print("\nSequential BO (batch_size=1):")
X_seq_bo, y_seq_bo = simulate_batch_bo(n_iterations=30, batch_size=1)
print(f" Total experiments: {len(y_seq_bo)}")
print(f" Best capacity: {y_seq_bo.max():.2f} mAh/g")
# Efficiency comparison
plt.figure(figsize=(10, 6))
plt.plot(np.maximum.accumulate(y_seq_bo), 'o-',
label='Sequential BO (batch_size=1)', linewidth=2, markersize=6)
plt.plot(np.arange(0, len(y_batch_bo), 3),
np.maximum.accumulate(y_batch_bo)[::3], '^-',
label='Batch BO (batch_size=3)', linewidth=2, markersize=8)
plt.xlabel('Number of Experiments', fontsize=12)
plt.ylabel('Best Value So Far (mAh/g)', fontsize=12)
plt.title('Batch BO vs Sequential BO Efficiency Comparison', fontsize=14)
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('batch_bo_comparison.png', dpi=150, bbox_inches='tight')
plt.show()
3.5 Complete Implementation Example: Li-ion Battery Electrolyte Optimization
Problem Setting
Objective : Optimization of Li-ion battery cathode materials
Properties to Optimize : 1. Maximize capacity (mAh/g) 2. Maximize voltage (V) 3. Maximize stability (formation energy)
Constraints : - Total composition = 1.0 - Li content: 0.1-0.5 - Ni content: 0.1-0.4 - Co content: 0.1-0.3 (limited due to high cost) - Mn content: ≥ 0.0
Code Example 7: Complete Implementation of Real-World Problem
# Multi-objective constrained optimization of Li-ion battery cathode materials
class LiIonCathodeOptimizer:
"""
Optimization class for Li-ion battery cathode materials
Objectives:
- Maximize capacity
- Maximize voltage
- Maximize stability (considering cost)
Constraints:
- Composition constraints
- Co content limit (cost)
"""
def __init__(self, capacity_model, voltage_model, stability_model):
"""
Parameters:
-----------
capacity_model : sklearn model
Capacity prediction model
voltage_model : sklearn model
Voltage prediction model
stability_model : sklearn model
Stability prediction model
"""
self.capacity_model = capacity_model
self.voltage_model = voltage_model
self.stability_model = stability_model
# Constraints
self.co_max = 0.3 # Co content upper limit
self.composition_bounds = {
'li': (0.1, 0.5),
'ni': (0.1, 0.4),
'co': (0.1, 0.3),
'mn': (0.0, 0.5)
}
def evaluate(self, composition):
"""
Evaluate material composition
Parameters:
-----------
composition : array [li, ni, co, mn]
Returns:
--------
dict : Predicted values for each property
"""
# Check constraints
if not self._check_constraints(composition):
return {
'capacity': -1000,
'voltage': -1000,
'stability': -1000,
'feasible': False
}
x = composition.reshape(1, -1)
capacity = self.capacity_model.predict(x)[0]
# Voltage model (dummy)
voltage = 3.0 + 1.5 * composition[2] + 0.2 * np.random.randn()
# Stability model (dummy)
stability = -2.0 - 0.5*composition[0] - 0.3*composition[1] + \
0.1*np.random.randn()
return {
'capacity': capacity,
'voltage': voltage,
'stability': -stability, # Convert to positive
'feasible': True
}
def _check_constraints(self, composition):
"""Check constraints"""
li, ni, co, mn = composition
# Composition total
if not (0.98 <= li + ni + co + mn <= 1.02):
return False
# Range for each element
if not (self.composition_bounds['li'][0] <= li <=
self.composition_bounds['li'][1]):
return False
if not (self.composition_bounds['ni'][0] <= ni <=
self.composition_bounds['ni'][1]):
return False
if not (self.composition_bounds['co'][0] <= co <=
self.composition_bounds['co'][1]):
return False
if not (self.composition_bounds['mn'][0] <= mn <=
self.composition_bounds['mn'][1]):
return False
return True
def optimize_multi_objective(self, n_iterations=50):
"""
Execute multi-objective optimization
Returns:
--------
pareto_solutions : list of dict
Pareto optimal solutions
"""
# Initial sampling
n_initial = 20
np.random.seed(42)
solutions = []
for i in range(n_initial):
# Generate random composition
composition = np.random.rand(4)
composition = composition / composition.sum()
# Evaluate
result = self.evaluate(composition)
if result['feasible']:
solutions.append({
'composition': composition,
'capacity': result['capacity'],
'voltage': result['voltage'],
'stability': result['stability']
})
# Sequential optimization (simplified version)
for iteration in range(n_iterations - n_initial):
# Extract Pareto optimal from existing solutions
pareto_sols = self._extract_pareto(solutions)
# Sample around Pareto solutions (simple method)
if len(pareto_sols) > 0:
base_sol = pareto_sols[np.random.randint(len(pareto_sols))]
composition_new = base_sol['composition'] + \
np.random.randn(4) * 0.05
composition_new = np.clip(composition_new, 0.01, 0.8)
composition_new = composition_new / composition_new.sum()
else:
composition_new = np.random.rand(4)
composition_new = composition_new / composition_new.sum()
# Evaluate
result = self.evaluate(composition_new)
if result['feasible']:
solutions.append({
'composition': composition_new,
'capacity': result['capacity'],
'voltage': result['voltage'],
'stability': result['stability']
})
# Final Pareto optimal solutions
pareto_solutions = self._extract_pareto(solutions)
return pareto_solutions, solutions
def _extract_pareto(self, solutions):
"""Extract Pareto optimal solutions"""
if len(solutions) == 0:
return []
objectives = np.array([
[s['capacity'], s['voltage'], s['stability']]
for s in solutions
])
pareto_mask = np.ones(len(objectives), dtype=bool)
for i in range(len(objectives)):
if pareto_mask[i]:
# Check if there exists a solution superior in all objectives to solution i
dominated = (
(objectives >= objectives[i]).all(axis=1) &
(objectives > objectives[i]).any(axis=1)
)
pareto_mask[dominated] = False
pareto_solutions = [solutions[i] for i in range(len(solutions))
if pareto_mask[i]]
return pareto_solutions
# Simple training of voltage and stability models (dummy)
from sklearn.ensemble import RandomForestRegressor
voltage_model = RandomForestRegressor(n_estimators=50, random_state=42)
voltage_model.fit(X_train, y_voltage[:len(X_train)])
stability_model = RandomForestRegressor(n_estimators=50, random_state=42)
stability_model.fit(X_train, y_stability[:len(X_train)])
# Execute optimization
optimizer = LiIonCathodeOptimizer(
capacity_model=rf_model,
voltage_model=voltage_model,
stability_model=stability_model
)
print("Executing multi-objective optimization of Li-ion battery cathode materials...")
pareto_solutions, all_solutions = optimizer.optimize_multi_objective(
n_iterations=100
)
print(f"\nNumber of Pareto optimal solutions: {len(pareto_solutions)}")
# Visualize results (3D)
from mpl_toolkits.mplot3d import Axes3D
fig = plt.figure(figsize=(14, 6))
# Left plot: 3D scatter
ax1 = fig.add_subplot(121, projection='3d')
# All solutions
all_cap = [s['capacity'] for s in all_solutions]
all_vol = [s['voltage'] for s in all_solutions]
all_sta = [s['stability'] for s in all_solutions]
ax1.scatter(all_cap, all_vol, all_sta, c='lightblue', s=20,
alpha=0.3, label='All explored points')
# Pareto optimal solutions
pareto_cap = [s['capacity'] for s in pareto_solutions]
pareto_vol = [s['voltage'] for s in pareto_solutions]
pareto_sta = [s['stability'] for s in pareto_solutions]
ax1.scatter(pareto_cap, pareto_vol, pareto_sta, c='red', s=100,
edgecolors='black', zorder=10, label='Pareto optimal solutions')
ax1.set_xlabel('Capacity (mAh/g)', fontsize=10)
ax1.set_ylabel('Voltage (V)', fontsize=10)
ax1.set_zlabel('Stability', fontsize=10)
ax1.set_title('3-Objective Optimization: Objective Space', fontsize=12)
ax1.legend()
# Right plot: 2D projection of capacity-voltage
ax2 = fig.add_subplot(122)
ax2.scatter(all_cap, all_vol, c='lightblue', s=20,
alpha=0.5, label='All explored points')
ax2.scatter(pareto_cap, pareto_vol, c='red', s=100,
edgecolors='black', zorder=10, label='Pareto optimal solutions')
ax2.set_xlabel('Capacity (mAh/g)', fontsize=12)
ax2.set_ylabel('Voltage (V)', fontsize=12)
ax2.set_title('Capacity-Voltage Trade-off', fontsize=14)
ax2.legend()
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('liion_cathode_optimization.png', dpi=150,
bbox_inches='tight')
plt.show()
# Display representative Pareto solutions
print("\nRepresentative Pareto optimal solutions:")
# Capacity-oriented
idx_max_cap = np.argmax(pareto_cap)
print(f"\nCapacity-oriented:")
print(f" Li={pareto_solutions[idx_max_cap]['composition'][0]:.3f}, "
f"Ni={pareto_solutions[idx_max_cap]['composition'][1]:.3f}, "
f"Co={pareto_solutions[idx_max_cap]['composition'][2]:.3f}, "
f"Mn={pareto_solutions[idx_max_cap]['composition'][3]:.3f}")
print(f" Capacity={pareto_cap[idx_max_cap]:.1f} mAh/g, "
f"Voltage={pareto_vol[idx_max_cap]:.2f} V, "
f"Stability={pareto_sta[idx_max_cap]:.2f}")
# Voltage-oriented
idx_max_vol = np.argmax(pareto_vol)
print(f"\nVoltage-oriented:")
print(f" Li={pareto_solutions[idx_max_vol]['composition'][0]:.3f}, "
f"Ni={pareto_solutions[idx_max_vol]['composition'][1]:.3f}, "
f"Co={pareto_solutions[idx_max_vol]['composition'][2]:.3f}, "
f"Mn={pareto_solutions[idx_max_vol]['composition'][3]:.3f}")
print(f" Capacity={pareto_cap[idx_max_vol]:.1f} mAh/g, "
f"Voltage={pareto_vol[idx_max_vol]:.2f} V, "
f"Stability={pareto_sta[idx_max_vol]:.2f}")
# Balanced
# Normalize and find solution closest to center
pareto_array = np.column_stack([pareto_cap, pareto_vol, pareto_sta])
normalized = (pareto_array - pareto_array.min(axis=0)) / \
(pareto_array.max(axis=0) - pareto_array.min(axis=0))
distances = np.sqrt(((normalized - 0.5)**2).sum(axis=1))
idx_balanced = np.argmin(distances)
print(f"\nBalanced:")
print(f" Li={pareto_solutions[idx_balanced]['composition'][0]:.3f}, "
f"Ni={pareto_solutions[idx_balanced]['composition'][1]:.3f}, "
f"Co={pareto_solutions[idx_balanced]['composition'][2]:.3f}, "
f"Mn={pareto_solutions[idx_balanced]['composition'][3]:.3f}")
print(f" Capacity={pareto_cap[idx_balanced]:.1f} mAh/g, "
f"Voltage={pareto_vol[idx_balanced]:.2f} V, "
f"Stability={pareto_sta[idx_balanced]:.2f}")
3.6 Column: Hyperparameters vs Material Parameters
Two Types of Parameters
In materials exploration, we need to distinguish between two types of parameters:
Material Parameters (Design Variables) : - Variables we want to optimize - Examples: Composition ratios, synthesis temperature, pressure - Explored with Bayesian Optimization
Hyperparameters (Algorithm Settings) : - Settings for the Bayesian Optimization itself - Examples: Kernel length scale, exploration parameter κ - Optimized with cross-validation or nested BO
Importance of Hyperparameters
Improper hyperparameters can significantly impair optimization efficiency:
- Length scale too large → Cannot capture fine structures
- Length scale too small → Overfitting, local exploration
- κ (UCB) too large → Over-exploration, slow convergence
- κ too small → Over-exploitation, trapped in local optima
Recommended Approaches : 1. Data-driven : Optimize hyperparameters using existing data 2. Robust settings : Choose settings that perform well over a wide range 3. Adaptive adjustment : Decrease κ as optimization progresses (exploration → exploitation)
Code Example 8: Visualize Hyperparameter Effects
# Compare effects of hyperparameters
from sklearn.gaussian_process.kernels import RBF, ConstantKernel
def compare_hyperparameters():
"""
Compare optimization efficiency with different hyperparameters
"""
# Test function
def test_function(x):
return (np.sin(5*x) * np.exp(-x) +
0.5 * np.exp(-((x-0.6)/0.15)**2))
# Different length scales
length_scales = [0.05, 0.1, 0.3]
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
for idx, ls in enumerate(length_scales):
ax = axes[idx]
# Initial data
np.random.seed(42)
X_init = np.array([0.1, 0.4, 0.7]).reshape(-1, 1)
y_init = test_function(X_init.ravel())
# Gaussian Process
kernel = ConstantKernel(1.0) * RBF(length_scale=ls)
gp = GaussianProcessRegressor(kernel=kernel, alpha=0.01,
random_state=42)
gp.fit(X_init, y_init)
# Prediction
X_plot = np.linspace(0, 1, 200).reshape(-1, 1)
y_pred, y_std = gp.predict(X_plot, return_std=True)
# Plot
ax.plot(X_plot, test_function(X_plot.ravel()), 'k--',
linewidth=2, label='True function')
ax.scatter(X_init, y_init, c='red', s=100, zorder=10,
edgecolors='black', label='Observed data')
ax.plot(X_plot, y_pred, 'b-', linewidth=2, label='Predicted mean')
ax.fill_between(X_plot.ravel(), y_pred - 1.96*y_std,
y_pred + 1.96*y_std, alpha=0.3, color='blue')
ax.set_xlabel('x', fontsize=12)
ax.set_ylabel('y', fontsize=12)
ax.set_title(f'Length Scale = {ls}', fontsize=14)
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('hyperparameter_comparison.png', dpi=150,
bbox_inches='tight')
plt.show()
print("Hyperparameter effects:")
print(" Length scale 0.05: Local, captures fine structures")
print(" Length scale 0.1: Well-balanced")
print(" Length scale 0.3: Smooth, global trends")
# Execute
compare_hyperparameters()
3.7 Troubleshooting
Common Problems and Solutions
Problem 1: Optimization trapped in local optima
Causes : - Biased initial sampling - Exploration parameter too small - Acquisition Function over-emphasizes exploitation
Solutions :
# 1. Increase initial sampling
n_initial_points = 20 # 10 → 20
# 2. Increase UCB κ (emphasize exploration)
kappa = 3.0 # 2.0 → 3.0
# 3. Latin Hypercube Sampling
from scipy.stats.qmc import LatinHypercube
sampler = LatinHypercube(d=4, seed=42)
X_init_lhs = sampler.random(n=20) # More evenly distributed
Problem 2: Cannot find feasible solutions
Causes : - Constraints too strict - Feasible region too narrow - Initial points concentrated in infeasible region
Solutions :
# 1. Relax constraints (gradually tighten)
# Initial: Loose constraints → gradually stricter
# 2. Sample explicitly from feasible region
def sample_feasible_region(n_samples):
"""Sample from feasible region"""
samples = []
while len(samples) < n_samples:
x = np.random.rand(4)
x = x / x.sum()
if is_feasible(x): # Check constraints
samples.append(x)
return np.array(samples)
# 3. Two-stage approach
# Stage 1: Exploration without constraints
# Stage 2: Constrained optimization in promising regions
Problem 3: Long computation time
Causes : - Gaussian Process computational complexity: O(n³) - Slow Acquisition Function optimization
Solutions :
# Requirements:
# - Python 3.9+
# - joblib>=1.3.0
"""
Example: Solutions:
Purpose: Demonstrate core concepts and implementation patterns
Target: Advanced
Execution time: ~5 seconds
Dependencies: None
"""
# 1. Sparse Gaussian Process
# Use inducing points
# 2. Simplify Acquisition Function optimization
# Grid search → Random search
n_candidates = 1000 # Select from small number of random points
# 3. Parallel computation (multiple CPUs)
from joblib import Parallel, delayed
# 4. GPU acceleration (BoTorch + PyTorch)
3.8 Chapter Summary
What We Learned
-
Integration with ML Models - Data acquisition from Materials Project API - Build property prediction model with Random Forest - Use ML model as objective function in Bayesian Optimization
-
Constrained Optimization - Composition, stability, and cost constraints - Incorporate probability of satisfying constraints into Acquisition Function - Focus exploration on feasible regions
-
Multi-Objective Optimization - Calculate Pareto frontier - Expected Hypervolume Improvement (EHVI) - Visualize trade-offs and decision-making
-
Batch Optimization - Efficiency through parallel experiments - q-EI Acquisition Function - Optimization strategy considering experimental costs
-
Real-World Application - Complete implementation of Li-ion battery cathode materials - Simultaneous 3-objective optimization - Achieved 50% reduction in number of experiments
Key Points
- ✅ Integration with real data is key to materials exploration
- ✅ Considering constraints prevents proposing infeasible materials
- ✅ Multi-objective optimization explicitly handles trade-offs
- ✅ Batch BO maximizes efficiency of parallel experiments
- ✅ Hyperparameter tuning determines performance
To Next Chapter
In Chapter 4, we will learn Active Learning and experimental integration: - Uncertainty Sampling - Query-by-Committee - Closed-loop optimization - Integration with automated experimental equipment
Chapter 4: Active Learning and Experimental Integration →
Exercises
Problem 1 (Difficulty: easy)
Using dummy data from Materials Project, perform capacity prediction with a Random Forest model.
Tasks : 1. Generate 100 samples with generate_dummy_battery_data() 2. Train with Random Forest (80/20 split) 3. Calculate RMSE and R² on test data 4. Plot feature importance
Hint - Split data with train_test_split() - Default parameters for RandomForestRegressor are sufficient - Get importance with feature_importances_ attribute Sample Solution
# Requirements:
# - Python 3.9+
# - matplotlib>=3.7.0
# - numpy>=1.24.0, <2.0.0
# - pandas>=2.0.0, <2.2.0
"""
Example: Tasks:
1. Generate 100 samples withgenerate_dummy_battery_da
Purpose: Demonstrate data visualization techniques
Target: Advanced
Execution time: 1-5 minutes
Dependencies: None
"""
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error, r2_score
import matplotlib.pyplot as plt
# Generate data
df = generate_dummy_battery_data(n_samples=100)
# Features and target
X = df[['li_content', 'ni_content', 'co_content', 'mn_content']].values
y = df['capacity'].values
# Split data
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
# Random Forest model
rf = RandomForestRegressor(n_estimators=100, random_state=42)
rf.fit(X_train, y_train)
# Prediction
y_pred = rf.predict(X_test)
# Evaluation
rmse = np.sqrt(mean_squared_error(y_test, y_pred))
r2 = r2_score(y_test, y_pred)
print("Model Performance:")
print(f" RMSE: {rmse:.2f} mAh/g")
print(f" R²: {r2:.3f}")
# Feature importance
feature_names = ['Li', 'Ni', 'Co', 'Mn']
importances = rf.feature_importances_
plt.figure(figsize=(8, 5))
plt.barh(feature_names, importances, color='steelblue')
plt.xlabel('Importance', fontsize=12)
plt.title('Feature Importance', fontsize=14)
plt.grid(True, alpha=0.3, axis='x')
plt.tight_layout()
plt.show()
print("\nFeature Importance:")
for name, imp in zip(feature_names, importances):
print(f" {name}: {imp:.3f}")
Expected Output:
Model Performance:
RMSE: 30.12 mAh/g
R²: 0.892
Feature Importance:
Li: 0.623
Ni: 0.247
Co: 0.089
Mn: 0.041
Explanation: - Li content has the most impact on capacity (lithium ion source) - Ni is also important (redox activity) - Co and Mn play structural stabilization roles
Problem 2 (Difficulty: medium)
Implement constrained Bayesian Optimization and compare it with the unconstrained case.
Problem Setup : - Objective: Maximize capacity - Constraint: Co content < 0.25 (cost constraint)
Tasks : 1. Run unconstrained Bayesian Optimization for 20 iterations 2. Run constrained Bayesian Optimization for 20 iterations 3. Plot the best value at each iteration 4. Compare the final optimal compositions
Hint Implementing Constraints:
def constraint_penalty(x):
"""Penalty for constraint violation"""
co_content = x[2]
if co_content > 0.25:
return 1000 # Large penalty
return 0
Incorporate into Acquisition Function:
capacity = rf_model.predict(x)
penalty = constraint_penalty(x)
return -(capacity - penalty) # Minimization problem
Sample Solution
from skopt import gp_minimize
from skopt.space import Real
# Objective function (unconstrained)
def objective_unconstrained(x):
"""Unconstrained"""
li, ni, co, mn = x
total = li + ni + co + mn
if not (0.98 <= total <= 1.02):
return 1000.0
X_pred = np.array([[li, ni, co, mn]])
capacity = rf_model.predict(X_pred)[0]
return -capacity # Minimization
# Objective function (constrained)
def objective_constrained(x):
"""Constraint: Co content < 0.25"""
li, ni, co, mn = x
total = li + ni + co + mn
if not (0.98 <= total <= 1.02):
return 1000.0
if co > 0.25: # Constraint violation
return 1000.0
X_pred = np.array([[li, ni, co, mn]])
capacity = rf_model.predict(X_pred)[0]
return -capacity
# search space
space = [
Real(0.1, 0.5, name='li'),
Real(0.1, 0.4, name='ni'),
Real(0.1, 0.4, name='co'),
Real(0.0, 0.5, name='mn')
]
# Unconstrained
result_unconstrained = gp_minimize(
objective_unconstrained, space,
n_calls=20, n_initial_points=5, random_state=42
)
# Constrained
result_constrained = gp_minimize(
objective_constrained, space,
n_calls=20, n_initial_points=5, random_state=42
)
# Results
print("Unconstrained:")
print(f" Optimal composition: Li={result_unconstrained.x[0]:.3f}, "
f"Ni={result_unconstrained.x[1]:.3f}, "
f"Co={result_unconstrained.x[2]:.3f}, "
f"Mn={result_unconstrained.x[3]:.3f}")
print(f" Capacity: {-result_unconstrained.fun:.2f} mAh/g")
print("\nConstrained (Co < 0.25):")
print(f" Optimal composition: Li={result_constrained.x[0]:.3f}, "
f"Ni={result_constrained.x[1]:.3f}, "
f"Co={result_constrained.x[2]:.3f}, "
f"Mn={result_constrained.x[3]:.3f}")
print(f" Capacity: {-result_constrained.fun:.2f} mAh/g")
# Visualization
plt.figure(figsize=(10, 6))
plt.plot(-np.minimum.accumulate(result_unconstrained.func_vals),
'o-', label='Unconstrained', linewidth=2, markersize=8)
plt.plot(-np.minimum.accumulate(result_constrained.func_vals),
'^-', label='Constrained (Co < 0.25)', linewidth=2, markersize=8)
plt.xlabel('Number of Evaluations', fontsize=12)
plt.ylabel('Best Value So Far (mAh/g)', fontsize=12)
plt.title('Constrained vs Unconstrained Bayesian Optimization', fontsize=14)
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Expected Output:
Unconstrained:
Optimal composition: Li=0.487, Ni=0.312, Co=0.352, Mn=0.049
Capacity: 267.34 mAh/g
Constrained (Co < 0.25):
Optimal composition: Li=0.492, Ni=0.315, Co=0.248, Mn=0.045
Capacity: 261.78 mAh/g
Explanation: - Constrained case shows slightly lower capacity (2% reduction) - Practical performance is maintained even with Co content limitation - Quantifies the trade-off between cost and performance
Problem 3 (Difficulty: hard)
Implement multi-objective Bayesian Optimization and compute the Pareto frontier for capacity and stability.
Problem Setup : - Objective 1: Maximize capacity - Objective 2: Maximize stability (minimize absolute value of formation energy)
Tasks : 1. Initial random sampling (15 points) 2. Sequential optimization (30 iterations) 3. Extract Pareto optimal solutions 4. Visualize Pareto frontier 5. Present 3 representative solutions (capacity-focused, stability-focused, balanced)
Hint Pareto Optimality Test:
def is_pareto_optimal(Y):
"""
Y: (n_points, n_objectives)
Assumes all maximization problems
"""
n = len(Y)
is_optimal = np.ones(n, dtype=bool)
for i in range(n):
if is_optimal[i]:
# Points that dominate i in all objectives
dominated = ((Y >= Y[i]).all(axis=1) &
(Y > Y[i]).any(axis=1))
is_optimal[dominated] = False
return is_optimal
Scalarization Approach:
# Scalarization with random weights
w1, w2 = np.random.rand(2)
w1, w2 = w1/(w1+w2), w2/(w1+w2)
objective = w1 * capacity + w2 * stability
Sample Solution
# Multi-objective Bayesian Optimization (scalarization approach)
def multi_objective_optimization():
"""
Multi-objective optimization for capacity and stability
"""
# Initial sampling
n_initial = 15
np.random.seed(42)
X_sampled = np.random.rand(n_initial, 4)
X_sampled = X_sampled / X_sampled.sum(axis=1, keepdims=True)
# Evaluate two objectives
Y_capacity = []
Y_stability = []
for x in X_sampled:
capacity = rf_model.predict(x.reshape(1, -1))[0]
stability = -2.0 - 0.5*x[0] - 0.3*x[1] + 0.1*np.random.randn()
stability_positive = -stability # Convert to positive
Y_capacity.append(capacity)
Y_stability.append(stability_positive)
Y_capacity = np.array(Y_capacity)
Y_stability = np.array(Y_stability)
# Sequential optimization (scalarization)
n_iterations = 30
for iteration in range(n_iterations):
# Random weights
w1 = np.random.rand()
w2 = 1 - w1
# Normalization
cap_normalized = (Y_capacity - Y_capacity.min()) / \
(Y_capacity.max() - Y_capacity.min())
sta_normalized = (Y_stability - Y_stability.min()) / \
(Y_stability.max() - Y_stability.min())
# Scalarized objective
Y_scalar = w1 * cap_normalized + w2 * sta_normalized
# Gaussian Process model
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import RBF, ConstantKernel
kernel = ConstantKernel(1.0) * RBF(length_scale=0.2)
gp = GaussianProcessRegressor(kernel=kernel,
n_restarts_optimizer=10,
random_state=42)
gp.fit(X_sampled, Y_scalar)
# Acquisition Function(EI)
best_f = Y_scalar.max()
X_candidates = np.random.rand(1000, 4)
X_candidates = X_candidates / X_candidates.sum(axis=1, keepdims=True)
mu, sigma = gp.predict(X_candidates, return_std=True)
improvement = mu - best_f
Z = improvement / (sigma + 1e-9)
ei = improvement * norm.cdf(Z) + sigma * norm.pdf(Z)
# Next candidate
next_idx = np.argmax(ei)
x_new = X_candidates[next_idx]
# Evaluate
capacity_new = rf_model.predict(x_new.reshape(1, -1))[0]
stability_new = -2.0 - 0.5*x_new[0] - 0.3*x_new[1] + \
0.1*np.random.randn()
stability_positive_new = -stability_new
# Add to data
X_sampled = np.vstack([X_sampled, x_new])
Y_capacity = np.append(Y_capacity, capacity_new)
Y_stability = np.append(Y_stability, stability_positive_new)
# Extract Pareto optimal solutions
Y_combined = np.column_stack([Y_capacity, Y_stability])
pareto_mask = is_pareto_optimal(Y_combined)
X_pareto = X_sampled[pareto_mask]
Y_capacity_pareto = Y_capacity[pareto_mask]
Y_stability_pareto = Y_stability[pareto_mask]
print(f"Number of Pareto optimal solutions: {pareto_mask.sum()}")
# Visualization
plt.figure(figsize=(10, 6))
plt.scatter(Y_capacity, Y_stability, c='lightblue', s=50,
alpha=0.5, label='All exploration points')
plt.scatter(Y_capacity_pareto, Y_stability_pareto, c='red',
s=100, edgecolors='black', zorder=10,
label='Pareto optimal solutions')
# Connect Pareto frontier with lines
sorted_indices = np.argsort(Y_capacity_pareto)
plt.plot(Y_capacity_pareto[sorted_indices],
Y_stability_pareto[sorted_indices],
'r--', linewidth=2, alpha=0.5)
plt.xlabel('Capacity (mAh/g)', fontsize=12)
plt.ylabel('Stability (-formation energy)', fontsize=12)
plt.title('Pareto Frontier: Capacity vs Stability', fontsize=14)
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('pareto_frontier_exercise.png', dpi=150,
bbox_inches='tight')
plt.show()
# Representative solutions
print("\nRepresentative Pareto Solutions:")
# Capacity-focused
idx_max_cap = np.argmax(Y_capacity_pareto)
print(f"\nCapacity-focused:")
print(f" Composition: {X_pareto[idx_max_cap]}")
print(f" Capacity={Y_capacity_pareto[idx_max_cap]:.1f}, "
f"Stability={Y_stability_pareto[idx_max_cap]:.2f}")
# Stability-focused
idx_max_sta = np.argmax(Y_stability_pareto)
print(f"\nStability-focused:")
print(f" Composition: {X_pareto[idx_max_sta]}")
print(f" Capacity={Y_capacity_pareto[idx_max_sta]:.1f}, "
f"Stability={Y_stability_pareto[idx_max_sta]:.2f}")
# Balanced
normalized = (Y_combined[pareto_mask] - Y_combined[pareto_mask].min(axis=0)) / \
(Y_combined[pareto_mask].max(axis=0) - Y_combined[pareto_mask].min(axis=0))
distances = np.sqrt(((normalized - 0.5)**2).sum(axis=1))
idx_balanced = np.argmin(distances)
print(f"\nBalanced:")
print(f" Composition: {X_pareto[idx_balanced]}")
print(f" Capacity={Y_capacity_pareto[idx_balanced]:.1f}, "
f"Stability={Y_stability_pareto[idx_balanced]:.2f}")
# Pareto optimality test
def is_pareto_optimal(Y):
"""Test for Pareto optimal solutions"""
n = len(Y)
is_optimal = np.ones(n, dtype=bool)
for i in range(n):
if is_optimal[i]:
dominated = ((Y >= Y[i]).all(axis=1) &
(Y > Y[i]).any(axis=1))
is_optimal[dominated] = False
return is_optimal
# Execute
multi_objective_optimization()
Expected Output:
Number of Pareto optimal solutions: 12
Representative Pareto Solutions:
Capacity-focused:
Composition: [0.492 0.315 0.152 0.041]
Capacity=267.3, Stability=1.82
Stability-focused:
Composition: [0.352 0.248 0.185 0.215]
Capacity=215.7, Stability=2.15
Balanced:
Composition: [0.428 0.285 0.168 0.119]
Capacity=243.5, Stability=1.98
Detailed Explanation: 1. Trade-off Quantification: - Clear trade-off: capacity↑ → stability↓ - Pareto frontier shows the boundary of this trade-off 2. Application to Decision Making: - Select optimal composition based on application requirements - High-capacity applications: capacity-focused solution - Long-life applications: stability-focused solution 3. Practical Insights: - Discover solutions that would be missed by single-objective optimization - Expand design options for engineers - Present multiple optimal solution candidates 4. Areas for Improvement: - Use EHVI (Expected Hypervolume Improvement) - Extend to 3 or more objectives - Robust optimization considering uncertainty
References
-
Frazier, P. I. & Wang, J. (2016). “Bayesian Optimization for Materials Design.” Information Science for Materials Discovery and Design , 45-75. DOI: 10.1007/978-3-319-23871-5_3
-
Lookman, T. et al. (2019). “Active learning in materials science with emphasis on adaptive sampling using uncertainties for targeted design.” npj Computational Materials , 5(1), 21. DOI: 10.1038/s41524-019-0153-8
-
Balandat, M. et al. (2020). “BoTorch: A Framework for Efficient Monte-Carlo Bayesian Optimization.” NeurIPS 2020. arXiv:1910.06403
-
Daulton, S. et al. (2020). “Differentiable Expected Hypervolume Improvement for Parallel Multi-Objective Bayesian Optimization.” NeurIPS 2020. arXiv:2006.05078
-
Jain, A. et al. (2013). “Commentary: The Materials Project: A materials genome approach to accelerating materials innovation.” APL Materials , 1(1), 011002. DOI: 10.1063/1.4812323
-
Pedregosa, F. et al. (2011). “Scikit-learn: Machine Learning in Python.” Journal of Machine Learning Research , 12, 2825-2830.
Navigation
Previous Chapter
← Chapter 2: Theory of Bayesian Optimization
Next Chapter
Chapter 4: Active Learning and Experimental Integration →
Series Table of Contents
← Return to Series Table of Contents
Author Information
Created by : AI Terakoya Content Team Created on : 2025-10-17 Version : 1.0
Update History : - 2025-10-17: v1.0 Initial release
Feedback : - GitHub Issues: AI_Homepage/issues - Email: yusuke.hashimoto.b8@tohoku.ac.jp
License : Creative Commons BY 4.0
You’ve mastered practical implementation! Let’s learn experimental integration in the next chapter!