AIの背後にある数学: シンプルに始めましょう

通常、100未満の2つの数字を足すプログラムをどのように書きますか?簡単ですよね?通常は、次のようなシンプルなものを書くでしょう:

#include <iostream>
using namespace std;

int main() {
    int a, b, sum;

    cout << "最初の数字を入力してください: ";
    cin >> a;

    cout << "2番目の数字を入力してください: ";
    cin >> b;

    sum = a + b;

    cout << "合計は: " << sum << endl;

    return 0;
}

でも、もっとエキサイティングな方法を試してみましょう—機械に自分で足し算を学ばせてみましょう。ニューラルネットワークは現代のAIの中心にあり、画像認識や言語翻訳からChatGPTのような高度なシステムまで、さまざまなものを支えています。

この記事では、ニューラルネットワークが「学習」して2つの数字を足す方法を示します。これはハードコードされたルールに従うのではなく、訓練を通じて行います。ニューラルネットワークがパターンを見つけることで「足す」方法を学ぶ様子を見ていきます。実際にこのプロセスを実現する数字(カーテンの後ろの魔法)も公開します。

訓練されたニューラルネットワークの内部構造を覗いてみましょう:

// 隠れ層の重みとバイアス
double weight1_node1 = 0.658136;
double weight2_node1 = 0.840666;
double bias_node1 = -0.893218;

double weight1_node2 = -0.720667;
double weight2_node2 = -0.369172;
double bias_node2 = 0.036762;

double weight1_node3 = 0.512252;
double weight2_node3 = 0.292342;
double bias_node3 = -0.0745917;

// 出力層の重みとバイアス
double weight_out_node1 = 1.93108;
double weight_out_node2 = -0.718584;
double weight_out_node3 = 0.589741;
double bias_out = -0.467899;

一見すると、これらの値はランダムに見えるかもしれませんが、これは2つの数字を足す方法を学習できる小さな人工脳を表しています。

この式を使って出力を計算します。今は複雑に見えるかもしれませんが、ついてきてください:

ここで、x1x2は足したい2つの数字です(100で割って正規化されています):

double x1 = 0.3; // 30 / 100
double x2 = 0.5; // 50 / 100

// 隠れ層
double z1 = x1 * weight1_node1 + x2 * weight2_node1 + bias_node1;
double h1 = sigmoid(z1);

double z2 = x1 * weight1_node2 + x2 * weight2_node2 + bias_node2;
double h2 = sigmoid(z2);
double z3 = x1 * weight1_node3 + x2 * weight2_node3 + bias_node3;
double h3 = sigmoid(z3);

// 出力層
double sum = (h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out) * 200.0f;

なぜ200で掛けるのかを分解してみましょう: 最初に、入力と出力を0から1の間に収めるためにスケーリングしました。100未満の2つの数字の最大合計は200なので、出力を200で掛けて元に戻します。

シグモイド関数:

double sigmoid(double x) {
    return 1.0 / (1.0 + exp(-x));
}  

実際の数字で試してみましょう:

ここで、x1 = 30x2 = 50を使います。まず正規化します:

x1 = 30 / 100 = 0.3
x2 = 50 / 100 = 0.5

次に、数学を実行します:

z1 = (0.3 × 0.658136) + (0.5 × 0.840666) - 0.893218
   ≈ 0.1974408 + 0.420333 - 0.893218
   ≈ -0.2754442
h1 = sigmoid(z1) ≈ 0.431844

z2 = (0.3 × -0.720667) + (0.5 × -0.369172) + 0.036762
   ≈ -0.2162001 - 0.184586 + 0.036762
   ≈ -0.3640241
h2 = sigmoid(z2) ≈ 0.409872

z3 = (0.3 × 0.512252) + (0.5 × 0.292342) - 0.0745917
   ≈ 0.1536756 + 0.146171 - 0.0745917
   ≈ 0.2252549
h3 = sigmoid(z3) ≈ 0.556078

// 最終出力層
output = (h1 × 1.93108) + (h2 × -0.718584) + (h3 × 0.589741) - 0.467899
       ≈ (0.834389) + (-0.294320) + (0.328013) - 0.467899
       ≈ 0.400183

予測された合計 = 0.400183 × 200 ≈ 80.0366
// なぜ200で掛けるのか?訓練中に、すべての入力と出力を0から1の間に収めて、学習の安定性を向上させました。2つの入力の最大合計は100 + 100 = 200なので、ネットワークの出力を200で掛けて元に戻します。

バン!結果は約80で、30と50の正しい合計です。

この小さなネットワークが本当に足し算できるか信じられないなら、別の数字で再テストしてみましょう: 20 と 30。

まず入力を正規化します:

