ニューラルネットワークの基礎、単純パーセプトロンを学ぶ

はじめに

大規模言語モデル (LLM) の発展は目覚ましく、2025年時点でも日々進化しています。LLMを構成するのはニューラルネットワークですが、ニューラルネットワークを構成するのはパーセプトロンです。パーセプトロンを理解することがニューラルネットワークを理解する第一歩になるでしょう。

単純パーセプトロンとは

早速ですが単純パーセプトロンの定義です。単純パーセプトロンはアルゴリズムで、

  • n個の入力 x
  • n個の重み w
  • バイアス b

に対し

  • 出力 y (2値)

を出力します。u = b + x1w1 + x2w2 + ... + xnwn とするとき、

  • y = 0 (u <= 0), y = 1 (u > 0)

という関数で書き表せます。yは2値であれば良いので、 -1と1の2値にすることもあるようです。

単純パーセプトロンはよく、下記のような図で表されます。

単純パーセプトロンの図

プログラムでパーセプトロンを表現する

C言語

#include <stdio.h>

/**
 * @param x 入力ベクトル
 * @param w 重みベクトル
 * @param size ベクトルのサイズ
 * @param b バイアス
 * @return 0 or 1(ステップ関数の出力)
 */
int perceptron(
  const double x[],
  const double w[],
  const size_t size,
  const double b) {

  double u = b;
  for (size_t i = 0; i < size; i++) {
    u += x[i] * w[i];
  }
  return u <= 0 ? 0 : 1;
}

Python

機械学習ナビ さんの パーセプトロンとは?図解で分かりやすく解説!! を参照してください。

単純パーセプトロンで問題を解く

単純パーセプトロンを使って問題を解いてみましょう。

ANDゲート

ANDゲート (x1 = x2 = 1 の時だけyが1になる) を単純パーセプトロンで作ってみましょう。

x1x2y
000
010
100
111

この条件を満たすwとbの組み合わせは1つではありませんが、例えば

  • (w1, w2, b) = (0.5, 0.5, -0.6)

を採用するとANDゲートを表現できます。

下記のコードを実行して動作を確かめてみましょう。

#include <stdio.h> 
 
/** 
 * @param x 入力ベクトル 
 * @param w 重みベクトル 
 * @param size ベクトルのサイズ 
 * @param b バイアス 
 * @return 0 or 1(ステップ関数の出力) 
 */ 
int perceptron( 
  const double x[], 
  const double w[], 
  const size_t size, 
  const double b) { 
 
  double u = b; 
  for (size_t i = 0; i < size; i++) { 
    u += x[i] * w[i]; 
  } 
  return u <= 0 ? 0 : 1; 
} 
 
int main(void) { 
  // AND 
  const double w[] = {0.5, 0.5}; 
  const double b = -0.6; 
  for (size_t i = 0; i < 2; i++) { 
    for (size_t j = 0; j < 2; j++) { 
      double x[] = {i, j}; 
      printf("x[0]: %f, x[1]: %f, y: %d\n", x[0], x[1], perceptron(x, w, 2, b)); 
    }   
  } 
  return 0; 
} 

これをビルドして実行すると以下の出力が得られ、正しくANDゲートを構成できていることがわかります。

x[0]: 0.000000, x[1]: 0.000000, y: 0
x[0]: 0.000000, x[1]: 1.000000, y: 0
x[0]: 1.000000, x[1]: 0.000000, y: 0
x[0]: 1.000000, x[1]: 1.000000, y: 1

NANDゲート

NANDゲート (x1 = x2 = 1 の場合のみyが0になる) を単純パーセプトロンで作ってみましょう。

x1x2y
001
011
101
110

この条件を満たすwとbの組み合わせは1つではありませんが、例えば

  • (w1, w2, b) = (-0.5, -0.5, 0.6)

を採用するとNANDゲートを表現できます。

下記のコードを実行して動作を確かめてみましょう。

#include <stdio.h>

/**
 * @param x 入力ベクトル
 * @param w 重みベクトル
 * @param size ベクトルのサイズ
 * @param b バイアス
 * @return 0 or 1(ステップ関数の出力)
 */
int perceptron(
  const double x[],
  const double w[],
  const size_t size,
  const double b) {

  double u = b;
  for (size_t i = 0; i < size; i++) {
    u += x[i] * w[i];
  }
  return u <= 0 ? 0 : 1;
}

