Mini NNUE Lab — 自分で組んで理解する
NNUE 詳細ページ で仕組みを学んだあと、もう一歩踏み込みたい人のための「動くプログラムを自分で組み立てる」実習。 JavaScript の素のコード 200 行ほどで本物の NNUE と同じ構造のミニ版が作れます。 ブラウザ上で動かしながら「特徴ベクトル」「重み」「評価値」が 具体的に何の数字なのか を体感してください。
作るもの — Mini NNUE のスペック
実物のNNUEは40,000次元・数百万パラメータで巨大すぎるので、以下のように 極端に縮小 したミニ版を作ります:
| 項目 | 本物のNNUE | Mini NNUE(このページで作る) |
|---|---|---|
| 盤の大きさ | 9×9 | 5×5 |
| 駒の種類 | 14種(成駒含む) | 2種(玉と銀) |
| 持ち主 | 先手・後手 | 先手のみ |
| 特徴ベクトル次元 | 約41,024 | 625(=25×25) |
| 第1層の埋め込み | 256×2=512次元 | 16次元 |
| 中間層 | 32→32 | 8 |
| パラメータ数 | 約2,100万 | 約1万 |
| 学習 | 数億局面 / 数日 | このページではランダム重みのみ(Step 6で学習も) |
でも構造は同じ。スケールを縮めただけで、原理は本物と一致しています。
Step 1: 局面を表現する
5×5 の盤を2次元配列で表現します。各マスに数値:
0= 空1= 玉(先手)2= 銀(先手)
// === Step 1: 局面の定義 ===
// 5x5 の盤。0=空、1=玉、2=銀
const board = [
[0, 0, 0, 0, 2], // 1段目(一番上)
[0, 0, 0, 0, 0],
[0, 2, 0, 0, 0], // 銀が33に
[0, 0, 0, 0, 0],
[1, 0, 0, 0, 0], // 玉が51に
];
console.log("盤面:");
board.forEach(row => console.log(row.join(' ')));
Step 2: HalfKP風の特徴抽出
HalfKP の原理:「玉の位置 × もう1つの駒の位置」のペアを全部数えて、特徴ビットを立てます。
5×5 の盤では:
- 玉の位置:25通り(5×5マス)
- 銀の位置:25通り
- 組合せ:25 × 25 = 625通り
つまり 625次元のベクトルを用意し、現局面に該当するビットだけを1にします(残りは全部0=スパース)。
// === Step 2: HalfKP風の特徴抽出 ===
function extractFeatures(board) {
const FEATURE_DIM = 25 * 25; // 625次元
const features = new Array(FEATURE_DIM).fill(0);
// 玉の位置を見つける(25通りのうちの1つ)
let kingPos = -1;
for (let r = 0; r < 5; r++) {
for (let c = 0; c < 5; c++) {
if (board[r][c] === 1) { // 玉
kingPos = r * 5 + c; // 0〜24
}
}
}
if (kingPos === -1) throw new Error("玉が盤上にいない!");
// 銀の位置を全部見つけて、玉×銀のペアを特徴ビットに変換
for (let r = 0; r < 5; r++) {
for (let c = 0; c < 5; c++) {
if (board[r][c] === 2) { // 銀
const silverPos = r * 5 + c;
// 「玉=kingPos × 銀=silverPos」のペア → 一意なindex
const featureIdx = kingPos * 25 + silverPos;
features[featureIdx] = 1;
}
}
}
return features;
}
const features = extractFeatures(board);
// 1が立っているindexを表示
const activeIndices = features
.map((v, i) => v === 1 ? i : -1)
.filter(i => i >= 0);
console.log("1が立っているindex:", activeIndices);
console.log("総ビット数:", features.length);
console.log("1の数:", activeIndices.length);
console.log("密度:", (activeIndices.length / features.length * 100).toFixed(2) + "%");
実行すると、こんな出力になるはずです:
1が立っているindex: [504, 561] // 例
総ビット数: 625
1の数: 2
密度: 0.32%
これがスパース性!625ビット中、わずか2ビットだけが1です。
Step 3: 浅いMLP を組む
Mini NNUE の構造は 4層:
625次元(HalfKP特徴)
↓ W₁
16次元(埋め込み層)+ ReLU
↓ W₂
8次元(中間層)+ ReLU
↓ W₃
1次元(評価値)
重みをランダムに初期化します。学習しないと意味のある評価値にはなりませんが、forward pass の流れは確認できます。
// === Step 3: ネットワーク重みの初期化 ===
function randomWeights(inDim, outDim) {
// [-0.1, +0.1] の範囲で初期化
const w = [];
for (let i = 0; i < outDim; i++) {
w.push([]);
for (let j = 0; j < inDim; j++) {
w[i].push((Math.random() - 0.5) * 0.2);
}
}
return w;
}
function randomBiases(dim) {
return Array.from({length: dim}, () => (Math.random() - 0.5) * 0.2);
}
const INPUT_DIM = 625;
const HIDDEN1 = 16;
const HIDDEN2 = 8;
const W1 = randomWeights(INPUT_DIM, HIDDEN1); // 625 → 16
const b1 = randomBiases(HIDDEN1);
const W2 = randomWeights(HIDDEN1, HIDDEN2); // 16 → 8
const b2 = randomBiases(HIDDEN2);
const W3 = randomWeights(HIDDEN2, 1); // 8 → 1
const b3 = randomBiases(1);
console.log("W1の形状:", W1.length, "×", W1[0].length); // 16 × 625
console.log("総パラメータ:",
625*16 + 16 + 16*8 + 8 + 8*1 + 1, // = 10153
"個"
);
Step 4: Forward Pass — 評価値を計算する
特徴ベクトルをネットに流して、最終的な評価値(スカラー1個)を出します。
// === Step 4: Forward Pass ===
function relu(x) {
return Math.max(0, x);
}
function forwardPass(features) {
// ---- 第1層: 625 → 16 ----
// ★ スパース最適化:features の値が1のところだけ加算
const h1 = [...b1]; // バイアスで初期化
for (let j = 0; j < features.length; j++) {
if (features[j] === 1) { // 0との掛け算は無意味なのでスキップ
for (let i = 0; i < HIDDEN1; i++) {
h1[i] += W1[i][j]; // ★ x[j]=1 なので掛け算不要
}
}
}
// ReLU
for (let i = 0; i < HIDDEN1; i++) h1[i] = relu(h1[i]);
// ---- 第2層: 16 → 8 ----
const h2 = [...b2];
for (let i = 0; i < HIDDEN2; i++) {
for (let j = 0; j < HIDDEN1; j++) {
h2[i] += W2[i][j] * h1[j];
}
h2[i] = relu(h2[i]);
}
// ---- 出力層: 8 → 1 ----
let score = b3[0];
for (let j = 0; j < HIDDEN2; j++) {
score += W3[0][j] * h2[j];
}
return { h1, h2, score };
}
const result = forwardPass(features);
console.log("h1 (16次元):", result.h1.map(v => v.toFixed(3)));
console.log("h2 (8次元):", result.h2.map(v => v.toFixed(3)));
console.log("評価値:", result.score.toFixed(3));
💡 注目してほしいトリック: 第1層のループで、features[j] === 1 のときだけ加算しています。 これが「巨大なスパース入力でも、計算するのはアクティブな数十ビットだけ」というNNUEの肝。 625個全部に掛け算しないので、めちゃくちゃ速い。
🛝 インタラクティブ Lab — 実際に動かしてみる
ここまでのコードを このページにそのまま組み込んでいます。下の盤面でクリックして駒を配置 → 「評価する」 でリアルタイム計算:
操作方法
- マスをクリック:空 → 玉 → 銀 → 空 と切り替わる
- 玉は 盤に1つだけ 置ける。新しい場所をクリックすると元の玉は消える
- 「🎲 重みを再生成」:ランダム初期化をやり直す(評価値が変わるはず)
- 「⚡ 評価する」:現在の盤面で forward pass を実行
📋 盤面(5×5)
📊 出力
Step 5: 差分更新を実装してみる
ここまでは 「毎回ゼロから forward pass」 をやっていますが、本物のNNUEの真骨頂は 差分更新。実装はこんな感じです:
// === Step 5: 差分更新 ===
// 前局面の h1(accumulator)を覚えておいて、変化したビットだけ加減算する
class NNUEAccumulator {
constructor() {
this.h1 = [...b1]; // 初期状態(バイアスのみ)
}
/**
* 1手分の差分を適用する
* @param {number[]} addedFeatures 新たに1になった特徴のindex
* @param {number[]} removedFeatures 0になった特徴のindex
*/
update(addedFeatures, removedFeatures) {
// 追加:W1 の該当列を足す
for (const j of addedFeatures) {
for (let i = 0; i < HIDDEN1; i++) {
this.h1[i] += W1[i][j];
}
}
// 削除:W1 の該当列を引く
for (const j of removedFeatures) {
for (let i = 0; i < HIDDEN1; i++) {
this.h1[i] -= W1[i][j];
}
}
// ※ ReLU はあとで forward 時に適用(差分更新では生の値を保持)
}
evaluate() {
// h1 に ReLU を適用
const h1_relu = this.h1.map(relu);
// 残りの層は普通に
const h2 = [...b2];
for (let i = 0; i < HIDDEN2; i++) {
for (let j = 0; j < HIDDEN1; j++) {
h2[i] += W2[i][j] * h1_relu[j];
}
h2[i] = relu(h2[i]);
}
let score = b3[0];
for (let j = 0; j < HIDDEN2; j++) {
score += W3[0][j] * h2[j];
}
return score;
}
}
// 使い方
const acc = new NNUEAccumulator();
acc.update([504, 561], []); // 初期局面の特徴を追加
console.log("初期評価:", acc.evaluate());
// 銀を1マス動かしたら:
acc.update([562], [561]); // 561が消えて、562が立つ
console.log("銀を動かしたあと:", acc.evaluate());
ポイント:1手指すと特徴ビットは数個しか変化しない(消える駒と追加される駒)ので、変化したぶんだけ加減算すればOK。 625次元全部を再計算するより、10〜100倍速い。
Step 6: 学習(gradient descent)を1行ずつ書く
最後のステップ:このネットを 実際に学習させて みます。 「玉と銀の距離が近いと評価値+1、遠いと-1」というルールを覚えさせてみましょう:
// === Step 6: 教師あり学習 ===
// 教師データ生成:1000局面 + 正解評価値
function generateTrainingData(N) {
const data = [];
for (let n = 0; n < N; n++) {
// ランダムな盤面
const board = Array.from({length: 5}, () => Array(5).fill(0));
// 玉を1個、ランダム配置
const kingPos = Math.floor(Math.random() * 25);
board[Math.floor(kingPos/5)][kingPos%5] = 1;
// 銀を1個、ランダム配置(玉と被らない)
let silverPos;
do { silverPos = Math.floor(Math.random() * 25); }
while (silverPos === kingPos);
board[Math.floor(silverPos/5)][silverPos%5] = 2;
// 正解:玉と銀のマンハッタン距離が近いほど+の評価
const dist = Math.abs(Math.floor(kingPos/5) - Math.floor(silverPos/5))
+ Math.abs(kingPos%5 - silverPos%5);
const target = (8 - dist) / 8; // 距離0→+1.0、距離8→0
data.push({
features: extractFeatures(board),
target
});
}
return data;
}
// 訓練ループ(簡略版:W3 と b3 だけ学習)
function trainOneEpoch(data, lr = 0.01) {
let totalLoss = 0;
for (const { features, target } of data) {
// Forward
const { h1, h2, score } = forwardPass(features);
// 損失(MSE)
const error = score - target;
totalLoss += error * error;
// 勾配計算(出力層のみ、シンプル化)
// ∂L/∂W3 = error * h2, ∂L/∂b3 = error
for (let j = 0; j < HIDDEN2; j++) {
W3[0][j] -= lr * error * h2[j];
}
b3[0] -= lr * error;
}
return totalLoss / data.length;
}
// 実行
const trainData = generateTrainingData(1000);
for (let epoch = 0; epoch < 50; epoch++) {
const loss = trainOneEpoch(trainData);
if (epoch % 10 === 0) {
console.log(`Epoch ${epoch}: loss = ${loss.toFixed(4)}`);
}
}
この簡易学習だけでも、損失が 徐々に下がっていく のが確認できます。 実物のNNUEではすべての層 W₁/W₂/W₃ を 誤差逆伝播で学習しますが、原理は同じです。
💡 ここまで来たあなたへ: これで NNUE の すべての要素(特徴抽出 / スパース計算 / forward / 差分更新 / 学習)を自分の手で組み立てました。 本物の NNUE はこれを 50倍以上のスケールで動かしているだけ。原理は同じです。
次のステップ — 本物に近づける
このミニ実装を起点に、本物のNNUEに段階的に近づける方向性:
| 拡張 | 何が必要か |
|---|---|
| ① 9×9盤 + 14駒種 | 特徴ベクトルが約41,024次元になる。本物のHalfKP |
| ② 256x2 構造 | 先手玉視点と後手玉視点を別々に埋め込み、連結 |
| ③ 量子化(int8) | 重みをfloat32→int8に変換。SIMDで並列計算 |
| ④ ClippedReLU | Math.max(0, Math.min(127, x)) に置き換え |
| ⑤ 完全な誤差逆伝播 | W₁/W₂/W₃ すべて勾配計算(連鎖律) |
| ⑥ 教師データの自動生成 | 強いエンジンで自己対局し、評価値と勝敗を記録 |
| ⑦ α-β探索との統合 | やねうら王のような探索エンジンに評価関数として組み込む |
⑥〜⑦ までやれば、世界トップの将棋AIに近づきます。 すべて公開された技術なので、本気で取り組めば「ワン・アイデア」で独自の評価関数を作ることも可能です(第8章 §6 を参照)。
🎓 このページのコードを自分の環境で動かしたい人へ
mini-nnue.js として保存し、node mini-nnue.js で実行できます。
ブラウザの DevTools(F12)のコンソールでもそのままコピペで動きます。試行錯誤しながら理解を深めてください。