ブラックボックス最適化によるモデル超電導材料の探索¶
ブラックボックス最適化の効果的な活用方法を理解していただくために、本サンプルコードでは、疑似的な材料から構成される超電導材料の探索を例題として取り扱います。本サンプルコードでは、非線形なモデル代数式に基づいて、材料探索を行いますが、モデル代数式の代わりに、高精度なシミュレーションや実験計測結果を用いても同様のステップで様々な材料探索に関する FMQA 最適化を行うことが可能で、その場合、本サンプルコードをほぼそのまま活用いただけます。
ブラックボックス最適化 FMQA の基本知識については、『量子アニーリング・イジングマシンによるブラックボックス最適化』をご覧ください。
また、FMQA を活用したより応用的なモデルケースとして、流体工学、化学プラント、都市交通などの様々な領域の課題に対する FMQA のサンプルプログラムを公開しています。こちらからご覧ください。
問題設定¶
超電導材料の探索シナリオ¶
超電導技術は、リニアモーターカーに代表される輸送分野や計測分野、エネルギー分野においての活用が期待される技術で、現在様々な超電導材料の開発が行われています。しかし、現在確認されている超電導材料において、超電導状態に転移する温度(臨界温度)は一般的に絶対温度 0 K(ケルビン)付近であるため、その活用は高コストで、現状社会的な応用範囲は限られています。したがって、高温超電導材料の探索が求められています。
通常、(より高温の臨界温度を有する)超電導材料の探索には、知見や経験に基づき数々の材料を選択・合成し、その合成材料の臨界温度を計測により評価するというプロセスを繰り返す、試行錯誤を行います。この合成と臨界温度の評価は非常に高時間コストと考えられます。本サンプルコードでは、この探索に対してブラックボックス最適化手法の 1 つである FMQA を活用し、比較的少ない評価回数で最適解に近い材料の組み合わせを求めます。
本サンプルコードでは、FMQA による材料探索の解説のために、疑似的な材料から構成される超電導材料の探索を例題として取り扱い、臨界温度の評価には模擬的な臨界温度モデルを用います。従って、以下で紹介する臨界温度モデル及び取得される材料の組み合わせは、必ずしも物理的な正確性を持たないことに注意してください。
ソルバークライアントの設定¶
まず、FMQA の最適化サイクル中に用いるソルバーを設定します。本サンプルプログラムでは、Amplify AE を用います。
from amplify import FixstarsClient
from datetime import timedelta
# ソルバークライアントを Amplify AE に設定
client = FixstarsClient()
# ローカル環境等で実行する場合はコメントを外して Amplify AEのアクセストークンを入力してください
# client.token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# 最適化の実行時間を 2 秒に設定
client.parameters.timeout = timedelta(milliseconds=2000)
ブラックボックス関数の定義¶
本サンプルコードでは、多くの種類の材料から、いくつかの材料を組み合わせを上手く選択し、それらの合成で生成された超電導材料の臨界温度を最大化する最適化を実施します。
一般的に、臨界温度は実験計測で評価するしかなく、その実施には毎回比較的大きなコスト(時間・費用)が必要です。本サンプルコードでは、臨界温度の計測の代わりに、以下の模擬的な臨界温度モデル
supercon_temperature()
を用いて評価を行いますが、この関数はあくまでも実験やシミュレーションの代用であり、その中身やパラメータについては未知であるとして扱い、コストの観点から
supercon_temperature()
を呼ぶ回数にも制限があるものとして取り扱います。
以下の make_blackbox_func
は、FMQAにおける目的関数でもあるブラックボックス関数 blackbox
関数を作成し返却する関数です。また、ブラックボックス関数 blackbox
では、supercon_temperature()
を実行し、得られた臨界温度の負値を返却します。
本サンプルでは、この臨界温度の負値を最小化するように最適化を進めます。
import numpy as np
import math
from typing import Callable, Any
# 乱数シードの固定
seed = 1234
rng = np.random.default_rng(seed)
def make_blackbox_func(d: int) -> Callable[[np.ndarray], float]:
"""入力が長さ d のバリナリ値のベクトルで出力が float であるような関数を返却する"""
def set_properties(size: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""(ランダムに決定した)材料物性係数を返却する"""
mu, sigma, ratio = 0.0, 1.0, 0.2
table1 = rng.random(size) * 1e5 * (0.1 * math.log(size) - 0.23)
table2 = rng.lognormal(mu, sigma, size) * ratio
table3 = rng.lognormal(mu, sigma, size) * ratio
return table1, table2, table3
def supercon_temperature(
x: np.ndarray,
debye_table: np.ndarray,
state_table: np.ndarray,
interaction_table: np.ndarray,
) -> float:
"""与えられた材料の組合せ(長さ d のバリナリ値のベクトル)に対し、臨界温度を計算し返却する(シミュレーションや実験の代わり)"""
debye_temperature = np.sum(x * debye_table) / np.sum(x)
state_density = np.sum(x * state_table) / np.sum(x)
interaction = np.sum(x * interaction_table) / np.sum(x)
crit_temp = debye_temperature * math.exp(-1.0 / state_density / interaction)
return crit_temp
# 係数テーブルの準備
debye_temperature_table, state_density_table, interaction_table = set_properties(d)
# ブラックボックス関数の定義
def blackbox(x: np.ndarray) -> float:
"""与えられた材料の組合せ(長さ d のバリナリ値のベクトル)に対し、超電導臨界温度の負値を返却する"""
assert x.shape == (d,) # x は要素数 d の一次元配列
t_c = supercon_temperature(
x, debye_temperature_table, state_density_table, interaction_table
)
return -t_c
return blackbox
試しに、上記で定義したブラックボックス関数
blackbox(x)
(実験やシミュレーションの代わり)を用いて、ランダムに選択した材料から合成される超電導材料の臨界温度を評価します。ここで、num_materials
は選択対象となる材料の数で、入力のバイナリベクトル x
は、サイズ num_materials
のバイナリベクトルです。
例えば、5 種類の材料から最初と最後の材料を選択して合成する、という場合、入力ベクトルは x = [1, 0, 0, 0, 1]
となります。この場合、選択の仕方(組み合わせ)は、$2^5-1=31$ 通りあります。num_materials = 100
の場合、組み合わせの数は、$10^{30}$
通り程度存在し、全探索的な方法は困難と考えられます。
num_materials = 100 # 決定変数のサイズ(材料選択肢の数)
blackbox_func = make_blackbox_func(num_materials)
# ランダムな入力 x で ブラックボックス関数を n_cycle 回評価し、得られた最小目的関数値と平均目的関数値を出力。
n_cycle = 100
obj_min = 0.0 # 臨界温度の負値の最小値を格納する変数
obj_mean = 0.0 # 臨界温度の負値の平均値を計算する変数
for i in range(n_cycle):
x = rng.integers(0, 2, num_materials)
if np.sum(x) == 0:
continue
obj = blackbox_func(x)
if obj_min > obj:
obj_min = obj
obj_mean += obj
obj_mean /= n_cycle
print(f"Minimum objective function value: {obj_min:.2f} K")
print(f"Mean objective function value: {obj_mean:.2f} K")
FMQA のプログラム実装¶
機械学習によるモデルの学習¶
FMQA のうち、機械学習によりモデルの最適なパラメータを学習する部分のプログラム実装を行います。まず、Factorization Machine によるモデルを表す
TorchFM
クラスを PyTorch を用いて定義します。Factorization Machine は以下の式で表されます。
$$ \begin{aligned} f(\boldsymbol{x} | \boldsymbol{w}, \boldsymbol{v}) &= \underset{\color{red}{\mathtt{out\_linear}}}{\underline{ w_0 + \sum_{i=1}^d w_i x_i} } + \underset{\color{red}{\mathtt{out\_quadratic}}}{\underline{\frac{1}{2} \left[\underset{\color{red}{\mathtt{out\_1}}}{\underline{ \sum_{f=1}^k\left(\sum_{i=1}^d v_{i f} x_i\right)^2 }} - \underset{\color{red}{\mathtt{out\_2}}}{\underline{ \sum_{f=1}^k\sum_{i=1}^d v_{i f}^2 x_i^2 }} \right] }} \end{aligned} $$
このモデルの入力 $x$ はブラックボックス関数の入力と同じ長さ $d$ のベクトルであり、パラメータは以下の 3 種類です。
- $v$: $d\times k$ の 2 次元配列
- $w$: 長さ $d$ の 1 次元ベクトル
- $w_0$: スカラー
ハイパーパラメータは $k$ のみで、これは $d$ 以下の正の整数で与えます。
以下で定義する TorchFM
クラスは torch.nn.Module
を継承しており、入力ベクトル $x$ のサイズ $d$ とハイパーパラメータ
$k$
から構築されます。ハイパーパラメータ $k$ はモデルのパラメータ数を制御するためのもので、大きくするほどパラメータは多くなり精度が向上しますが、一方で過学習が起こりやすくなる傾向があります。
TorchFM
クラスはモデルのパラメータの $v$, $w$, $w_0$
をアトリビュートに持ち、学習を進めることによってこれらのパラメータを更新します。また、forward
メソッドは入力 $x$ から $y$
の推定値を上式に従って出力します。パラメータ
$v$, $w$, $w_0$ はイジングマシンによる最適化を行うときに必要なので、これらを出力する関数 get_parameters
も定義しておきます。
import torch
import torch.nn as nn
# 乱数シードの固定
torch.manual_seed(seed)
class TorchFM(nn.Module):
def __init__(self, d: int, k: int):
"""モデルを構築する
Args:
d (int): 入力ベクトルのサイズ
k (int): パラメータ k
"""
super().__init__()
self.d = d
self.v = torch.randn((d, k), requires_grad=True)
self.w = torch.randn((d,), requires_grad=True)
self.w0 = torch.randn((), requires_grad=True)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""入力 x を受け取って y の推定値を出力する
Args:
x (torch.Tensor): (データ数 × d) の 2 次元 tensor
Returns:
torch.Tensor: y の推定値 の 1次元 tensor (サイズはデータ数)
"""
out_linear = torch.matmul(x, self.w) + self.w0
out_1 = torch.matmul(x, self.v).pow(2).sum(1)
out_2 = torch.matmul(x.pow(2), self.v.pow(2)).sum(1)
out_quadratic = 0.5 * (out_1 - out_2)
out = out_linear + out_quadratic
return out
def get_parameters(self) -> tuple[np.ndarray, np.ndarray, float]:
"""パラメータ v, w, w0 を出力する"""
np_v = self.v.detach().numpy().copy()
np_w = self.w.detach().numpy().copy()
np_w0 = self.w0.detach().numpy().copy()
return np_v, np_w, float(np_w0)
次に、TorchFM
クラスの機械学習を行う関数 train()
を定義します。入力は教師データ $x, y$ と
TorchFM
モデルのインスタンスです。train()
関数を呼ぶことで TorchFM
のパラメータの学習が行われます。
一般的な機械学習と同様に、教師データを学習データと検証データに分割し、学習データを用いてパラメータの最適化、検証データを用いて学習中のモデル検証を行います。エポックごとにモデルの検証を行い、検証データに対して最も予測精度の高かったエポックにおけるパラメータを保存して、これを学習後のモデルとします。
from torch.utils.data import TensorDataset, DataLoader, random_split
from tqdm.auto import tqdm, trange
import copy
def train(
x: np.ndarray,
y: np.ndarray,
model: TorchFM,
epochs: int = 2000,
lr: float = 0.1,
) -> None:
"""FM モデルの学習を行う
Args:
x (np.ndarray): 学習データ (入力ベクトル)
y (np.ndarray): 学習データ (出力値)
model (TorchFM): TorchFM モデル
epochs (int): イテレーション数
lr (float): 学習率
"""
# モデルの最適化関数
optimizer = torch.optim.AdamW([model.v, model.w, model.w0], lr=lr)
# 損失関数
loss_func = nn.MSELoss()
# データセットの用意
x_tensor, y_tensor = (
torch.from_numpy(x).float(),
torch.from_numpy(y).float(),
)
dataset = TensorDataset(x_tensor, y_tensor)
train_set, valid_set = random_split(dataset, [0.8, 0.2])
train_loader = DataLoader(train_set, batch_size=8, shuffle=True)
valid_loader = DataLoader(valid_set, batch_size=8, shuffle=True)
# 学習の実行
min_loss = 1e18 # 損失関数の最小値を保存
best_state = model.state_dict() # モデルの最も良いパラメータを保存
# `range` の代わりに `tqdm` モジュールを用いて進捗を表示
for _ in trange(epochs, leave=False):
# 学習フェイズ
for x_train, y_train in train_loader:
optimizer.zero_grad()
pred_y = model(x_train)
loss = loss_func(pred_y, y_train)
loss.backward()
optimizer.step()
# 検証フェイズ
with torch.no_grad():
loss = 0
for x_valid, y_valid in valid_loader:
out_valid = model(x_valid)
loss += loss_func(out_valid, y_valid)
if loss < min_loss:
# 損失関数の値が更新されたらパラメータを保存
best_state = copy.deepcopy(model.state_dict())
min_loss = loss
# モデルを学習済みパラメータで更新
model.load_state_dict(best_state)
Amplify によるモデルの最小化¶
次に、推論された機械学習モデルの最小化を行う anneal
関数の実装を行います。入力は学習後のモデル TorchFM
クラス、出力はモデルを最小化するようなベクトル $x$ です。
先ほど学習した TorchFM
クラスのモデルに対応する以下の最適化問題を解くことで、推論されたモデルを最小化するような入力 $x$ を求めます。
$$ \underset{x}{\mathrm{argmin}} \quad \underset{\color{red}{\mathtt{out\_linear}}}{\underline{ w_0 + \sum_{i=1}^d w_i x_i} } + \underset{\color{red}{\mathtt{out\_quadratic}}}{\underline{\frac{1}{2} \left[\underset{\color{red}{\mathtt{out\_1}}}{\underline{ \sum_{f=1}^k\left(\sum_{i=1}^d v_{i f} x_i\right)^2 }} - \underset{\color{red}{\mathtt{out\_2}}}{\underline{ \sum_{f=1}^k\sum_{i=1}^d v_{i f}^2 x_i^2 }} \right] }} $$
この最適化問題において決定変数は $x$ です。これはブラックボックス関数への入力ベクトルと同じく、長さ $d$ の 1 次元バイナリ変数ベクトルです。また、学習課程ではパラメータ(最適化の対象であった機械学習モデルの重みやバイアス)であった $v$, $w$, $w_0$ はここでは定数です。
与えられたモデルに対して Amplify で最適化を実行する anneal
関数を以下のように定義します。anneal
関数では
VariableGenerator
を用いて長さ $d$ の 1 次元バイナリ変数ベクトル x
を作成し、バイナリ変数配列 $x$ と
TorchFM
クラスから取得した $v$, $w$, $w_0$ を用いて、Factorization Machine の式に従い最適化対象の目的関数を作成します。
構築化した最適化モデルと先に定義したソルバークライアント (FixstarsClient
) を使用して、目的関数の最小化を実行します。
from amplify import VariableGenerator, Model, solve, Poly
from datetime import timedelta
def anneal(torch_model: TorchFM) -> np.ndarray:
"""FM モデルを受け取り、それらのパラメータにより記述される FM モデルの最小値を与える x を求める"""
# 長さ d のバイナリ変数の配列を作成
gen = VariableGenerator()
x = gen.array("Binary", torch_model.d)
# TorchFM からパラメータ v, w, w0 を取得
v, w, w0 = torch_model.get_parameters()
# 目的関数を作成
out_linear = w0 + (x * w).sum()
out_1 = ((x[:, np.newaxis] * v).sum(axis=0) ** 2).sum() # type: ignore
out_2 = ((x[:, np.newaxis] * v) ** 2).sum()
objective: Poly = out_linear + (out_1 - out_2) / 2
# 組合せ最適化モデルを構築
amplify_model = Model(objective)
# 最小化を実行
result = solve(amplify_model, client)
if len(result.solutions) == 0:
raise RuntimeError("No solution was found.")
# モデルを最小化する入力ベクトルを返却
return x.evaluate(result.best.values).astype(int)
以上によりFMQA の中核である、機械学習を行う train
関数と最適化を行う anneal
関数を定義しました。これらを用いて実際に FMQA
を実行します。ブラックボックス最適化の対象となるブラックボックス関数(実験やシミュレーションに対応)は、既に上記で定義した blackbox_func
を用います。この関数は
$0$
または $1$ からなる長さ $d = 100$ の NumPy 一次元バイナリベクトルを受け取り臨界温度の負値を返却します。
初期教師データの作成¶
次に、入力ベクトル $\boldsymbol{x}$ に対してブラックボックス関数 $y = f(\boldsymbol{x})$ を評価することで、$N_0$ 個の初期教師データを作成します。通常、 ブラックボックス関数はシミュレーションや実験の結果に相当するため、過去のデータ等を用いて初期教師データを作成することも可能です。
今回は、以下のようにランダムな $N_0$ 個の入力ベクトル $x$ を用いて初期教師データを作成する init_training_data
関数を定義します。この関数は、ブラックボックス関数 blackbox_func
と初期教師データの数 $N_0$ を受け取り、初期教師データとして $N_0$ 個の入力ベクトル
$\boldsymbol{x}$ と対応する出力 $y$ を返します。
def init_training_data(d: int, n0: int):
"""n0 組の初期教師データを作成する"""
assert n0 < 2**d
# n0 個の 長さ d の入力値を乱数を用いて作成
x = rng.choice(np.array([0, 1]), size=(n0, d))
# 入力値の重複が発生していたらランダムに値を変更して回避する
x = np.unique(x, axis=0)
while x.shape[0] != n0:
x = np.vstack((x, np.random.randint(0, 2, size=(n0 - x.shape[0], d))))
x = np.unique(x, axis=0)
# blackbox 関数を評価して入力値に対応する n0 個の出力を得る
y = np.zeros(n0)
for i in range(n0):
y[i] = blackbox_func(x[i])
return x, y
N0 = 10 # 初期教師データの数
x_init, y_init = init_training_data(num_materials, N0)
$N_0$ 組の初期教師データ(入力100要素、出力1要素の10組)が作成できました。
print(x_init.shape, y_init.shape)
FMQA サイクルの実行¶
上で作成した $N_0$ 組のデータを初期学習データとして、FMQA のサイクルを実行します。
1 回のサイクルごとに以下の操作を行います。
- モデルの学習
TorchFM
クラスのモデルを構築し初期教師データx = x_init
,y = y_init
とモデルに対してtrain
関数を呼ぶことで学習を行う
- モデルの最小化
anneal
関数にTorchModel
クラスの学習済みモデルを渡すことでモデルを最小化する $\hat{x}$ を得る- $\hat{x}$ が既に教師データ
x
に含まれている場合は $\hat{x}$ の一部を変更して教師データが重複しないようにする
- モデルの評価
- $\hat{x}$ をブラックボックス関数に入力し出力 $\hat{y}$ を得る
- $\hat{x}$ と $\hat{y}$ をそれぞれ教師データ
x
およびy
に追加する
上記を実行する実装の例は次の通りです。実行にはおよそ10分程度の計算時間を要するため、出力例をサンプルコード実行例に示します。
# FMQA サイクルの実行回数
N = 40
# 教師データの初期化
x, y = x_init, y_init
# N - N0 回のイテレーションを実行
# `range` の代わりに `tqdm` モジュールを用いて進捗を表示
for i in trange(N):
# 機械学習モデルの作成
model = TorchFM(d=num_materials, k=10)
# モデルの学習の実行
train(x, y, model)
# 学習済みの最小値を与える入力ベクトルの値を取得
x_hat = anneal(model)
# x_hat が重複しないようにする
while (x_hat == x).all(axis=1).any():
flip_idx = rng.choice(np.arange(num_materials))
x_hat[flip_idx] = 1 - x_hat[flip_idx]
# 推定された入力ベクトルを用いてブラックボックス関数を評価
y_hat = blackbox_func(x_hat)
# 評価した値をデータセットに追加
x = np.vstack((x, x_hat))
y = np.append(y, y_hat)
tqdm.write(f"FMQA cycle {i}: found y = {y_hat:.1f}; current best = {np.min(y):.1f}")
上のセルの実行後、x
と y
には $N_0 + N = 50$ 回のブラックボックス関数の評価における入力および出力が保存されています。
print(x.shape, y.shape)
以下のようにしてブラックボックス関数の評価の最小値を与える入力とその値が得られます。
min_idx = np.argmin(y)
print(f"best x = {x[min_idx]}")
print(f"best y = {y[min_idx]}")
評価値の推移¶
以下に$N_0$ 個の初期教師データと $N$ 回の FMQA サイクルで最適化された評価値の推移をプロットします。初期教師データを青色で、FMQA サイクルによって得られた評価値を赤色で示します。
import matplotlib.pyplot as plt
fig = plt.figure(figsize=(6, 4))
ax = fig.add_subplot()
# 初期教師データ生成のブラックボックス関数の評価値
ax.plot(
range(N0),
y[:N0],
marker="o",
linestyle="-",
color="b",
)
# FMQA サイクルのブラックボックス関数の評価値
ax.plot(
range(N0, N0 + N),
y[N0:],
marker="o",
linestyle="-",
color="r",
)
# 目的関数最小値の更新履歴
ax.plot(
range(N0 + N),
[y[:i].min() for i in range(1, N0 + N + 1)],
linestyle="-",
color="k",
)
ax.set_xlabel("number of iterations", fontsize=18)
ax.set_ylabel("f(x)", fontsize=18)
ax.tick_params(labelsize=18)
plt.show()
サンプルコード実行例¶
一般的に、Fixstars Amplify AE で採用されているヒューリスティクスというアルゴリズムの原理上、得られる解に完全な再現性はありませんが、本サンプルコードを実行した際に得られる、典型的な出力結果を以下に示します。