int main(void) {
  // NAND
  const double w[] = {-0.5, -0.5};
  const double b = 0.6;
  for (size_t i = 0; i < 2; i++) {
    for (size_t j = 0; j < 2; j++) {
      double x[] = {i, j}; 
      printf("x[0]: %f, x[1]: %f, y: %d\n", x[0], x[1], perceptron(x, w, 2, b));
    }   
  }
  return 0;
}

これをビルドして実行すると以下の出力が得られ、正しくNANDゲートを構成できていることがわかります。

x[0]: 0.000000, x[1]: 0.000000, y: 1
x[0]: 0.000000, x[1]: 1.000000, y: 1
x[0]: 1.000000, x[1]: 0.000000, y: 1
x[0]: 1.000000, x[1]: 1.000000, y: 0

ORゲート

ORゲート (x1 = 1 または x2 = 1 の場合のみyが1になる) を単純パーセプトロンで作ってみましょう。

x1x2y
000
011
101
111

この条件を満たすwとbの組み合わせは1つではありませんが、例えば

  • (w1, w2, b) = (0.6, 0.6, -0.5)

を採用するとORゲートを表現できます。

下記のコードを実行して動作を確かめてみましょう。

#include <stdio.h>

/**
 * @param x 入力ベクトル
 * @param w 重みベクトル
 * @param size ベクトルのサイズ
 * @param b バイアス
 * @return 0 or 1(ステップ関数の出力)
 */
int perceptron(
  const double x[],
  const double w[],
  const size_t size,
  const double b) {

  double u = b;
  for (size_t i = 0; i < size; i++) {
    u += x[i] * w[i];
  }
  return u <= 0 ? 0 : 1;
}

int main(void) {
  // OR
  const double w[] = {0.6, 0.6};
  const double b = -0.5;
  for (size_t i = 0; i < 2; i++) {
    for (size_t j = 0; j < 2; j++) {
      double x[] = {i, j}; 
      printf("x[0]: %f, x[1]: %f, y: %d\n", x[0], x[1], perceptron(x, w, 2, b));
    }   
  }
  return 0;
}
x[0]: 0.000000, x[1]: 0.000000, y: 0
x[0]: 0.000000, x[1]: 1.000000, y: 1
x[0]: 1.000000, x[1]: 0.000000, y: 1
x[0]: 1.000000, x[1]: 1.000000, y: 1

単純パーセプトロンの限界

単純パーセプトロンは、線形分離可能 (入力を線や平面で属性を分けられる問題) しか解くことができません。線形分離可能でない問題の代表例はXORゲートです。XORゲートを考えてみましょう。

XORゲート

x1x2y
000
011
101
110

上記表を式にすると

  • b <= 0
  • b + w1 > 0
  • b + w2 > 0
  • b + w1 + w2 <= 0

の4本の式が得られます。この4本の不等式は矛盾しており、次のように証明できます:

  • b + w1 > 0, b + w2 > 0 より
    • w1 + w2 > -2b
  • b + w1 + w2 <= 0 より
    • w1 + w2 <= -b
  • w1 + w2 > -2b, w1 + w2 <= -b より
    • -2b < w1 + w2 <= -b
    • つまり -2b < -b
    • よって b > 0
    • これは b <= 0 と矛盾する

したがって、XORゲートを表現可能な重みw, バイアスbの組み合わせは存在しません。

単純パーセプトロンを学習させる (教師あり学習)

ANDゲート, NANDゲート, ORゲートを単純パーセプトロンで作成した際、重みとバイアスは人の手で導きました。今回は学習データをもとに重みとバイアスを自動で設定してみましょう。

パーセプトロンの重みwは次の式を用いて更新 (学習) することが可能です:

  • (新しい) w = (今の) w + Δw
    • ここで Δw = η(y - ŷ)x
      • η: 学習率。0~1の範囲。一度にどの程度重みを変化させるかの値です。
      • y: 正しい出力 (教師データ)
      • ŷ (yハット): パーセプトロンの出力
      • x: パーセプトロンへの入力 (教師データ)

ANDゲートでの例

まず、各訓練データを1回だけ学習させてみましょう。次のコードを使用しました:

