🌐 EN | 🇯🇵 JP | Last sync: 2025-11-16

Chapter 3: Practice: Application to Materials Discovery

Learn Real-World Materials Optimization with Python Implementation

📖 Reading Time: 25-30 min 📊 Difficulty: Intermediate 💻 Code Examples: 12 📝 Exercises: 3

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:

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:

  1. Build ML Model from Existing Data - Public databases like Materials Project - Past experimental data - DFT calculation results

  2. 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:

  1. Composition Constraints: Total 100%, upper and lower limits for each element
  2. Stability Constraints: formation energy < threshold
  3. Experimental Constraints: Synthesis temperature, pressure range
  4. 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:

These have trade-offs, and no single optimal solution exists.

Concept of Pareto Frontier

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:

Workflow

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:

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

  1. 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

  2. Constrained Optimization - Composition, stability, and cost constraints - Incorporate probability of satisfying constraints into Acquisition Function - Focus exploration on feasible regions

  3. Multi-Objective Optimization - Calculate Pareto frontier - Expected Hypervolume Improvement (EHVI) - Visualize trade-offs and decision-making

  4. Batch Optimization - Efficiency through parallel experiments - q-EI Acquisition Function - Optimization strategy considering experimental costs

  5. Real-World Application - Complete implementation of Li-ion battery cathode materials - Simultaneous 3-objective optimization - Achieved 50% reduction in number of experiments

Key Points

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

  1. 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

  2. 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

  3. Balandat, M. et al. (2020). "BoTorch: A Framework for Efficient Monte-Carlo Bayesian Optimization." NeurIPS 2020. arXiv:1910.06403

  4. Daulton, S. et al. (2020). "Differentiable Expected Hypervolume Improvement for Parallel Multi-Objective Bayesian Optimization." NeurIPS 2020. arXiv:2006.05078

  5. 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

  6. 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!

Disclaimer