はじめに
Part1では、自己符号化器の構成と、入力の再構成誤差を小さくするという学習の目標を説明しました。
今回は、ニューラルネットワークの一つの層に注目し、数式をC言語の配列とループへ対応づけます。
まずは固定した重みを使って、入力から出力を計算するところまで実装します。重みの学習や、複数の層を扱う構造体は後の回で追加します。
全結合層の計算
入力を $\boldsymbol{x}\in\mathbb{R}^{n}$、出力を $\boldsymbol{y}\in\mathbb{R}^{m}$ とすると、一つの全結合層の計算は次のように表せます。
\boldsymbol{u}=W\boldsymbol{x}+\boldsymbol{b},
\qquad
\boldsymbol{y}=\sigma(\boldsymbol{u})
各記号の定義は以下。
| 記号 | 意味 | サイズ |
|---|---|---|
| $\boldsymbol{x}$ | 層への入力 | $n$次元 |
| $W$ | 重み行列 | $m\times n$ |
| $\boldsymbol{b}$ | バイアス | $m$次元 |
| $\boldsymbol{u}$ | 活性化関数を適用する前の値 | $m$次元 |
| $\boldsymbol{y}$ | 層の出力 | $m$次元 |
| $\sigma$ | 各成分に適用する活性化関数 |
行列 $W$ の行は出力側、列は入力側に対応します。この対応を決めておくと、Cの配列に格納するときも添字が明確になります。
なお、ここでの $\boldsymbol{x}$ と $\boldsymbol{y}$ は、一つの層の入力と出力を表す一般的な記号です。AEのエンコーダでは $\boldsymbol{y}$ が内部表現 $\boldsymbol{z}$ に、デコーダでは再構成した出力 $\boldsymbol{x}_{\mathrm{re}}$ に対応します。
行列の式を成分ごとに書く
行列積を成分ごとに書き下すと、出力側の第 $j$ ニューロンについて、
u_j=\sum_{i=0}^{n-1}W_{ji}x_i+b_j,
\qquad
y_j=\sigma(u_j)
\quad
(j=0,\ldots,m-1)
となります。
本稿では、Cの配列に合わせて添字を0から始めます。$W_{ji}$ は、入力側の第 $i$ 成分から、出力側の第 $j$ ニューロンへの重みです。
この式は、次の手順を表しています。
- バイアス $b_j$ を初期値とする。
- 各入力 $x_i$ に対応する重み $W_{ji}$ を掛け、足し合わせる。
- 得られた $u_j$ に活性化関数を適用する。
すべての出力ニューロンについて、この計算を繰り返します。
ベクトルをCの配列で表す
ベクトルは、double型の一次元配列で表します。たとえば、
\boldsymbol{x}
=
\begin{pmatrix}
1\\
2\\
-1
\end{pmatrix}
という3次元の入力は、次のように記述できます。
double x[3] = {1.0, 2.0, -1.0};
数式の $x_i$ と、Cの x[i] が対応します。
出力やバイアスも同様です。出力が2次元の場合は、それぞれ2要素を用意します。
ここでは、バイアスを重みとは別の配列に格納します。
重み行列を(一次元)配列で表す
二次元配列で表しても良いですが、ここでは、層ごとに異なる大きさの行列を扱いやすくするため、重みも一次元配列に格納します。
たとえば、入力が3次元、出力が2次元なら、重み行列のサイズは$2\times3$ です。
W=
\begin{pmatrix}
0.1 & 0.2 & 0.3\\
-0.4 & 0.5 & -0.6
\end{pmatrix}
これを、行ごとに並べて格納します。
double w[6] = {
0.1, 0.2, 0.3, /* 出力0への重み */
-0.4, 0.5, -0.6 /* 出力1への重み */
};
この並べ方を行優先(row-major)と呼びます。
入力の次元をn_inとすると、各要素の対応は、
W_{ji}
\quad\longleftrightarrow\quad
\texttt{w[j * n_in + i]}
です。つまり、
| 行列の要素 | 配列の要素 |
|---|---|
| $W_{00}$ | w[0] |
| $W_{01}$ | w[1] |
| $W_{02}$ | w[2] |
| $W_{10}$ | w[3] |
| $W_{11}$ | w[4] |
| $W_{12}$ | w[5] |
j * n_in で第 $j$ 行の先頭へ移動し、そこからi要素進む、と考えるとわかりやすくなります。行列を1次元配列で扱うことはそれなりに多いので慣れておくと良いと思います。
必要な重みの個数は、入力次元と出力次元の積であるn_in * n_outです。
行列とベクトルの積を実装する
以上の対応を使うと、
u_j=\sum_{i=0}^{n-1}W_{ji}x_i+b_j
は、次の二重ループになります。
for (size_t j = 0; j < n_out; ++j) {
double sum = b[j];
for (size_t i = 0; i < n_in; ++i) {
sum += w[j * n_in + i] * x[i];
}
u[j] = sum;
}
外側のループは出力ニューロン、内側のループは入力成分に対応します。
関数としてまとめると、次のようになります。
#include <stddef.h>
void affine_forward(
size_t n_in,
size_t n_out,
const double *w,
const double *b,
const double *x,
double *u
) {
for (size_t j = 0; j < n_out; ++j) {
double sum = b[j];
for (size_t i = 0; i < n_in; ++i) {
sum += w[j * n_in + i] * x[i];
}
u[j] = sum;
}
}
この関数は、$W\boldsymbol{x}+\boldsymbol{b}$ というaffine変換を計算します。活性化関数はまだ適用していません。
const double *は、この関数内ではポインタを通じて配列の要素を変更しないことを表しています。計算結果を書き込むuには、constを付けません。
また、Cのポインタには配列の長さが含まれないため、n_inとn_outを別に渡しています。呼び出し側で、次の要素数を確保しておく必要があります。
| 配列 | 必要な要素数 |
|---|---|
| w | n_out * n_in |
| b | n_out |
| x | n_in |
| u | n_out |
シグモイド関数を適用する
今回は、活性化関数としてシグモイド関数を使います。
\sigma(t)=\frac{1}{1+\exp(-t)}
補足:シグモイド関数の微分
シグモイド関数の微分は、出力値を使って表せます。
\sigma'(t)=\sigma(t)\left(1-\sigma(t)\right)
Cではmath.hのexpを使って計算できます。ただし、非常に小さな負の値を入力した場合、$\exp(-t)$が大きくなりすぎます。そのため、入力の符号によって、数学的に等価な式を使い分けます。
#include <math.h>
double sigmoid(double t)
{
if (t >= 0.0) {
return 1.0 / (1.0 + exp(-t));
}
double e = exp(t);
return e / (1.0 + e);
}
負の場合の式は、元の式の分子と分母に $\exp(t)$ を掛けて得られます。
\frac{1}{1+\exp(-t)}
=
\frac{\exp(t)}{1+\exp(t)}
この計算方法では、指数関数に渡す値が正の大きな値になるのを避けられます。
affine変換の結果uに対して、成分ごとに適用します。
for (size_t j = 0; j < n_out; ++j) {
y[j] = sigmoid(u[j]);
}
手計算と照合する
実装の確認には、手で計算できる小さな例を使います。
入力、重み、バイアスを次のように設定します。
\boldsymbol{x}
=
\begin{pmatrix}
1\\
2\\
-1
\end{pmatrix},
\qquad
W=
\begin{pmatrix}
0.1 & 0.2 & 0.3\\
-0.4 & 0.5 & -0.6
\end{pmatrix},
\qquad
\boldsymbol{b}
=
\begin{pmatrix}
0.1\\
-0.2
\end{pmatrix}
活性化関数を適用する前の値は、
\begin{aligned}
u_0
&=0.1\times1+0.2\times2+0.3\times(-1)+0.1
=0.3,\\
u_1
&=(-0.4)\times1+0.5\times2+(-0.6)\times(-1)-0.2
=1.0
\end{aligned}
です。
シグモイド関数を適用すると、
y_0=\sigma(0.3)\approx 0.574443,
\qquad
y_1=\sigma(1.0)\approx 0.731059
となります。
プログラム全体
以下を part02_forward.cとして保存します。(ファイル名はご自由に)
クリックしてコード全体を表示
#include <math.h>
#include <stddef.h>
#include <stdio.h>
static void affine_forward(
size_t n_in,
size_t n_out,
const double *w,
const double *b,
const double *x,
double *u
) {
for (size_t j = 0; j < n_out; ++j) {
double sum = b[j];
for (size_t i = 0; i < n_in; ++i) {
sum += w[j * n_in + i] * x[i];
}
u[j] = sum;
}
}
static double sigmoid(double t)
{
if (t >= 0.0) {
return 1.0 / (1.0 + exp(-t));
}
double e = exp(t);
return e / (1.0 + e);
}
int main(void)
{
enum { N_IN = 3, N_OUT = 2 };
const double x[N_IN] = {1.0, 2.0, -1.0};
const double w[N_OUT * N_IN] = {
0.1, 0.2, 0.3,
-0.4, 0.5, -0.6
};
const double b[N_OUT] = {0.1, -0.2};
double u[N_OUT];
double y[N_OUT];
affine_forward(N_IN, N_OUT, w, b, x, u);
for (size_t j = 0; j < N_OUT; ++j) {
y[j] = sigmoid(u[j]);
printf(
"u[%zu] = %.6f, y[%zu] = %.6f\n",
j, u[j], j, y[j]
);
}
return 0;
}
実行結果として期待する値は、次のとおりです。
u[0] = 0.300000, y[0] = 0.574443
u[1] = 1.000000, y[1] = 0.731059
実行してみて、正しい結果になるか確かめましょう。
自己符号化器との対応
前回のAEの式は、
\boldsymbol{z}
=
\sigma_{\mathrm{enc}}
\left(
W_{\mathrm{enc}}\boldsymbol{x}
+
\boldsymbol{b}_{\mathrm{enc}}
\right)
,\quad
\boldsymbol{x}_{\mathrm{re}}
=
\sigma_{\mathrm{dec}}
\left(
W_{\mathrm{dec}}\boldsymbol{z}
+
\boldsymbol{b}_{\mathrm{dec}}
\right)
でした。
まさに今回実装した「affine変換の後に活性化関数を適用する」という計算です。異なるのは、入力・出力の次元と、使用するパラメータです。
たとえば784→128→784のAEでは、各層は次の対応になります。
| 層 | 入力次元 | 出力次元 | 重みの要素数 |
|---|---|---|---|
| エンコーダ | 784 | 128 | $128\times784$ |
| デコーダ | 128 | 784 | $784\times128$ |
エンコーダの出力をデコーダの入力へ渡せば、AE全体の順伝播につながります。
おわりに
少し長くなってしまいました…
今回は、一つの全結合層の数式を、配列と二重ループへ対応づけました。重み行列の行を出力側、列を入力側とし、各要素をw[j * n_in + i]に格納する規則は、今後の実装でも使います。
次回は、今回の全結合層を組み合わせ、単層自己符号化器を実装します。重みや計算結果を構造体にまとめ、順伝播・再構成誤差の計算・誤差逆伝播・パラメータの更新までをつなげます。最後に、小さな数値例を使い、実際に再構成誤差が減ることを確認します。
