Python
NeuralNetwork
複素数
HUITDay 23

複素ニューラルネットワークでXORを学習させてみた


はじめに

この記事はHUITアドベントカレンダー2018 日目の記事になります。

この記事はほぼ全て下記URLのブログを翻訳して、実装したものになっています。

http://makeyourownneuralnetwork.blogspot.com/2016/05/complex-valued-neural-networks.html


複素ニューラルネットワークとは

複素ニューラルネットワークとは、ニューラルネットワークの学習を複素数の性質を利用することで改善しようという試みの一種で、ここでは特に複素数の持つ位相の性質について注目することで、誤差逆伝播を使わずに学習してしまおうという試みになっています。

ブログの筆者によるコードもGithubに上がっており、cloneして簡単に試すことができますが、今回はこれを参考に演算をGPUで行えるように、PyTorchで複素行列から全て定義してXORを学習させてみました。

私のコードはこちらにあります。


実数から複素数へ

さて、まず実数値のニューラルネットワークでの学習を設計する時、その過程は以下のように分割できると思います。(MNISTの簡易的な例を右側に書いています)


  1. データの前処理(画素値を0~1に)

  2. モデルの設計


    • 層の定義(畳み込み層や全結合層)

    • 活性化関数の定義(ReLUやSigmoid)



  3. 出力層, 損失関数, 学習アルゴリズムの設計(Softmax with CrossEntropy, SGD)

これを複素数で扱う過程で以下のような変更を施します。


  1. データの前処理では、入力データ(XORならバイナリ2入力)を実数値({0, 1})から複素数値に変換する手法を複素数の持つ位相を元に考えます。

  2. モデルの設計では、ここでは層に関して全結合層のみを考え、重みの扱い方を定義し、活性化関数については位相に注目して複素数を扱う際に適したものを定義します。

  3. 出力層, 損失関数, 学習アルゴリズムの設計では出力($\{ 0, 1\}$)を誤差が算出できるように複素数で扱うにはどのようにすれば良いかを考慮した設計を行います。学習アルゴリズムでも複素数の性質を利用して、獲得した誤差からの重みの修正を行います。


1. データの前処理

上述のように、ブログの筆者は複素ニューラルネットワークを扱う際には位相が重要なんだ!!と激しく主張しています。そこで、まずは複素数を位相で扱う際に適した入力を考えます。

複素数というのは極形式で書くと$z = re^{i\theta}$のように書き表せ、特に$r=1$の時、複素平面の単位円上にプロットすることができます。

この単位円上に入力データをプロットすることで、位相によってデータを扱うということを実装します。XORは$\{ 0, 1\}$の2入力であるため、単位円上で正反対の位置にプロットすることにしましょう。ここでは、$ 0 \rightarrow -1(\theta = -\pi) , 1 \rightarrow 1(\theta = 0)$とします。

学習では、全4パターンを使用するので、各入力のベクトルを用意します。


train.py

inputs = [Complex(x=torch.Tensor([[-1], [-1]]), y=torch.zeros(2, 1)),

Complex(x=torch.Tensor([[-1], [1]]), y=torch.zeros(2, 1)),
Complex(x=torch.Tensor([[1], [-1]]), y=torch.zeros(2, 1)),
Complex(x=torch.Tensor([[1], [1]]), y=torch.zeros(2, 1))]

私の定義した複素数クラスを簡単に説明すると、x=, y=という形でtorch.Tensorを代入すれば、直交座標表示によって複素テンソルが作成され、r=, theta=という形でtorch.Tensorを代入すれば、極座標表示によって複素テンソルが作成されます。complex_torch.pyを見ていただければわかりますが、クラス内にx, y, r, thetaがメンバとして用意しており、メモリの無駄を引き起こしていますが、これは簡単に定義するためにあえてこうしました。


2. モデルの設計


2-1. 重み

さて、入力を複素数化したところで、次にそれにかける重みを考えましょう。と言っても、重みに関しては実数値のニューラルネットワークと同様に、ノードにあった複素行列$\bf W$を重み行列とするだけです。

つまり、次のような計算を行います。

$$

{\bf x} = \left[

\begin{array}{c}

x_{1} \\

\vdots \\

x_{n} \\

\end{array} \right],\

{\bf W} = \left[

\begin{array}{ccc}

w_{11} & \ldots & w_{1n} \\

\vdots & \ddots & \vdots \\

w_{m1} & \ldots & w_{mn} \\

\end{array} \right] \\

{\bf z} = {\bf Wx}

$$

これを実装すると以下のようになります。


torch_.py

class ComplexLayer:

def __init__(self, in_nodes, out_nodes, device=torch.device("cpu")):
self.weight = Complex(x=torch.empty(out_nodes, in_nodes).normal_(0.0, pow(1.0, -0.5)),
y=torch.empty(out_nodes, in_nodes).normal_(0.0, pow(1.0, -0.5))).to(device)

