ブラックボックス最適化による機械学習のための特徴量選択¶
はじめに¶
機械学習では、モデルに与える入力データは多数の特徴量(データの各項目)から構成されます。しかし、すべての特徴量が予測に役立つとは限りません。そのため、重要な特徴量だけを選んでモデルに入力する「特徴量選択」がよく行われます。
多数の特徴量から予測に寄与するものだけを選ぶことで、学習・推論の計算量を抑えながら、高い予測精度を持つ軽量なモデルの実現が期待されます。
本チュートリアルでは、手書き数字画像(MNIST)を分類する問題を例に、特徴量選択を行います。1 枚の画像は 28×28 個、合計 784 個のピクセル(=特徴量)で表されます。ここでは、それぞれの特徴量について「使う」「使わない」を決め、どの特徴量を選べば最も良い分類モデルになるかを探索します。
この問題では、分類精度はできるだけ高くしたい一方で、使う特徴量はできるだけ少なくしたいという 2 つの目標があります。特徴量を減らし過ぎると分類精度が下がりやすくなり、逆に精度を重視すると多くの特徴量が必要になります。そのため、この2つのバランスが取れた解を探す必要があります。このように複数の目標を同時に扱う最適化を多目的最適化と呼びます。
また、この問題では 784 個の特徴量について、それぞれ「使う」「使わない」を決める必要があります。しかし、そのすべての組合せを試すことは現実的ではありません。加えて、特徴量の選び方を 1 つ試すたびに分類モデルを学習し、その性能を評価する必要があるため、多くの時間がかかります。
そこで本チュートリアルでは、このような問題に適したブラックボックス最適化ライブラリ Amplify-BBOpt を用います。Amplify-BBOpt は、量子インスパイアード技術を活用してブラックボックス最適化問題を効率的に解く Python ライブラリで、高次元な探索問題や評価コストの高い問題に強みを持ちます。
機械学習と量子アニーリング・イジングマシンによるブラックボックス最適化の基礎については『量子アニーリング・イジングマシンによるブラックボックス最適化』を、ブラックボックス最適化手法の他の活用例はこちらをご覧ください。
目次¶
- 1. MNIST と特徴量選択
- 1.1. MNIST とは
- 1.2. 特徴量選択のフロー
- 1.3. 2 つの目的とパレートフロント
- 1.4. データの読み込みと前処理
- 1.5. マスク適用・学習・評価のための関数
- 1.6. 全特徴量を考慮した場合のモデル精度
- 2. MultiOptimizer による多目的ブラックボックス最適化
- 2.1. ブラックボックス関数
- 2.2. 初期学習データの生成
- 2.3. ソルバーの設定
- 2.4. MultiOptimizer のインスタンス化
- 2.5. 重みスケジュールの設計
- 2.6. 最適化サイクルの実行
- 3. 最適化履歴の可視化
- 3.1. 探索された解に対する目的関数値の推移
- 3.2. パレートフロント
- 3.3. まとめ
1. MNIST と特徴量選択¶
1.1. MNIST とは¶
Modified National Institute of Standards and Technology (MNIST) データとは、様々な画像処理モデルの学習や検証に利用される手書き数字画像のデータベースで、28×28 ピクセルの手書き画像とそれに対応する数字のラベルが含まれています。以下に学習データに含まれるいくつかの代表的な手書き数字サンプルを示します(各画像上の [ ] 内の数字は、画像に対応するラベル)。
本サンプルプログラムで対象とする機械学習モデルでは、各手書きの数字画像が与えられた際に、その画像に対応する数字を予測する、手書き画像の分類を目的とします。
1.2. 特徴量選択のフロー¶
機械学習における特徴量選択の流れは以下の図の通りです。今回の機械学習モデルでは、手書き画像の 28×28 ピクセルの輝度値、すなわち $N_f = 28 \times 28 = 784$ 個の特徴量が元データとして得られます。ここで、特徴量選択を行うことで、$N_f$ 個の特徴量の中から適切に $N_f'\ (\leq N_f)$ 個の特徴量を選択し、モデルへは選択後の $N_f'$ 個の特徴量のみを入力として与えます。
元々の $N_f$ 個の特徴量から $N_f'$ 個の特徴量をどう選択するかは、下図のようなマスクで表現します。マスクは $N_f$
個の成分からなる配列であり、各成分の値(True
又は False)に応じて、該当する特徴量を選択するか否かを指定します (True なら選択する)。
1.3. 2 つの目的とパレートフロント¶
本サンプルでは「分類精度の最大化」すなわち「誤答率の最小化」と、「使用する特徴量数の最小化」という 2 つの目的を同時に考えます。しかしこの 2 つはトレードオフの関係にあります。特徴量を減らせばモデルが使える情報が減るため誤答率は上がりやすく、逆に誤答率を下げようとすれば多くの特徴量が必要になりがちです。
このように複数の目的がトレードオフの関係にある場合、すべての目的を同時に最小にする「唯一の最適解」は通常存在しません。代わりに答えとなるのは、一方の目的を改善しようとすると必ずもう一方が悪化してしまうような、互いに優劣のつけられない解の集合です。これをパレート最適解と呼び、目的空間上にプロットしたその集合をパレートフロントと呼びます。
本サンプルのゴールは、Amplify-BBOpt を用いてパレートフロントを見つけ出すことです。得られたパレートフロントを使うことで、誤答率と特徴量数のバランスを見ながら、実務上の要件に応じて解を選べるようになります(パレートフロントの可視化は 3.2 節で行います)。
from sklearn import datasets, model_selection
import numpy as np
PIX_SIZE = 28 # 画像1辺のピクセルサイズ
NUM_FEATURES = PIX_SIZE * PIX_SIZE # 特徴量の総数 (=784)
mnist_data, mnist_label = datasets.fetch_openml(
"mnist_784", return_X_y=True, parser="auto"
)
mnist_data /= 255 # 輝度値を [0, 1] にスケーリング
train_data, test_data, train_label, test_label = model_selection.train_test_split(
mnist_data, mnist_label, train_size=1000, test_size=1000, random_state=0
)
1.5. マスク適用・学習・評価のための関数¶
続いて、マスクを受け取って機械学習モデルを学習・評価する関数を実装します。まず、マスクの True である要素に対応する特徴量を抽出する
apply_mask
を定義します。
import pandas as pd
def apply_mask(data: pd.DataFrame, mask: np.ndarray) -> pd.DataFrame:
"""データに対してマスクを適用 (マスクの True である要素だけを返却)"""
return data.loc[:, mask]
次に、マスク適用後のデータで分類器モデルを学習し、テストデータに対する誤答率を返す train_and_evaluate を定義します。本サンプルコードでは、分類器モデルとして
sklearn.svm.SVC を使用します。
これが、与えられたマスクの性能を評価する関数となり、後述するブラックボックス関数の中身になります。
from sklearn import svm, metrics
def train_and_evaluate(mask: np.ndarray) -> float:
"""マスク適用後のデータで SVM モデルを学習・評価し、誤答率を返す"""
masked_train_data = apply_mask(train_data, mask)
masked_test_data = apply_mask(test_data, mask)
model = svm.SVC()
model.fit(masked_train_data, train_label)
prediction = model.predict(masked_test_data)
return 1.0 - float(metrics.accuracy_score(test_label, prediction))
1.6. 全特徴量を考慮した場合のモデル精度¶
試しに、マスクの全要素を True にし、全 784
個の特徴量を考慮して機械学習を行ったモデルの誤答率を見てみましょう。この値は、特徴量を減らしていく際に誤答率がどれだけ悪化するかを測る基準値となります。
mask_all_feat = np.array([True] * NUM_FEATURES)
error_all_feat = train_and_evaluate(mask_all_feat)
print(f"{error_all_feat=:.3f}")
2. MultiOptimizer による多目的ブラックボックス最適化¶
良いマスクを見つけるのは、次の 2 つの理由から簡単ではありません。
- マスクのパターンは全部で $2^{784}$ 通りと膨大で、すべて試すことは到底できない
- あるマスクの良し悪し(誤答率)は、実際にモデルを学習・評価してみるまで分からず、その評価にもコストがかかる
このように探索空間が広く、かつ目的関数の中身が分からない問題に有効なのが、Amplify-BBOpt によるブラックボックス最適化です。本サンプルでは、サロゲートモデル(=ブラックボックス関数を近似する代理モデル)を用いる手法の一つである Kernel-QA を利用します。Kernel-QA はカーネル法に基づくサロゲートモデルを構築し、それをイジングマシンで最小化することで次に評価すべき解を決める手法です。
2.1. ブラックボックス関数¶
ここではマスクを受け取りその性能指標を返すブラックボックス関数を定義します。Amplify-BBOpt の @blackbox
デコレーターを使い実装します。ブラックボックス関数のデフォルト引数に Amplify-BBOpt で作成した決定変数を設定することで、決定変数のブラックボックス関数への紐づけが行えます。
今回は 2 つの目的を最小化したいので、これらをまとめて list で返却します。返す値は次の 2 つです。
- 誤答率 (
error):train_and_evaluateによってマスクを評価して得られた誤答率 (0~1) - 特徴量割合 (
feature_ratio): 分類時に参照する特徴量数の割合 (1/784~1)
使用する特徴量数 (1~784) は、そのままだと誤答率とスケールが合わないため、代わりにその割合を考えることにしています。
いずれも小さいほど良い指標なので、そのまま最小化問題として扱えます。
from amplify_bbopt import blackbox, BinaryVariable
# バイナリ決定変数配列を作成 (要素数はマスクの長さ = 784 に一致させる)
variables = [BinaryVariable() for _ in range(NUM_FEATURES)]
@blackbox
def my_blackbox_func(x=variables):
# x は要素数 784 の一次元配列であることを確認
assert len(x) == NUM_FEATURES
# 特徴量割合の計算 (マスク中の True の割合)
feature_ratio = sum(x) / NUM_FEATURES
# マスク x による学習・評価を実行し、誤答率を計算
error = train_and_evaluate(np.array(x, dtype=bool))
# 多目的最適化 (MultiOptimizer) を実行するので list で返却 (いずれも最小化)
return [error, feature_ratio]
2.2. 初期学習データの生成¶
Kernel-QA を用いたブラックボックス最適化には、サロゲートモデルを学習するための初期データが必要です。ここでは、特徴量割合を 1/784~1
からランダムに選び、その割合だけ特徴量を残すマスクを生成する関数 random_mask を用意します。
特徴量割合を一様乱数から選ぶのは、残す特徴量が少ないマスクから多いマスクまでを目的空間上に広く分布させておくことで、サロゲートモデルを特定の範囲に偏らず構築でき、その後の最適化サイクルで幅広く探索できるようにするためです。
def random_mask(feature_ratio: float, rng: np.random.Generator) -> np.ndarray:
"""feature_ratio の割合だけ特徴量を残すマスクをランダムに生成する"""
num_kept = int(NUM_FEATURES * feature_ratio)
# num_kept 個の特徴量を残すマスクを生成
mask = np.array([True] * num_kept + [False] * (NUM_FEATURES - num_kept))
rng.shuffle(mask)
return mask
生成した各マスクをブラックボックス関数に通して評価し、num_init_data 個のマスク・目的関数値の組を集めた初期学習データセットを作成します。
from amplify_bbopt import Dataset
# 初期学習データのサイズ
num_init_data = 10
rng = np.random.default_rng(0)
x = [] # 学習データのマスク配列
y = [] # マスクの誤答率と特徴量割合のタプルのリスト
for i in range(num_init_data):
feature_ratio = rng.uniform(1 / NUM_FEATURES, 1.0)
mask = random_mask(feature_ratio, rng)
x.append(mask)
y.append(my_blackbox_func(mask))
# 初期学習データの準備
init_data = Dataset(np.array(x), np.array(y))
from amplify import AmplifyAEClient
# ソルバークライアントを Amplify AE に設定
client = AmplifyAEClient()
client.parameters.time_limit_ms = 1000 # 1 sec
# client.token = "API トークンを入力してください"
2.4. MultiOptimizer のインスタンス化¶
これまでに定義したブラックボックス関数、ソルバークライアント、初期学習データを使い、多目的最適化を行う Amplify-BBOpt の MultiOptimizer
をインスタンス化します。
from amplify_bbopt import MultiOptimizer, KMTrainer, ExpScaler
# MultiOptimizer のインスタンス化
optimizer = MultiOptimizer(
blackbox=my_blackbox_func,
trainer=[KMTrainer(), KMTrainer()],
client=client,
training_data=init_data,
surrogate_data_transformer=[ExpScaler(), ExpScaler()],
)
2.5. 重みスケジュールの設計¶
MultiOptimizer は、複数のサロゲートモデルを重み付き和として 1
つの目的関数に統合し、イジングマシンでそれを最小化します。つまり実際に最小化されるのは、$\varepsilon$ を誤答率、$r_f$ を特徴量割合、 $w > 0$ を特徴量割合の重みとして
$$ \varepsilon + w \cdot r_f $$
という 1 つのスカラー値です。
ここでの特徴量割合の重み $w$ は、特徴量割合を誤答率に対してどれだけ重要視するかを決定します。つまり、$w < 1$ ならば分類精度 (すなわちより低い誤答率) を重視し、$w > 1$ ならば特徴量割合 (すなわちより少ない特徴量) をより重視するようになります。
後ほど実装する最適化サイクル中でこの重みを変化させていくことで、序盤は「誤答率重視(=特徴量は多め)」、終盤は「特徴量数重視(=誤答率は高め)」へと探索の重心を移し、幅広い解を得るよう試みます(得られた解の分布は 3.2 節で可視化します)。重みが実際にどのように変化するかを、プロットで確認してみましょう。
import matplotlib.pyplot as plt
num_iterations = 5 # 最適化サイクル数
# 動的重みスケジュールを生成
weight_schedule = np.geomspace(3e-1, 3e1, num_iterations)
_, ax = plt.subplots(figsize=(10, 4))
ax.plot(range(1, num_iterations + 1), weight_schedule)
ax.set_xlabel("Number of iterations", fontsize=14)
ax.set_ylabel("Weight of feature", fontsize=14)
ax.set_yscale("log") # y軸を対数スケールに設定
ax.grid(True)
なお、今回はデモ環境の制約から、最適化サイクル数 (num_iterations) を 5 回に設定しています。サイクル数 100 回での実行結果
(パレートフロントのプロット) は
3.2
節で示していますので、そちらもご覧ください。また、本ノートブックをダウンロードすれば、ローカル環境などでサイクル数をご自由に設定して実行することもできますので、ぜひお試しください。
2.6. 最適化サイクルの実行¶
MultiOptimizer には最適化サイクルを実行するための関数 optimize()
がありますが、今回はサイクルごとにサロゲートモデルの重みを動的に変化させたいので、最適化サイクルをカスタマイズする必要があります。
optimize() での 1 サイクル分の処理を次の 4 ステップに分けて実装して最適化サイクルを実行します。
- これまでの学習データでサロゲートモデルを構築する
- 重みスケジュールに従い、特徴量側サロゲートモデルの重みを更新する(2.5 節を参照)
- 重み付き和のサロゲートモデルをイジングマシンで最小化し、新しいマスク(解)を得る
- 新しいマスクをブラックボックス関数(学習・評価)に通して実際の目的関数値を得た後、これを学習データに追加する
なお本サンプルでは説明を簡潔にするため、ステップ 4 では常に最良解 (solutions[0]) をそのまま採用し、既出解との重複回避などのフォールバック処理は省略しています
(詳細はこちら)。また、後の
3 章で目的関数値の推移をプロットするために、履歴 (objectives_opt) にも目的関数値を追加します。
import logging
from amplify_bbopt import AMPLIFY_BBOPT_LOGGER_NAME
logger = logging.getLogger(AMPLIFY_BBOPT_LOGGER_NAME)
# 最適化サイクルで得られる解を記録するリスト
objectives_opt = []
for n_iter in range(num_iterations):
current_weight = weight_schedule[n_iter]
logger.info(f"=== Iteration: {n_iter + 1}/{num_iterations} ===")
logger.info(f"current weight: {current_weight:.2f}")
# Step 1: サロゲートモデル関数の構築
optimizer.train_surrogate()
# Step 2: サロゲートモデルの重みを設定 (2つ目のサロゲートモデル (特徴量の最小化) を徐々に重視する)
optimizer.surrogate_model[1].weight = current_weight
# Step 3: イジングマシンによるサロゲートモデル関数の最適化 (最小化)
solutions, _ = optimizer.minimize_surrogate()
if len(solutions) == 0:
raise RuntimeError(f"No feasible solution was found in iteration {n_iter}")
# Step 4: 得られた最良解をデータセットに追加
new_solution = solutions[0]
new_objective = optimizer.evaluate_objective(new_solution)
optimizer.add_solution(new_solution, new_objective)
# 得られた解の記録 (可視化用)
objectives_opt.append(new_objective)
# 最適化履歴の出力
optimizer._log_objective()
# 初期学習データ
objectives_init = np.array(optimizer.training_data.y[:num_init_data])
# 最適化サイクルで得られた解の list を numpy 配列に変換
objectives_opt = np.array(objectives_opt)
# 特徴量割合を特徴量数に変換
objectives_init[:, 1] *= NUM_FEATURES
objectives_opt[:, 1] *= NUM_FEATURES
続いて、各目的関数値の推移をプロットする関数 plot_objectives
を定義し、グラフをプロットします。青線が初期学習データ、赤線が最適化サイクルで得られた解、黒線がそれまでの最良値 (累積最小値) の推移を表します。
# 各目的関数値のプロット (idx_sm: 何番目の目的関数か (sm: surrogate model))
def plot_objectives(obj_init, obj_opt, idx_sm, title=None):
obj_init = obj_init[:, idx_sm]
obj_opt = obj_opt[:, idx_sm]
num_init_data = len(obj_init)
# 累積最小値の計算
best_opt = [
min((obj_init.tolist() + obj_opt.tolist())[: i + 1])
for i in range(0, num_init_data + len(obj_opt))
]
_, ax = plt.subplots(figsize=(10, 6))
# 初期学習データのプロット (最適化サイクルの前なので、x軸は負の値を使用)
ax.plot(range(-num_init_data + 1, 1), obj_init, "b", marker="o", linestyle="-")
# 最適化サイクルで得られた解のプロット
ax.plot(range(1, len(obj_opt) + 1), obj_opt, "r", marker="o", linestyle="-")
# 累積最小値のプロット
ax.plot(range(-num_init_data + 1, len(obj_opt) + 1), best_opt, "k")
ax.set_xlabel("Number of iterations", fontsize=18)
if title is not None:
ax.set_ylabel(title, fontsize=18)
ax.tick_params(labelsize=18)
ax.grid(True)
plot_objectives(objectives_init, objectives_opt, 0, title="Error")
plot_objectives(objectives_init, objectives_opt, 1, title="Number of features")
# パレート最適集合の計算
def get_pareto_front(objs):
cand = np.ones(objs.shape[0], dtype=bool)
for i, c in enumerate(objs):
if cand[i]:
cand[cand] = np.any(objs[cand] < c, axis=1)
cand[i] = True
return cand
# 全ての解 (初期データ + 探索データ) からパレート最適集合を計算
objectives_combined = np.concatenate([objectives_init, objectives_opt])
pareto_mask = get_pareto_front(objectives_combined)
pareto_front = objectives_combined[pareto_mask]
pareto_front = pareto_front[pareto_front[:, 0].argsort()]
計算したパレート最適集合をグラフに描画します。青色の点が初期学習データ、赤-緑のグラデーションの点が最適化サイクルで得られた解
(赤は初期、緑は終盤のサイクルで得られた解)、黒の破線がパレート最適解を結んだ線、灰色の点線が 1.6 節で求めた全特徴量使用時の誤答率
(error_all_feat) を示す基準線です。
from matplotlib.colors import LinearSegmentedColormap
_, ax = plt.subplots(figsize=(10, 6))
# 初期学習データのプロット
ax.scatter(
objectives_init[:, 0], objectives_init[:, 1], color="blue", label="Initial data"
)
# 最適化サイクルで得られた解のプロット (探索順にグラデーション)
cm = LinearSegmentedColormap.from_list(
"custom_cm", [(0, "orangered"), (1, "limegreen")]
)
cols = np.linspace(0, 1, len(objectives_opt))
ax.scatter(
objectives_opt[:, 0],
objectives_opt[:, 1],
c=cols,
cmap=cm,
edgecolors="k",
label="Optimized solutions",
)
# パレートフロントのプロット
ax.plot(pareto_front[:, 0], pareto_front[:, 1], "--k", label="Pareto front")
# 全特徴量を使った場合の誤答率を基準線として描画
ax.axvline(
error_all_feat,
color="gray",
linestyle=":",
label=f"All-features error ({error_all_feat:.3f})",
)
# 軸ラベルの設定
ax.set_xlabel("Error", fontsize=18)
ax.set_ylabel("Number of features", fontsize=18)
ax.set_title("Pareto front", fontsize=18)
ax.legend(fontsize=12)
ax.grid(True)
ノートブックを実行すると、このセルの上にパレート最適集合がプロットされます。なお、今回はデモ環境の制約から、2.5 節で最適化サイクル数
(num_iterations) を 5 回に設定していますが、サイクル数を 100
回に増やして実行すると下の図のような結果が得られます。本ノートブックをダウンロードし、ローカル環境などでぜひお試しください。
探索が進むにつれて(赤→緑)、解が右下(特徴量数の少ない側)へ移っていく様子に注目してください。これが、重みを動かして幅広い解を探索した結果です。
黒の破線は、特徴量数と誤答率の間に存在するトレードオフの関係を示しています。特徴量数を削る (グラフ上で下に向かう) ほど誤答率が上がる (グラフ上で右に向かう) 一方で、基準線の近くでも特徴量数を大きく減らせる解が得られていることが分かります。
3.3. まとめ¶
本サンプルでは、誤答率と特徴量数というトレードオフの関係にある 2 つの目的を MultiOptimizer
で同時に扱い、サロゲートモデルの重みを最適化サイクル中で動的に変化させることで、1 回の最適化で多様な解を得ることができました。
得られたパレートフロントからは、全 784 特徴量を使った場合の誤答率(基準線)を大きく悪化させることなく特徴量数を削減できる解が得られていることが読み取れます。実務では、このパレートフロント上から「許容できる誤答率」や「使いたい特徴量数」といった要件に合致する解を選ぶことになります。