Loading post
Jul 06, 2026

姉妹記事「ブログの将棋AIが弱すぎたので、AIエージェント群に1日で作り直させた話」には、NNUE・蒸留・誤差逆伝播・量子化といった機械学習の言葉が説明抜きで大量に出てくる。この記事は、その本編を「言葉に詰まらずに読み切る」ための前提知識をゼロから積み上げる入門編だ。読者として想定しているのは、機械学習は完全に初めて/将棋はアマ二段くらいには指せる/コードは多少読めるという人。数式は最小限にして、比喩と、実際にコピペで動かせるPythonコードで説明する。専門用語は初めて出たところで必ずかみ砕く。すべての例を、本編に出てくる自作将棋AIの実装(
DistillNet、評価関数、やねうら王、NNUE)に紐づけて話す。
本編を読むのに必要な知識を、下から順に積み上げる。
急ぐなら、5章(CPU/GPU)と7章(用語集)だけでも本編の「なぜGPUで速くできないのか」「NNUEって何」という疑問は解ける。だが1〜4章を通して読むと、本編の「評価関数を審査員に例える」「山下りに例える」といった比喩が、なぜその比喩なのかまで腑に落ちるはずだ。
コンピュータに何かを「判断」させたいとき、やり方は大きく2つある。
やり方A:ルールを人間が全部書く。 これが普通のプログラミングだ。「もし○○なら△△せよ」を人間が言葉にして並べる。将棋の手書きの評価関数がまさにこれだった。本編に出てくる実装は、人間が読めるルールの足し算でできている:
score = 駒の損得; // 歩=100点、飛車=1040点… の合計差
score += 駒の位置ボーナス; // 「この駒はこのマスで+何点」の表
score += 玉の守りの枚数; // 玉の周囲の金銀の数
score += 囲いの形; // 矢倉・美濃・穴熊のパターン照合
score += 棒銀プレッシャー; // 「銀が2筋を上がってきたら受け側にマイナス」
return score; // 例: +250(先手が歩2.5枚ぶん良い)
一行ずつ、人間が「玉の守りが厚いと有利」「囲いが崩れると不利」と知っていることを書き下している。長所は、間違えたときに直せること——本編では「棒銀プレッシャーの項が盤面のdan4で切れていた」というバグをピンポイントで直している。短所は、人間が言葉にできる知識しか書けないこと。将棋二段の「なんとなくこの局面は先手持ち」という感覚を、全部ルールに落とすのは事実上不可能だ。
やり方B:ルールを書く代わりに、学習する仕組みを書く。 これが機械学習だ。人間は「玉が薄いなら−N点」というルールを一切書かない。代わりに、「100万局面と、その正解の点数」を大量に見せて、ルールを自分で見つけさせる。人間が書くのは「どうやって間違いを直しながらルールを探すか」という手続きだけで、中身のルールは機械が勝手に埋める。
この対比が機械学習の核心だ。標語にするとこうなる:
ルールを書く代わりに、ルールを学習する仕組みを書く。
同じ「盤面を見て点数を返す」という仕事を、2つのやり方で作ってみる。
驚くのは、機械学習版が中を開けても約59万個の名前のない数字が並んでいるだけ、という点だ。「玉の守り」という行はどこにもない。それでも、うまく学習すれば手書き版より正確に点数を当てられる。本編ではこの機械学習版(NNUE)が、最終的に手書き版にセルフプレイで77.1%勝った。
「中身が読めないのに、なぜ正しい答えを出せるのか」——本編が繰り返し問うこのテーマは、実は身近な例で説明できる。有段者の大局観だ。二段の人は局面を一目見て「先手持ち」と感じ取れるが、その判断過程を完全には言語化できない。「駒得だから」と後付けはできても、脳内の計算そのものは本人にも見えない。それでも判断は当たる。機械学習は、この「言語化を経由しない直感」を数字の塊として実装したものだ、と考えるとしっくりくる。
機械学習で「ルールを探す機械」の中身にはいくつか種類がある。将棋AI(と現代のAIの大半)で使われているのがニューラルネットワークだ。名前は脳の神経細胞(ニューロン)に由来するが、中身は生物とは関係なく、ただの掛け算と足し算の連鎖だと思ってよい。
一番小さい部品「ニューロン」がやることは単純だ。いくつかの入力の数字を受け取り、それぞれに「重み」という数字を掛けて、全部足す。 それだけ。
出力 = 入力1 × 重み1 + 入力2 × 重み2 + ... + 入力n × 重みn + バイアス
この「重み」こそが、機械学習で調整される中身だ。重み = 調整可能なたくさんのダイヤルだと思うといい。ダイヤルを回すと、そのニューロンが「どの入力をどれだけ重視するか」が変わる。本編の将棋ネットワーク(DistillNet)にはこのダイヤルが約59万個ある。学習とは、この59万個のダイヤルを「正解に近づくように」少しずつ回していく作業だ。
ニューロンを横に並べたものが「層(layer)」、層を縦に積んだものがネットワークだ。データは 入力層 → 隠れ層 → 出力層 と、掛け算と足し算を繰り返しながら流れていく。「隠れ層」というのは、入力でも出力でもない中間の層のこと(外から直接は見えないので「隠れ」)。
本編で実際に学習させているネットワーク DistillNet の形を見ると、話が一気に具体的になる:
入力: 盤面の特徴 2268個 + 持ち駒の情報
↓ 第1層(256個のニューロン) ← ここが一番大きく、速度をほぼ決める
↓ 第2層(32個のニューロン) ← 256から一気に1/8へ絞る(NNUE系の特徴)
↓ 第3層(1個のニューロン) ← 最終的に「評価値1個」を出す
出力: 数字1個(例: cp / 600 で、後で「+250点」のような評価値に戻す)
ここで一つ、直感に反する事実がある。掛け算と足し算だけを何層積んでも、実は1層と同じ表現力しか出ない。 数学的に、線形な変換(掛けて足す)を何回繰り返しても、まとめれば1回の線形変換に潰れてしまうからだ。これでは層を深くする意味がない。
そこで、各層の後に活性化関数という「まっすぐでない加工」を1回はさむ。これが入ることで初めて、ネットワークは「非線形」——曲がった、複雑なパターン——を表現できるようになる。「AかつBのときだけ強く反応する」といった、掛け算と足し算だけでは作れない判断が可能になる。
将棋NNUEで使う活性化関数は ClippedReLU(クリップド・レル) という単純なものだ。やることは「0未満は0に、1より大きいものは1に刈り込む」だけ。PyTorchでは torch.clamp(x, 0.0, 1.0) の一行で書ける。
入力が −3 → 出力 0 (0未満は0)
入力が 0.4 → 出力 0.4 (0〜1の間はそのまま)
入力が 2.5 → 出力 1 (1より上は1)
なぜ普通のReLU(0未満を0にするだけ、上限なし)ではなく、上も1で刈り込むのか。理由は本編でも重要な「量子化に強い」という性質にある。学習は小数(float)でやるが、本番のブラウザでは軽く速く動かすため、重みや途中の値を整数(int16)に変換する。これを量子化と呼ぶ。もし値が青天井だと、整数に押し込むときに桁あふれ(オーバーフロー)を起こしたり、大きな値に精度を食われたりする。値域が0〜1に固定されていれば、整数化しても壊れない。本編に「ClippedReLUを選んでいるのは趣味ではなく実利で、値域が0〜1に固定されるため、学習後にfloatをint16へ量子化してWASMの整数演算に載せても精度が崩れない」とあるのは、これのことだ。
まとめると、ニューラルネットワークは:
という機械だ。中身の59万個のダイヤルには名前がなく、「玉の守り」のような人間の概念とは対応しない。それでも、有段者の大局観のように、言葉にできないパターンを丸ごと数字の塊として持てる。手書き評価関数が「人間が言語化できた知識」しか持てないのに対し、ニューラルネットはやねうら王の100万問の判断からパターンを直接吸収する。これが、手書き版を超えられる理由だ。
「59万個のダイヤルを正解に近づくように回す」と書いたが、具体的にどうやるのか。ここが機械学習の心臓部だ。順に「間違いの測り方 → 直す方向の見つけ方 → 回し方の実務」と積み上げる。
学習を始めるには、まず「今どれだけ間違えているか」を測る物差しが要る。これが**損失関数(loss function、ロス)**だ。ネットワークの答えと正解のズレを、1個の数字にまとめる。lossが小さいほど正解に近い。 学習のゴールは「lossをできるだけ小さくする」ことに尽きる。
一番素朴な損失が MSE(Mean Squared Error、平均二乗誤差) だ。「ズレを二乗して平均する」だけ。将棋で言えば:
ある局面で、
やねうら王(先生)の正解 = +300点
ネット(生徒)の答え = +120点
ズレ = 180点
ズレの二乗 = 180 × 180 = 32400
これを何千局面ぶんも計算して平均したものが MSE。
なぜ二乗するのか。(1) プラスのズレもマイナスのズレも二乗すれば正になり、打ち消し合わない。(2) 大きなズレほど二乗で強く罰せられるので、「たまに大外しする」ことを嫌う学習になる。PyTorchでは F.mse_loss(予測, 正解) の一行だ。
lossという「間違いの大きさ」が測れた。次は「どうダイヤルを回せばlossが減るか」だ。ここで本編でも使われる比喩が効いてくる。
学習とは、目隠しで霧の山を下りる人である。
山の高さ = loss だと思ってほしい。谷底(loss最小=一番正解に近い)に降りたい。だが霧が深く、山全体の地図(正解のダイヤルの組)は誰にも見えない。見えるのは足元の傾きだけ。 足で地面を探れば「こっちが下り坂だ」とはわかる。だったら、一番急な下りの方向へ小さく一歩踏み出す。また傾きを測って一歩。これを延々と繰り返せば、いつか谷底に着く。
この「足元の傾き」を数学の言葉で**勾配(gradient)**と呼ぶ。正確には「各ダイヤルをほんの少し増やしたら、lossは増えるか減るか、どのくらい急に変わるか」を表す数字だ。ダイヤル1個ごとに1つの傾きがある。59万個のダイヤルなら59万個の傾き(=勾配)がある。この勾配の方向に少しずつダイヤルを回す手法を 勾配降下法(gradient descent) という。1回のダイヤル更新は、本質的にこの一行だ:
新しい重み = 今の重み − 学習率 × 傾き(勾配)
「傾きの方向に、学習率のぶんだけ動かす」。マイナスなのは「下り坂へ行く」から(傾きが正=上り方向なので、逆向きに動かすと下る)。
問題は、その傾き(勾配)をどうやって求めるかだ。素朴にやるなら「ダイヤルを1個だけほんの少しずらして、lossを測り直す」を59万回繰り返す——ネットの再評価を59万回。とても現実的ではない。
これを一撃で解くのが 誤差逆伝播(backpropagation、バックプロパゲーション、略してbackprop) だ。核心はこう:
出力側で出た「誤りの責任」を、出力から入力へと逆向きに配っていく。たった1回の逆向きパスで、全ダイヤルの傾きが同時に求まる。
将棋ネットで絵にするとこうなる:
出力のズレ「評価値が0.3低すぎた」
↓ 第3層の重みへ 「最終判断でこの特徴を軽視したせい」
↓ 第2層の重みへ 「その特徴が弱く出たのは、この縮約のせい」
↓ 盤面の入力テーブルへ 「そもそも『7六の歩』の行のこの数字が小さすぎた」
「評価値が低すぎた」という1個の誤りを、後ろの層から前の層へ、「この誤りのうち何割は君のせいだ」と責任を分配していく。数学的には微分の連鎖律(合成関数の微分)を機械的に適用しているだけだが、直感的には「誤りの責任のなすりつけを、出力側から入力側へ順にやる」と捉えれば十分だ。これで59万個の傾きが1回の計算で揃う。あとは3.2節の一行で全ダイヤルを一斉に回す。
厳密には、毎回の傾きは手元の一部のデータ(次節の「バッチ」)の上で測った推定値で、真の傾きとは少しズレる。この「毎歩わずかにブレる山下り」を**確率的勾配降下法(SGD, Stochastic Gradient Descent)**と呼ぶ。ブレはあるが、たくさん歩けば平均として正しい方向に降りていく。
山下りを実際に回すときの3つの言葉を、本編の実数字で定義する。
数字で実感すると、100万局面 × 40エポック ÷ バッチ256 ≒ 約15万歩の山下りだ。これがMacのGPUで数分〜十分で終わる。「機械学習=何日も回す大掛かりなもの」というイメージより、ずっと軽い。
学習には落とし穴がある。**過学習(overfitting、オーバーフィッティング)**だ。ネットが「パターンを学ぶ」のではなく、見せた問題の答えを丸暗記してしまう現象を指す。暗記した生徒は、練習問題は満点でも、初見の問題は解けない。
これを見抜く定石がホールドアウト(holdout)だ。手元のデータを分けておき、一部を学習には一切使わず、答え合わせ専用に取っておく。本編では「末尾4,000行をホールドアウトに分離」している。学習の途中でこの未見4,000局面を採点させ、そこでの誤差(val_mae などと呼ぶ、valはvalidation=検証の略)を見張る。練習データでの誤差は下がり続けているのに、未見データでの誤差が下がらない/逆に上がりだしたら、暗記が始まったサインだ。
将棋の文脈でこの教訓はさらに深い。本編の一番の反省は「セルフプレイの勝率で測ったら、人間との実戦では弱かった」というものだった。ホールドアウトの誤差(先生への近さ)に合格しても、それは「本番で愚行をしないこと」を保証しない。だから本編は最終的に、検証の物差しを「先生への近さ」から「人間の実戦棋譜での悪手率」へ差し替えている。代理の指標にいくら合格しても、最後は本番の振る舞いそのものを測れ——これは機械学習全般に通じる、この記事で一番重い教訓の一つだ。
ここまでの「損失 → 勾配 → backprop → 山下り」を、全部手で実装するのは大変だ。特にbackprop(微分の連鎖律を全層に適用)を手書きするのは、間違えやすく苦しい。それを全部自動でやってくれる道具が PyTorch(パイトーチ) だ。Facebook(現Meta)発の、ニューラルネットを作って学習させるためのPythonライブラリで、本編の学習コードもこれで書かれている。
loss.backward() と呼ぶだけで、その履歴を逆向きにたどって全ダイヤルの勾配を自動計算してくれる。3.3節を手で書かずに済む、ということ。optimizer.step() の一行で全ダイヤルを一斉に更新する。本編が使う AdamW は、素朴な勾配降下を賢くした定番(各ダイヤルごとに歩幅を自動調整する)。.to(device) の一言で、計算をCPUからGPUへ移せる。同じコードがCPUでもGPUでも動く(詳しくは5章)。言葉より動くコードだ。「y = 2x + 1 という関係を、正解を教えずにデータだけから学ばせる」超シンプルな例を作る。人間は「傾き2、切片1」を一度も書かない——ネットがデータから見つける。そのまま貼れば動く本物のPythonコードだ(各行にコメント)。
import torch # PyTorch本体
import torch.nn as nn # ニューラルネットの部品(層・損失など)
# --- 1. ダミーデータを作る(本当の関係は y = 2x + 1 だが、ネットには教えない) ---
x = torch.randn(200, 1) # 入力を200個ランダムに用意(形は200行×1列のテンソル)
y = 2 * x + 1 # 正解を計算(この「2」と「1」を当てさせたい)
# --- 2. ネットワークを定義する(入力1個 → 出力1個の、たった1層) ---
model = nn.Linear(1, 1) # nn.Linear(入力次元, 出力次元)。中に重み1個とバイアス1個
# = 調整すべきダイヤルは2個だけ。これが最小のニューラルネット
# --- 3. 損失関数とオプティマイザを用意する ---
loss_fn = nn.MSELoss() # 3.1節のMSE(平均二乗誤差)
optimizer = torch.optim.SGD(model.parameters(), lr=0.1) # 3.2節の勾配降下。歩幅lr=0.1
# --- 4. 学習ループ(山下り)を回す ---
for epoch in range(200): # 200歩ぶん山を下りる
pred = model(x) # ① 今のダイヤルで予測(順伝播=forward)
loss = loss_fn(pred, y) # ② 正解とのズレを1個の数字に(loss)
optimizer.zero_grad() # ③ 前の歩の勾配を消す(残っていると足し込まれてしまう)
loss.backward() # ④ 誤差逆伝播。全ダイヤルの傾きを自動計算(autogradの力)
optimizer.step() # ⑤ 傾きの方向へ全ダイヤルを一歩動かす
if epoch % 40 == 0: # たまに途中経過を表示
print(f"epoch {epoch:3d} loss={loss.item():.4f}")
# --- 5. 学習結果を確認する(2 と 1 に近づいているはず) ---
w = model.weight.item() # 学習した傾き(正解は 2.0)
b = model.bias.item() # 学習した切片(正解は 1.0)
print(f"学習結果: y = {w:.3f} x + {b:.3f} (正解は y = 2x + 1)")
走らせると、lossがみるみる0に近づき、最後に y = 1.998 x + 1.001 のような、ほぼ 2x + 1 の値が出る。人間は一度も「2」や「1」を書いていない。 データを見せて、間違いを測り(loss)、傾きを求め(backward)、一歩動かす(step)——このループだけで、ネットが関係を自力で見つけた。これが機械学習のすべての骨格だ。
覚えるべきは、この5行の型だ。どんなに大きなネットワークでも、学習ループの心臓部は常にこれと同じ形をしている:
pred = model(x) # 順伝播:予測する
loss = loss_fn(pred, y) # ズレを測る
optimizer.zero_grad() # 前の勾配を消す
loss.backward() # 逆伝播:勾配を求める
optimizer.step() # 一歩動かす
「1層だけでは物足りない」人向けに、隠れ層を挟んだ**多層パーセプトロン(MLP, Multi-Layer Perceptron)**の例も出す。DistillNetと同じ「入力 → 隠れ層 → 出力」の構造を、最小サイズで作ってみる。ネットの定義の仕方(nn.Module を継承する型)が、本編のDistillNetと同じであることに注目してほしい。
import torch
import torch.nn as nn
import torch.nn.functional as F # clampやreluなど「関数版」の部品
# --- ネットワークを class として定義する(PyTorchの標準的な書き方) ---
class SmallMLP(nn.Module): # nn.Module を継承するのがお約束
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(1, 16) # 入力1 → 隠れ層16
self.fc2 = nn.Linear(16, 16) # 隠れ層16 → 隠れ層16
self.fc3 = nn.Linear(16, 1) # 隠れ層16 → 出力1
def forward(self, x): # データが流れる順路を書く(順伝播の定義)
x = torch.clamp(self.fc1(x), 0.0, 1.0) # 第1層 → ClippedReLU(0〜1に刈る)
x = torch.clamp(self.fc2(x), 0.0, 1.0) # 第2層 → ClippedReLU
return self.fc3(x) # 第3層 → そのまま出力(活性化なし)
# --- 学習の型は 4.2 と まったく同じ ---
model = SmallMLP()
x = torch.randn(500, 1)
y = torch.sin(x) # 今度は sin(x) を学ばせてみる(曲線)
loss_fn = nn.MSELoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=0.01) # 本編と同じ AdamW
for epoch in range(2000):
pred = model(x)
loss = loss_fn(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
print(f"最終 loss = {loss.item():.5f}") # sin という曲線も、隠れ層のおかげで近似できる
y = sin(x) は曲線なので、2.3節で説明した通り「掛けて足す」だけの1層では表現できない。だが torch.clamp(ClippedReLU)で非線形性を入れた3層なら、ちゃんと近似できる。活性化関数が「非線形」を可能にするという2.3節の話が、ここで動くコードとして効いてくる。
pip install torch の一行(環境によっては公式サイトの案内に従う)。device = "mps" を指定する。MPSは Metal Performance Shaders の略で、AppleのGPUをPyTorchから叩くための窓口だ。# Mac(Apple Silicon)でGPUを使う
device = "mps" if torch.backends.mps.is_available() else "cpu" # mpsがあればApple GPU
model = model.to(device) # ネットのダイヤルをGPUメモリへ
x = x.to(device) # データもGPUへ(同じデバイスに揃える必要がある)
y = y.to(device)
# あとの学習ループは 4.2 とまったく同じ。.to(device) を足すだけ
本編で「学習はMacのGPU(MPS)で30万局面なら1本2〜4分」とあるのは、この device = "mps" のことだ。CPUのままでも動くが、GPUに載せると桁違いに速くなる(理由は次章)。
ここまでの型を頭に入れて、本編に出てくる本物の DistillNet を見ると、もう怖くない。4.3節の SmallMLP と骨格が完全に同じであることがわかるはずだ:
class DistillNet(nn.Module):
H1 = 256 # 第1層の幅(4.3の 16 が、ここでは 256 になっただけ)
H2 = 32 # 第2層の幅
def __init__(self):
super().__init__()
# ① 盤面の入力層。「7六に先手の歩」のような事実2268種それぞれに256次元の重みを割り当てた巨大な表。
# EmbeddingBag(mode="sum") は「成立している事実の行を全部引いて足す」を一発でやる部品
self.board = nn.EmbeddingBag(BOARD_FEATS + 1, self.H1, mode="sum", padding_idx=PAD_IDX)
# ② 持ち駒の入力層。歩を何枚持っているか等14種の枚数 → 同じ256次元へ(4.2で見た nn.Linear そのもの)
self.hand = nn.Linear(HAND_FEATS, self.H1)
# ③④ 縮約層。256 → 32 → 1。SmallMLP の fc2, fc3 と同じ役目
self.l2 = nn.Linear(self.H1, self.H2)
self.l3 = nn.Linear(self.H2, 1)
def forward(self, board_idx, hands):
a1 = self.board(board_idx) + self.hand(hands) # 盤面と持ち駒の寄与を合算
h1 = torch.clamp(a1, 0.0, 1.0) # ClippedReLU(SmallMLP と同じ活性化)
h2 = torch.clamp(self.l2(h1), 0.0, 1.0) # ClippedReLU
return self.l3(h2).squeeze(-1) # 出力1個(活性化なし)≈ cp / 600
新しい部品は**nn.EmbeddingBag** ひとつだけだ。これは「盤面の2268個の事実のうち、成立しているものの重みベクトルを全部引いて足す」を効率よくやる部品。将棋の局面は「盤上に駒がある場所」の集合なので、nn.Linear(2268, 256) に0/1のベクトルを掛けるのと数学的には同じだが、成立している事実だけを拾って足すほうが圧倒的に速い。だから専用部品を使う。それ以外——nn.Linear、torch.clamp、forward の書き方、nn.Module の継承——は、4.2〜4.3で見た最小例とまったく同じだ。
学習ループも同じ型:
out = model(board_idx, hands) # ① 順伝播:ミニバッチの局面を採点
loss = F.mse_loss(torch.sigmoid(out), target) # ② ズレを測る(sigmoidの意味は下)
optimizer.zero_grad() # ③ 前の勾配を消す
loss.backward() # ④ 誤差逆伝播(autograd)
optimizer.step() # ⑤ AdamW で一歩
一点だけ、torch.sigmoid(out) が新顔だ。sigmoid(シグモイド) は、どんな数字も0〜1に押し込むS字カーブの関数。将棋の評価値(−2000点〜+2000点のように広い範囲)をそのまま学ぶより、「勝率っぽい0〜1の値」に変換してから学ぶほうが安定するので挟んでいる。ここは深追い不要だが、本編の第3サイクルで大事件を起こすのがこのsigmoidなので、名前だけ覚えておくとよい。sigmoidは入力が大きくなると出力が1に貼りついて動かなくなる(「飽和」する)性質があり、本編ではこれが「決着局面で全71手が同じ点に見える」という『意味不明な手』の主犯になった。2章のClippedReLUとは別物なので混同しないこと(ClippedReLUは活性化関数、sigmoidは損失を計算する前の出力変換)。
本編には「なぜGPUで教師データ生成を速くできないのか」「なぜNNUE評価はCPUなのか」という問いが繰り返し出てくる。ここを理解すると、将棋パイプラインの設計が全部腑に落ちる。核心はCPUとGPUは得意分野が正反対という一点だ。
GPU(Graphics Processing Unit) はもともと画像処理用のチップで、「同じ単純な計算を、大量のデータに一斉に適用する」ことに特化している。何千個もの小さな計算コアが並んでいて、全員で同じ命令を別々のデータに対して同時に実行する(「1つの命令で複数のデータをまとめて処理する」仕組みをSIMDと呼ぶが、GPUはそれを何千倍にも拡大したイメージだ。SIMDは7章の用語集も参照)。
ニューラルネットの学習は、まさにこれだ。「256局面を第1層に通す」は、実体としては巨大な行列の掛け算で、何万個もの掛け算・足し算が互いに独立に走る。数百万局面をまとめて処理するのだから、GPUの独壇場になる。だから:
学習(training)は GPU 向き。 MacではMPS(Apple GPU)に載せると、CPUより桁違いに速い。
CPU(Central Processing Unit) は、少数の強力なコアで「次に何をするかが状況次第で変わる、逐次的な処理」を高速にこなす。「もしAならB、そうでなければC」という分岐が連続する、先の読めない仕事が得意だ。
将棋の探索(αβ法によるゲーム木の読み)が、まさにこれだ。「この手を指したら相手はどう応じる? その手は良さそう? ダメなら別の枝を切ろう」——先を読むまでどの枝を切るか分からない、予測不能な分岐の連鎖だ。GPUのように「全員が同じ命令」というわけにいかない。枝ごとに全く違う処理が要る。だから:
ゲーム木探索(αβ)は CPU 向き。 本編の教師データ生成が「自己対戦+やねうら王採点」で、どちらもCPUで回るのはこのためだ。
本編に「もっとGPUを使って速くできないの?」という当然の疑問への答えがある——ps(プロセスの負荷を見るコマンド)で見ると、律速は「局面を作る側」(自己対戦。CPUの逐次探索)で、そこはGPUと無関係だった。GPUを足しても、ボトルネックが別の場所なら空振りする。
ここが一番おもしろく、一番誤解されやすい。NNUEの評価(盤面→数字)は、中身が小さな行列積だ。「行列積ならGPUでは?」と思うのが自然だ。だが実際はCPUで動かす。 理由は評価の呼ばれ方にある。
探索中、評価関数は毎秒数十万回、逐次的に呼ばれる。「この局面を評価 → 次の枝へ → また評価 → …」と、1個ずつ順番に問い合わせが来る。まとめて数百万局面を一度に、ではなく、探索の進行に合わせて1個ずつだ。
ここでGPUの弱点が出る。GPUは「大量データを一斉に」で真価を発揮するが、1回の計算をGPUに投げるたびに、CPUからGPUへデータを送り、結果を受け取る通信コストがかかる。この往復コストは、NNUEの小さな行列積1回ぶんの計算より大きい。つまり:
探索中に1個ずつ評価するなら、GPUへ送る通信コストが計算そのものを上回る。だからNNUE評価はCPUで動かすほうが速い。
そもそもNNUEが「Efficiently Updatable(効率的に更新できる)」という設計思想を持ち、CPU+SIMDで速く動くよう作られているのは、この「探索中に逐次的に呼ばれる」使われ方に最適化した結果だ。本編でNNUE推論をWASM(ブラウザで動く高速なバイナリ)+SIMDで書き、int16量子化までしているのは、CPUで最速に動かすための一連の工夫なのだ。
以上を将棋プロジェクトに当てはめると、こうなる。同じプロジェクトの中で、CPUとGPUを役割ごとに使い分けている。
| 工程 | 中身 | 向いているのは | なぜ |
|---|---|---|---|
| 教師データ生成(自己対戦) | ゲーム木探索の連鎖 | CPU | 分岐だらけの逐次処理。GPUは苦手 |
| 教師の採点(やねうら王) | NNUE評価を逐次多数回 | CPU | 1個ずつ呼ばれる。GPU往復が損 |
| NNUEの学習(PyTorch) | 大量局面の行列積を一斉に | GPU(MPS) | まさにGPUの独壇場 |
| 本番の対局(ブラウザ) | 探索+NNUE評価 | CPU(WASM+SIMD) | 探索も評価も逐次。GPUの出番なし |
「生成=CPU、学習=GPU」——この一言が、本編のパイプライン全体を貫く原則だ。
ここまでの部品が揃うと、本編の背骨である**蒸留(distillation、ディスティレーション)**が理解できる。蒸留とは、強いが遅い「先生」の知識を、小さくて速い「生徒」に移す技術だ。「教師-生徒(teacher-student)」の関係とも呼ぶ。
将棋での登場人物はこうだ:
蒸留の手順は、これまでの章がそのまま繋がる:
なぜこんなことをするのか。 一言で言えば、本番の制約が「ブラウザで軽く・速く動くこと」だからだ。やねうら王をそのままブラウザに載せれば一番強いが、重すぎて訪問者の環境で快適に動かない。そこで「やねうら王の判断(100万問の正解)」だけを吸い出して、ブラウザで動く小さなネットに移植する。生徒は先生ほど賢くはなれないが、先生の知識のかなりの部分を、桁違いに軽い体で再現できる。これが蒸留の狙いだ。
本編の全ストーリー——「先生(やねうら王)に100万局面を採点させ → 生徒(NNUE)が真似るよう学習し → 量子化して本番投入し → 手書き評価に77.1%勝った」——は、この6章の蒸留のフローを、実データと実測値で埋めていく記録なのだ。
本編に出てくる言葉を、1〜2行で。上から順に読めば、だいたい依存関係の順になっている。
| 用語 | 意味(1〜2行) |
|---|---|
| 特徴量(feature) | ネットへの入力にする「事実」。将棋なら「7六に先手の歩がある」など2268種。成立=1、不成立=0 |
| 重み(weight) | ネットの中で調整される「ダイヤル」。掛け算に使う数字。将棋ネットで約59万個 |
| テンソル(tensor) | 数字の多次元配列。PyTorchの基本データ型。自分にかかった計算を覚えている |
| autograd | PyTorchの自動微分機能。loss.backward() で全重みの勾配を自動計算。backpropの手書きを不要にする |
| 損失(loss) | 「どれだけ間違えたか」を表す1個の数字。小さいほど正解に近い。学習はこれを最小化する営み |
| MSE | 平均二乗誤差。ズレを二乗して平均した損失。F.mse_loss |
| MAE / cp | MAEは平均絶対誤差(ズレの絶対値の平均)。cpはセンチポーン、歩1枚を約100とする評価の単位。本編の生徒は先生とのMAEが約450cp |
| 勾配(gradient) | 「この重みを増やすとlossがどう変わるか」の傾き。重み1個ごとに1つ。山下りの「足元の傾斜」 |
| 勾配降下法 | 勾配の逆向きに重みを少しずつ動かしてlossを減らす手法。「新しい重み = 今の重み − 学習率 × 勾配」 |
| 誤差逆伝播(backprop) | 出力の誤りを入力側へ逆向きに配り、1回の逆パスで全重みの勾配を同時に求める仕組み |
| 学習率(lr) | 山下りの1歩の大きさ。大きすぎ=発散、小さすぎ=収束せず。本編は1e-3からコサイン減衰 |
| バッチ(batch) | 一度にまとめて処理する局面のかたまり。本編は256。1バッチ処理=山下り1歩 |
| エポック(epoch) | 全データをひと通り使い切る1周。本編は40エポック(100万局面を40回なめる) |
| 過学習(overfitting) | パターンを学ばず問題の答えを丸暗記する現象。未見データで初めて露見する |
| ホールドアウト(holdout) | 学習に使わず答え合わせ専用に取っておくデータ。過学習の検出に使う。本編は末尾4,000局面 |
| 活性化関数 | 各層の後に挟む非線形の加工。これがないと何層積んでも1層と同じ表現力しか出ない |
| ClippedReLU | 「0未満は0、1超は1に刈る」活性化関数。torch.clamp(x,0,1)。値域が0〜1なので量子化に強い |
| sigmoid | 何でも0〜1に押し込むS字関数。学習前の出力変換に使う。飽和(1に貼りつく)が本編第3サイクルの事件の主犯 |
| 推論(inference) | 学習済みのネットで実際に答えを出すこと。対局中に走るのはこれ(学習は事前に1回だけ) |
| 量子化(quantization) | float(小数)をint16(整数)に変換して軽く速くすること。ClippedReLUのおかげで精度を保てる |
| MPS | Metal Performance Shaders。AppleのGPUをPyTorchから使う窓口。device = "mps" |
| SIMD | 1命令で複数データを同時処理する仕組み。CPUでNNUE評価を高速化。本編で評価6.2倍 |
| NNUE | Efficiently Updatable Neural Network。将棋発祥の、効率的に差分更新できる小型評価ネット。CPU+SIMDで高速に動く |
| 蒸留(distillation) | 強くて遅い先生(やねうら王)の知識を、小さくて速い生徒(NNUE)に移す技術。本編の背骨 |
| 評価関数 | 盤面を1枚見て数字を1個返すもの。「手の良し悪し」は決めない(それは探索の仕事)。審査員に相当 |
| 探索(search) | 数手先まで指し進めて評価関数に点を聞き、一番良い手を選ぶ処理。評価関数を連れ回すツアーガイドに相当 |
以上が、姉妹記事「ブログの将棋AIが弱すぎたので、AIエージェント群に1日で作り直させた話」を、言葉に詰まらずに読み切るための前提知識だ。本編はここで積み上げた部品——損失・勾配・誤差逆伝播・PyTorch・CPU/GPUの使い分け・NNUE・蒸留・量子化——を、実際のバグ調査と、数字を省かない実測の中で総動員していく。「評価関数は審査員、探索はツアーガイド」「学習は目隠しで霧の山を下りる」「セルフプレイでは測れないものを実戦で測る」——本編のこうした比喩と教訓が、この記事を読んだあとなら、なぜその比喩なのかまで含めて腑に落ちるはずだ。
では、本編へどうぞ。