def __call__(self, z):
return mm(self.weight, z)



初期値

ブログの著者の実装では初期値は一様分布$\mathcal{U}(0, 1)$で複素平面上の第一象限内に収まるように定義しています。これについての理由はわかりませんが、改善の余地があるかもしれません。


2-2. 活性化関数

以上で重みを定義しました。では、活性化関数にも実数のニューラルネットワークで使用されていた活性化関数を使用しましょう!…というわけにはいきません。

仮に実数NNと同様、誤差逆伝播を使用して学習させようとすると、複素数ではとても複雑なものになります。複素関数では求められた勾配が必ずしも勾配降下に使用できるとは限らないからです。よって、従来とは別の活性化関数と学習アルゴリズムを考える必要があります。


複素数の掛け算の意味

ここで、複素数の重みを掛ける意味を考えてみましょう。複素数の掛け算とはどのような意味を持つでしょうか?

…そう!回転伸縮ですね!

何度も主張しているように、今我々は位相に注目しているため、ここでは回転の性質を考えましょう。

後述しますが、今回の問題は出力層での位相によって$\{ 0, 1\}$の2値分類を行っています。つまり、振幅($z=re^{i\theta}$と表した時の$r$)についてはどんな値をとっても構いません。それならば、活性化関数で各層での出力を単位円上にプロットしてしまいましょう!という発想の下、以下のような活性化関数を使用します。

これはコーシー・リーマンの方程式を満たさないため微分不可能な関数ですが、そもそも誤差逆伝播を使用しないため問題ないわけです。

活性化関数

活性化関数(ブログより)

$$

z = re^{i\theta} \\

P(z) = \frac{z}{\left| z\right| } = e^{i\theta}

$$

これを実装すると以下のようになります。


complex_nn.py

def normalize(z):

return Complex(r=torch.ones_like(z.abs), theta=z.angle)

r=, theta=という形でtorch.Tensorを代入すれば、極座標表示によって複素テンソルが作成されることを利用してr=torch.ones_like(z.abs)としてノルムを$1$にしています。


3. 出力層, 損失関数, 学習アルゴリズムの設計


3-1. 出力層

実数NNでクラス分類を行う場合の出力層は、二クラス分類の時は1つのノードで一方のクラスに属する確率を計算し、多クラス分類の時はクラス分のノードを用意して、softmax関数によって各クラスに属する確率を計算していましたが、ここでは実数NNと異なり、多クラス分類であっても出力層を1ノードに固定し、出力層での位相によってクラス分類を行います。

では、複素ニューロン1つの位相のみを使用して分類問題を行うことを考えましょう。例えば、grass, sand, waterの3値分類を行う場合、下図のように単位円を分割してクラスを割り当てます。角の二等分線(bisector)を基準として教師ラベル(図の場合$e^{i \frac{\pi}{3}}, e^{i \pi}, e^{i \frac{5\pi}{3}}$)とすれば、位相を元にクラス分類ができることになるでしょう。

出力層

出力層の設計(ブログより)


疑問点

しかし、これでは4クラス以上の分類の時に問題が発生してしまうと思います。3クラスの分類までは、各クラスの領域が他のすべてのクラスの領域に接しているため、どのクラスに対しても平等に予測とのずれを計算できますが、4クラスの分類からは隣接しないクラスが現れてしまうためです。これについて明確な答えはブログからは得られませんでした。著者はMNISTの実装も同様に10個の領域に分割して行なっていますが、4クラス以上の分類については改善の余地が多分にあるのではないかと思います。


3-2. 損失関数

この教師ラベルを使用して損失関数を設計しましょう。損失関数は実数NNと同様に教師ラベルとの差をとります。すなわち、教師データに対応する二等分線の複素数値を$t$として以下のような損失$e$を計算します。

$$

e = t - z

$$

損失関数


学習改善手法:三等分線を用いる

上述のクラス分類では、ネットワークの出力座標が、目標のクラスの領域に入ってさえしまえば正解となるわけです。それゆえに、二等分線のみをターゲットにするのではなく、領域内を三等分する2本の線を使って、ネットワークの出力に近い方の線に近づけるというやり方も考えられます。そこで、後述のコードのtrainの部分でn_partという変数を導入します。n_part=1の時は領域の二等分線に近づけるよう学習しますが、n_part=2では領域の三等分線の内ネットワークの出力に近い方に近づけるよう学習します。


3-3. 学習アルゴリズム

さて、ここで二等分線の値$t$について考えてみましょう。$t$とは単位円上の入力$x$を受けて分類クラスを決定する値であるので、単位円上で$x$を回転させた値であると言えます。この回転させた値を$(w_n + \Delta w_n)$と見なせば、誤差関数について以下が言えます。

$$

\begin{eqnarray}

e &=& t - z \\