#include <stdio.h>

/**
 * @param x 入力ベクトル
 * @param w 重みベクトル
 * @param size ベクトルのサイズ
 * @param b バイアス
 * @return 0 or 1(ステップ関数の出力)
 */
int perceptron(
  const double x[],
  const double w[],
  const size_t size,
  const double b) {

  double u = b;
  for (size_t i = 0; i < size; i++) {
    u += x[i] * w[i];
  }
  return u <= 0 ? 0 : 1;
}

/**
 * 教師データに対する、パーセプトロンの最適な重みを求めます。
 * @param vectors 入力データの配列 (教師データ)
 * @param answers 正しい答えの配列 (教師データ)
 * @param vector_size 入力データのベクトルの要素数
 * @param vector_length 入力データのベクトルの数
 * @param w 重みベクトルへのポインタ
 * @param b バイアス
 * @param eta 学習率 (0から1の間)
 */
void train(
  const double vectors[],
  const int answers[],
  const size_t vector_size,
  const size_t vector_length,
  double w[],
  double* b,
  const double eta
) {
  for (int i = 0; i < vector_length; i++) {
    int y_hat = perceptron(&vectors[i * vector_size], w, vector_size, *b);
    double err = answers[i] - y_hat;
    *b += eta * err;
    printf("b (bias) is now %f\n", *b);
    for (int j = 0; j < vector_size; j++) {
      const double delta_w = eta * err * vectors[i * vector_size + j];
      w[j] += delta_w;
      printf("w[%d] is now %f\n", j, w[j]);
    }
  }
}

int main(void) {
  // 教師データ (入力)
  const double vectors[] = {
    0, 0,
    0, 1,
    1, 0,
    1, 1
  };
  // 教師データ (正しい答え)
  const int answers[] = {0, 0, 0, 1};

  // 重みベクトル
  double w[2] = {0, 0};

  // バイアス
  double b = 0;

  // 学習率
  const double eta = 0.1;

  // 学習を行う
  const int epochs = 1;
  for(int i = 0; i < epochs; i++) {
    train(vectors, answers, 2, 4, w, &b, eta);
  }

  // 重みベクトルを表示する
  for (int i = 0; i < 2; i++) {
    printf("w[%d]: %f\n", i, w[i]);
  }
  // バイアスを表示する
  printf("b: %f\n", b);

  // 求めた重みを使用してパーセプトロンを動かす
  for (int i = 0; i < 4; i++) {
    printf("x[0]: %f, x[1]: %f, y: %d\n", vectors[i * 2], vectors[i * 2 + 1], perceptron(&vectors[i * 2], w, 2, b));
  }
  return 0;
}

ビルドして実行してみてください。結果は下記のようになったと思います。

(省略)
x[0]: 0.000000, x[1]: 0.000000, y: 1
x[0]: 0.000000, x[1]: 1.000000, y: 1
x[0]: 1.000000, x[1]: 0.000000, y: 1
x[0]: 1.000000, x[1]: 1.000000, y: 1

y (結果) が全て1になっています。これではダメですね。ここで「エポック」の概念が登場します。各教師データを1回ずつ学習すると「1エポック」となり、2回学習すると「2エポック」となります。実は、1エポックだけでは重みとバイアスをうまく設定できないことがあります。適切なエポック数を設定する方法は筆者もまだよくわかっていません。

ひとまず、エポック数を100に設定してみましょう。コード中に const int epochs = 1; という行がありますので、 const int epochs = 100; に変更してください。ビルドして実行すると次のようになると思います:

(省略)
x[0]: 0.000000, x[1]: 0.000000, y: 0
x[0]: 0.000000, x[1]: 1.000000, y: 0
x[0]: 1.000000, x[1]: 0.000000, y: 0
x[0]: 1.000000, x[1]: 1.000000, y: 1

今度は正しくANDゲートの振る舞いをさせることができました。

終わりに

ざっくりとですが、パーセプトロンについて理解いただけましたでしょうか。ChatGPTが登場してから2年以上経ち、2025年の今から学び始めるのは出遅れ感がするかもしれません。しかし、"LLMがわかる"人はまだ非常に限られているはずであり、またその価値は非常に大きいはずです。この記事が機械学習を学び始める人に手を差し伸べることができたらと願っています。

新規CTA