🛠️ Lab

Mini NNUE Lab — 自分で組んで理解する

NNUE 詳細ページ で仕組みを学んだあと、もう一歩踏み込みたい人のための「動くプログラムを自分で組み立てる」実習。 JavaScript の素のコード 200 行ほどで本物の NNUE と同じ構造のミニ版が作れます。 ブラウザ上で動かしながら「特徴ベクトル」「重み」「評価値」が 具体的に何の数字なのか を体感してください。

作るもの — Mini NNUE のスペック

実物のNNUEは40,000次元・数百万パラメータで巨大すぎるので、以下のように 極端に縮小 したミニ版を作ります:

項目本物のNNUEMini NNUE(このページで作る)
盤の大きさ9×95×5
駒の種類14種(成駒含む)2種(玉と銀)
持ち主先手・後手先手のみ
特徴ベクトル次元約41,024625(=25×25)
第1層の埋め込み256×2=512次元16次元
中間層32→328
パラメータ数約2,100万約1万
学習数億局面 / 数日このページではランダム重みのみ(Step 6で学習も)

でも構造は同じ。スケールを縮めただけで、原理は本物と一致しています。

Step 1: 局面を表現する

5×5 の盤を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 の盤では:

つまり 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 — 実際に動かしてみる

ここまでのコードを このページにそのまま組み込んでいます。下の盤面でクリックして駒を配置 → 「評価する」 でリアルタイム計算:

操作方法

📋 盤面(5×5)

📊 出力

活性化された特徴ビット(玉×銀ペア)
(盤を設定して「評価する」を押す)
h1 (16次元 埋め込み)
h2 (8次元 中間層)
📊 評価値(スカラー1個)

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)のコンソールでもそのままコピペで動きます。試行錯誤しながら理解を深めてください。