&=& \sum_n (w_n + \Delta w_n) x_n - \sum_n w_n x_n \\

&=& \sum_n \Delta w_n x_n

\end{eqnarray}

$$

よって、この$\Delta w_n$を利用して、重みを更新すれば良いのです。仮に、各ノードが同じだけ出力$z$に寄与していると考えると、$\Delta w_n$はその平均を取ればよく、

$$

\Delta w_n x_n = \frac{e}{N}

$$

$\left| x_n \right|=x_n \cdot \bar{x_n} = 1$より、両辺に$\bar{x_n}$をかけて、

$$

\Delta w_n = \frac{e}{N} \bar{x_n}

$$

これにより、重みの差$\Delta w_n$を得られます。これを更新に使用すれば以下のようになります。

$$

w_n \leftarrow w_n + \Delta w_n

$$

このように誤差逆伝播とは全く違う方法で重みが更新できるのです!

以上をまとめ、学習アルゴリズム(trainメソッド)とともに定義した複素ニューラルネットは以下のようになります。


complex_nn.py

class ComplexNN:

def __init__(self, nodes, device=torch.device("cpu")):
self.device = device
self.nodes = nodes
self.nodes[0] += 1
self.layers = []
for i in range(len(self.nodes) - 1):
self.layers.append(ComplexLayer(self.nodes[i], self.nodes[i + 1], device=self.device))
self.zs = []

def __call__(self, z):
z = Complex(x=torch.cat([z.real.to("cpu"), torch.ones(1, 1)]),
y=torch.cat([z.imag.to("cpu"), torch.zeros(1, 1)])).to(self.device)
self.zs = [z]
for layer in self.layers:
self.zs.append(normalize(layer(self.zs[-1])))
return self.zs[-1]

def train(self, label, n_category, n_part=1):
ts = [Complex(r=torch.ones_like(self.zs[-1].abs), theta=torch.ones_like(self.zs[-1].angle) * (
(label + (i + 1) / (n_part + 1)) * (2 * math.pi / n_category))).to(self.device) for i in
range(n_part)]
for i in reversed(range(len(self.layers))):
if i == len(self.layers) - 1:
loss = ts[0] - self.zs[-1]
for t in ts:
new_loss = (t - self.zs[-1])
if new_loss.abs.item() < loss.abs.item():
loss = new_loss
else:
loss /= self.nodes[i]
dw = mm(loss, self.zs[i].conjugate().transpose()) / self.nodes[i]
self.layers[i].weight += dw



XORの学習

これを使用してXORを学習させました。


コード


train.py

# set data

inputs = [Complex(x=torch.Tensor([[-1], [-1]]), y=torch.zeros(2, 1)),
Complex(x=torch.Tensor([[-1], [1]]), y=torch.zeros(2, 1)),
Complex(x=torch.Tensor([[1], [-1]]), y=torch.zeros(2, 1)),
Complex(x=torch.Tensor([[1], [1]]), y=torch.zeros(2, 1))]

targets = [0, 1, 1, 0] # XOR

# Hyper Parameters
n_epoch = 20
n_category = 2
n_part = 2

# set model
model = cvnn.ComplexNN([2, 10, 10, 1], device=device)

# train model
for epoch in range(n_epoch):
for i in range(4):
z = inputs[i].to(device)
model(z)
model.train(targets[i], n_category, n_part=n_part)

# test model
for i in range(4):
z = inputs[i].to(device)
printComp(z)
output = model(z)
print(int(output.imag < 0))



結果

中間層のノード数と分割数n_partを調節し、それぞれにおいて100回の精度測定を行なったところ、以下のような精度が得られました。

中間層
分割数=1
分割数=2

3
0.11
0.18

10
0.85
0.88

20
0.91
0.90

50
0.78
0.69

5, 5
0.76
0.83

5, 10
0.91
0.91

8, 8
0.95
0.99

10, 10
0.99
1.00


考察・印象

1層のノード数を増やすよりも、層を深くした方が精度が向上し、ノード数を増やしすぎると精度が落ちることが見て取れます。また、分割数については各場合で異なり、2層に関しては精度向上に寄与できているのかなという印象です。しかし、XORで精度を95%達成するためには8ノードの中間層が2層必要なことを考えると、実数NNの方がよく、まだ様々なところで精度向上の余地があるのではないかなという印象を受けました。ブログが書かれたのが2016年ですから、改善策やまた別のアプローチがあるかもしれないです。


まとめ

今回の複素ニューラルネットワークの要点は以下の3点になります。


  1. 複素数の位相に注目する

  2. 重みと活性化関数で複素数の回転を行う

  3. 分割した領域に分類されるよう、回転を考慮した重みの更新を行う

とにかく位相・回転に注目したアルゴリズムでした。精度としてはまだまだですが、複素数の不可思議な性質はもっと注目されてもいいと思います。振幅についても使い道はあるかもしれませんし…


参考