x1 = 20 / 100 = 0.2
x2 = 30 / 100 = 0.3

次に計算します:

z1 = (0.2 × 0.658136) + (0.3 × 0.840666) - 0.893218
   ≈ 0.1316272 + 0.2521998 - 0.893218
   ≈ -0.509391
h1 = sigmoid(z1) ≈ 0.375241

z2 = (0.2 × -0.720667) + (0.3 × -0.369172) + 0.036762
   ≈ -0.1441334 - 0.1107516 + 0.036762
   ≈ -0.218123
h2 = sigmoid(z2) ≈ 0.445714

z3 = (0.2 × 0.512252) + (0.3 × 0.292342) - 0.0745917
   ≈ 0.1024504 + 0.0877026 - 0.0745917
   ≈ 0.1155613
h3 = sigmoid(z3) ≈ 0.528860

// 最終出力層
output = (h1 × 1.93108) + (h2 × -0.718584) + (h3 × 0.589741) - 0.467899
       ≈ (0.724688) + (-0.320095) + (0.311644) - 0.467899
       ≈ 0.248338

予測された合計 = 0.248338 × 200 ≈ 49.6676

約49.67を予測しました。これは実際の合計である50に非常に近いです!

すごいですね?

さて... ニューラルネットワークとは一体何でしょう?

簡単な紹介: ニューラルネットワークは、あなたの脳の数学的バージョンのようなものです。これは、入力を重みで掛け算し、バイアスを加え、活性化関数を適用するユニット(ニューロン)で構成されています。一般的な活性化関数はシグモイドです:

sigmoid(x) = 1 / (1 + e^(-x))

これにより、任意の入力が0から1の間の値に圧縮され、ネットワークが複雑なパターンを捉えることができるようになります。

これがシグモイド関数のグラフで、入力がどのように滑らかに0から1の値に変換されるかが明確に示されています:

他にもReLU(Rectified Linear Unit)、tanh、softmaxなどの活性化関数がありますが、それぞれ異なる使用ケースと特性があります。しかし、シグモイドはシンプルで滑らかなので、私たちのような小さなニューラルネットワークには最適な出発点です。

セットアップ: 私たちのネットワークに足し算を教える

この実験のために、私たちは次のような小さなネットワークを作成しました:

  • 2つの入力ノード (足す2つの数字用)
  • 3つの隠れノード (シグモイドの魔法で重い作業を行う)
  • 1つの出力ノード (合計を出す)

こちらが、2つの数字を足すために使用しているニューラルネットワークの構造を示す図です。各入力ノードがすべての隠れ層ノードに接続され、重みとバイアスがどのように適用され、すべてが最終出力に向かって流れるかがわかります:

そして、はい、すべてをスケーリングしました。数字が100未満なので、入力を100で割り、最後に出力を200で戻します。

これがニューラルネットワークを使って2つの数字を足すために書いたコードです:

#include <iostream>
#include <cmath>
using namespace std;

float sigmoid(float x) {
    return 1.0f / (1.0f + exp(-x));
}

float sigmoid_derivative(float y) {
    return y * (1 - y); // y = sigmoid(x)
}

int main() {
    float weight1_node1 = 0.5f, weight2_node1 = 0.5f, bias_node1 = 0.0f;
    float weight1_node2 = -0.5f, weight2_node2 = -0.5f, bias_node2 = 0.0f;
    float weight1_node3 = 0.25f, weight2_node3 = -0.25f, bias_node3 = 0.0f;

    float weight_out_node1 = 0.5f, weight_out_node2 = 0.5f, weight_out_node3 = 0.5f, bias_out = 0.0f;

    float learning_rate = 0.01f;
    int epochs = 1000;

    for (int epoch = 0; epoch < epochs; ++epoch) {
        float total_loss = 0.0f;
        for (int a = 0; a < 100; ++a) {
            for (int b = 0; b < 100; ++b) {
                float x1 = a / 100.0f;
                float x2 = b / 100.0f;
                float target = (a + b) / 200.0f; // 目標を0-1の範囲に正規化

                // 隠れ層のフォワードパス
                float z1 = x1 * weight1_node1 + x2 * weight2_node1 + bias_node1;
                float h1 = sigmoid(z1);

                float z2 = x1 * weight1_node2 + x2 * weight2_node2 + bias_node2;
                float h2 = sigmoid(z2);

                float z3 = x1 * weight1_node3 + x2 * weight2_node3 + bias_node3;
                float h3 = sigmoid(z3);

                // 線形活性化の出力層
                float y_pred = h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out;

                float error = target - y_pred;
                total_loss += error * error;

                // バックワードパス(出力層 - 線形)
                float delta_out = error;
                float grad_out_w1 = delta_out * h1;
                float grad_out_w2 = delta_out * h2;
                float grad_out_w3 = delta_out * h3;
                float grad_out_b = delta_out;

                // バックワードパス(隠れ層)
                float delta_h1 = delta_out * weight_out_node1 * sigmoid_derivative(h1);
                float delta_h2 = delta_out * weight_out_node2 * sigmoid_derivative(h2);
                float delta_h3 = delta_out * weight_out_node3 * sigmoid_derivative(h3);

                float grad_w1_node1 = delta_h1 * x1;
                float grad_w2_node1 = delta_h1 * x2;
                float grad_b_node1 = delta_h1;

                float grad_w1_node2 = delta_h2 * x1;
                float grad_w2_node2 = delta_h2 * x2;
                float grad_b_node2 = delta_h2;

                float grad_w1_node3 = delta_h3 * x1;
                float grad_w2_node3 = delta_h3 * x2;
                float grad_b_node3 = delta_h3;

                // 重みを更新
                weight_out_node1 += learning_rate * grad_out_w1;
                weight_out_node2 += learning_rate * grad_out_w2;
                weight_out_node3 += learning_rate * grad_out_w3;
                bias_out += learning_rate * grad_out_b;

                weight1_node1 += learning_rate * grad_w1_node1;
                weight2_node1 += learning_rate * grad_w2_node1;
                bias_node1 += learning_rate * grad_b_node1;

                weight1_node2 += learning_rate * grad_w1_node2;
                weight2_node2 += learning_rate * grad_w2_node2;
                bias_node2 += learning_rate * grad_b_node2;

                weight1_node3 += learning_rate * grad_w1_node3;
                weight2_node3 += learning_rate * grad_w2_node3;
                bias_node3 += learning_rate * grad_b_node3;
            }
        }

        if ((epoch + 1) % 100 == 0 || epoch == 0)
            cout << "[サマリー] エポック " << epoch + 1 << ": 損失 = " << total_loss << endl;
    }

    // モデルをテスト
    float a, b;
    cout << "\n合計予測のテスト (a + b)\n aを入力してください: ";
    cin >> a;
    cout << "bを入力してください: ";
    cin >> b;

    float x1 = a / 100.0f;
    float x2 = b / 100.0f;

    float h1 = sigmoid(x1 * weight1_node1 + x2 * weight2_node1 + bias_node1);
    float h2 = sigmoid(x1 * weight1_node2 + x2 * weight2_node2 + bias_node2);
    float h3 = sigmoid(x1 * weight1_node3 + x2 * weight2_node3 + bias_node3);
    float y_pred = h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out;
    float predicted_sum = y_pred * 200.0f;

    cout << "w1_node1: " << weight1_node1 << ", w2_node1: " << weight2_node1
                             << ", w1_node2: " << weight1_node2 << ", w2_node2: " << weight2_node2
                             << ", w1_node3: " << weight1_node3 << ", w2_node3: " << weight2_node3
                             << ", out_w1: " << weight_out_node1 << ", out_w2: " << weight_out_node2
                             << ", out_w3: " << weight_out_node3 << ", bias_out: " << bias_out
                             << ", bias_node1: " << bias_node1 << ", bias_node2: " << bias_node2 << ", bias_node3: " << bias_node3 << endl;

    cout << "予測された合計: " << predicted_sum << "\n実際の合計: " << (a + b) << endl;

    return 0;
}

さて... どうやって学習したのでしょうか?

ここが面白い部分です: 足し算のルールをコード化したわけではありません。ネットワークに数字のペアとその合計をたくさん与え、徐々に重みとバイアスを調整して、予測がどんどん良くなっていきました。

これは、試行錯誤のようなものですが、よりスマートで、微積分のスパイスが加わっています。

本当に足し算を学んだのでしょうか?

ええ、私たちのようにはいきません。「プラス」が何を意味するのか理解しているわけではありません。しかし、非常に良い近似を学んでいます。数字のパターンを見て、しばしば正確な合計を予測します。

これは、ルールを知るのではなく、パターンを感じ取ることで学ぶようなものです。興味があれば、入力数字を変更してテストしてみてください。遊べば遊ぶほど、これらの小さな脳がどのように機能するかが理解できるようになります。

まとめ

ニューラルネットワークに2つの数字を足すことを教えるのは最初は馬鹿げているように思えるかもしれませんが、AIの心の中を覗く素晴らしい方法です。すべての誇大広告の背後には、重み、バイアス、そしていくつかのスマートな数学があるだけです。

次のパートでは、ニューラルネットワークの仕組みをさらに深く掘り下げていきます: 層、活性化関数、導関数、そして訓練中に実際に何が起こるのかを探ります。