banner
Nagi-ovo

Nagi-ovo

Breezing homepage: [nagi.fun](nagi.fun)
github

0から実現する極めてシンプルな自動微分フレームワーク

コードリポジトリ:https://github.com/karpathy/nn-zero-to-hero

Andrej Karpathy は著名な深層学習コース Stanford CS 231n の著者および講師であり、OpenAI の創設者の一人でもあります。"micrograd" は彼が作成した小規模で教育的なプロジェクトです。このプロジェクトは、非常に簡略化された自動微分および勾配降下ライブラリを実現しており、学習者は最も基本的な微積分の知識を持っていれば、教育およびデモ目的で使用できます。

Micrograd の紹介#

Micrograd は基本的に autograd(自動勾配)エンジンであり、その本当の役割は逆伝播を実現することです。

逆伝播は、神経ネットワーク内の特定の損失関数に対する重みの勾配を効率的に計算するアルゴリズムです。これにより、損失関数を最小化するために神経ネットワークの重みを反復的に調整でき、ネットワークの精度を向上させることができます。したがって、逆伝播は PyTorch や JAX などの現代的な深層神経ネットワークライブラリの数学的核心となります。

micrograd がサポートする操作の種類:

from micrograd.engine import Value

a = Value(-4.0)
b = Value(2.0)
c = a + b # C の子ノードは A と B であり、C は A と B の値オブジェクトのポインタを保持します
d = a * b + b**3
c += c + 1
c += 1 + c + (-a)
d += d * 2 + (b + a).relu()
d += 3 * d + (b - a).relu()
e = c - d
f = e**2
g = f / 2.0
g += 10.0 / f # 前向き伝播の出力
print(f'{g.data:.4f}') # prints 24.7041, この前向き伝播の結果
g.backward() # ノード G で逆伝播を初期化し、微積分から再帰的に連鎖律を参照して、G に対するすべての内部ノードの導関数を評価できます
print(f'{a.grad:.4f}') # prints 138.8338, すなわち dg/da の数値値
print(f'{b.grad:.4f}') # prints 645.5773, すなわち dg/db の数値値

これは、A と B に対してわずかな前向き調整を行った場合、G がどのように反応するかを示します。

これらの操作を実現するための重要な点は Value クラスの実装方法にあり、以下で実装の考え方を詳しく説明します。

神経ネットワークの原理#

基本的に、神経ネットワークは数学的表現に過ぎません。入力データを入力として受け取り、神経ネットワークの重みを入力として、出力は神経ネットワークの予測結果または損失関数になります。

ここでのエンジンは単一のスカラーのレベルで動作し、これらの原子操作やすべての加算、乗算に分解されるのはやや過剰です。これらの操作を生産環境で実行することは決してありません。ここで行っているのは教育目的のためであり、現代の深層神経ネットワークライブラリで使用される n 次元テンソル (nd tensor) を処理する必要はありません。このようにすることで、逆伝播と連鎖律、神経ネットワークのトレーニングの理解を深めることができます。より大きな神経ネットワークをトレーニングする場合は、これらのテンソルを使用する必要がありますが、数学的な方法は変わりません。

導関数の理解#

import numpy as np
import matplotlib.pyplot as plt
import math
%matplotlib inline
# スカラー関数 f(x) を定義します。スカラー x を受け取り、スカラー y を返します
def f(x):
    return 3*x**2 - 4*x + 5
    
f(3.0)

20.0

# f(x) に供給するスカラー値のセットを作成します
xs = np.arange(-5, 5, 0.25)
'''
xs = array([-5.  , -4.75, -4.5 , -4.25, -4.  , -3.75, -3.5 , -3.25, -3.  ,
       -2.75, -2.5 , -2.25, -2.  , -1.75, -1.5 , -1.25, -1.  , -0.75,
       -0.5 , -0.25,  0.  ,  0.25,  0.5 ,  0.75,  1.  ,  1.25,  1.5 ,
        1.75,  2.  ,  2.25,  2.5 ,  2.75,  3.  ,  3.25,  3.5 ,  3.75,
        4.  ,  4.25,  4.5 ,  4.75])
'''
# xs の各値に f(x) を適用します
ys = f(xs)
'''
ys = array([100.    ,  91.6875,  83.75  ,  76.1875,  69.    ,  62.1875,
        55.75  ,  49.6875,  44.    ,  38.6875,  33.75  ,  29.1875,
        25.    ,  21.1875,  17.75  ,  14.6875,  12.    ,   9.6875,
         7.75  ,   6.1875,   5.    ,   4.1875,   3.75  ,   3.6875,
         4.    ,   4.6875,   5.75  ,   7.1875,   9.    ,  11.1875,
        13.75  ,  16.6875,  20.    ,  23.6875,  27.75  ,  32.1875,
        37.    ,  42.1875,  47.75  ,  53.6875])
'''
# 結果をプロットします
plt.plot(xs, ys)

Pasted image 20231118212947

グラフ上の各 x の導関数は何ですか?

もちろん、f'(x) = 6x - 4 ですが、私たちはそうしません。なぜなら、神経ネットワークでは誰も実際に式を書くことはないからです

これは膨大な式になるため、このような記号的アプローチを取ることはありません。

導関数の定義#

代わりに、導関数の定義を見て、私たちがそれを本当に理解していることを確認しましょう。

# 基本的に、非常に小さな h を取ることで数値的に導関数を評価できます
h = 0.00000001
x = 3.0
(f(x + h) - f(x)) / h # 関数は正の方向に反応します

14.00000009255109

これは関数が正の方向での反応です。

実行によって正規化した後、上昇 / 横移動を得て、傾きの数値近似を得ます。

ある位置で、正の方向に進むと、関数は反応せず、ほぼ変わらない場合、これは slope=0 になる理由です。

導関数が私たちに何を教えてくれるか#

より複雑な例を見てみましょう:

a = 2.0
b = -3.0
c = 10
d = a * b + c
print(d)

この関数の出力変数 d は、3 つのスカラー入力の関数であり、d に対する a、b、c の導関数を再度考察する必要があります。

h = 0.0001

# 入力
a = 2.0
b = -3.0
c = 10
d1 = a * b + c
a += h
d2 = a * b + c

print('da',d1)
print('d2',d2)
print('slope', (d2 - d1) / h)

ここで、a を進めた後、d2 の変化がどのようになるかを感じ取ってください。

a が正の数で、b が負の数であることがわかります。a が大きくなると、d に加わる内容が減少するため、関数値 d2 が小さくなることが予想されます。

da 4.0
d2 3.999699999999999
slope -3.000000000010772

数学的にも証明できます。d(ab+c)da=b=3\frac{d(a*b+c)}{da}=b=-3

b += h

da 4.0
d2 4.0002
slope 2.0000000000042206

Screenshot 2023-11-18 at 22.00.39

関数の上昇量は、c に加えた量と同じであるため、傾きは 1 です。

これで、導関数がこの関数に関する情報をどのように提供するかについての直感を得たので、神経ネットワークに移りましょう。

フレームワークの実装#

カスタムクラスのデータ構造#

前述のように、神経ネットワークは非常に大きな数学的表現であるため、これらの表現を維持するためのデータ構造が必要です。

class Value:
    def __init__(self, data):
        self.data = data

    def __repr__(self):
        return f"Value({self.data})"

    def __add__(self, other):
        out = Value(self.data + other.data)
        return out
    
    def __mul__(self, other):
        out = Value(self.data * other.data)
        return out

a = Value(2.0)
b = Value(-3.0)
c = Value(10.0)
d = a * b + c # (a.__mul__(b)).__add__(c)

repr の役割は、私たちに印刷出力の方法を提供することです。

今、私たちはこの表現の関連付けを組織するために欠けているものがあります。どの値がどの他の値を生成したかに関するポインタを知り、保持する必要があります。

def __init__(self, data, _children=(), _op=''):
        self.data = data
        self._prev = set(_children)
        self._op = _op

    def __repr__(self):
        return f"Value({self.data})"

    def __add__(self, other):
        out = Value(self.data + other.data, (self, other), '+')
        return out
    
    def __mul__(self, other):
        out = Value(self.data * other.data, (self, other), '*')
        return out

これにより、各値の子項を知り、どの演算操作がこの値を生成したかを追跡できます。

今、私たちは完全な数学的表現を持っており、非常に大きくなる可能性のある表現をより良く視覚化する方法を探す必要があります。(重要ではないので、スキップできます)

from graphviz import Digraph

def trace(root):
    # グラフ内のすべてのノードとエッジのセットを構築します
    nodes, edges = set(), set()
    def build(v):
        if v not in nodes:
            nodes.add(v)
            for child in v._prev:
                edges.add((child, v))
                build(child)    
    build(root)
    return nodes, edges

def draw_dot(root):
    dot = Digraph(format='svg', graph_attr={'rankdir': 'LR'}) # LR = 左から右

    nodes, edges = trace(root)
    for n in nodes:
        uid = str(id(n))
        # グラフ内の多くの値に対して、矩形('record')ノードを作成します 
        dot.node(name = uid, label = "{ %s | data %.4f}" % (n.label, n.data), shape = 'record')
        if n._op:
            # この値が何らかの操作の結果である場合、そのための操作ノードを作成します 
            dot.node(name = uid + n._op, label = n._op)
            # そして、このノードに接続します 
            dot.edge(uid + n._op, uid)

    for n1, n2 in edges:
        # n1 を n2 の操作ノードに接続します 
        dot.edge(str(id(n1)), str(id(n2)) + n2._op)   

    return dot

draw_dot(d)

Value クラスに label メンバーを追加し、定義を次のように変更する必要があります:

a = Value(2.0, label='a')
b = Value(-3.0, label='b')
c = Value(10.0, label='c')
e = a * b; e.label = 'e'
d = e + c; d.label = 'd'
d

Screenshot 2023-11-19 at 02.06.25

今、表現をさらに深くしましょう:

f = Value(-2.0, label='f')
L = d * f; L.label = 'L'
L

draw_dot(L)

output 1

前向き伝播の視覚化

逆伝播の実行#

L から始めて、すべての中間値に沿った勾配を逆に計算します。神経ネットワーク環境では、損失関数 L に対する神経ネットワークの重みの導関数に非常に興味があります。

Value クラスに、L に関するその値の導関数を維持するための変数を作成します:

self.grad = 0

初期化時、各値が出力に影響を与えないと仮定します。なぜなら、導関数が 0 であることは、この変数が損失関数を変更しないことを意味するからです。

今、L から勾配を埋め始めます。L に関する導関数は何ですか?言い換えれば、L を微小量 h に変えた場合、L はどれだけ変化しますか?

L は h の変化に比例して変化することが容易に想像できるため、導関数は 1 です:

def lol():

    h = 0.0001
    a = Value(2.0, label='a')
    b = Value(-3.0, label='b')
    c = Value(10.0, label='c')
    e = a * b; e.label = 'e'
    d = e + c; d.label = 'd'
    f = Value(-2.0, label='f')
    L = d * f; L.label = 'L'
    L1 = L.data

    a = Value(2.0, label='a')
    b = Value(-3.0, label='b')
    c = Value(10.0, label='c')
    e = a * b; e.label = 'e'
    d = e + c; d.label = 'd'
    f = Value(-2.0, label='f')
    L = d * f; L.label = 'L'
    L2 = L.data + h

    print((L2 - L1) / h)

lol() # 0.9999999999976694
L.grad = 1.0
# L = d * f
# dL/dd = ? -> f
f.grad = 4.0 # d.data
d.grad = -2.0 # f.data

私たちが lol() 関数で行ったことは、内部で勾配をチェックすることに似ています。すなわち、逆伝播を行い、すべての中間結果に対する導関数を取得する操作です。数値勾配は、単に小さなステップを使用してそれを推定します。

今、逆伝播の要点に入ります。私たちは L が D に非常に敏感であることを知っていますが、L は C にどのように影響されるのでしょうか?

'''
dd / dc = ? -> 1.0 得到局部导数
dd / de = ? -> 1.0
d = c + e

(f(x+h)) - f(x) / h 
((c + h + e) - (c + e)) / h
h / h = 1.0
'''

local derivate の計算

c、e、d の間の + ノードは、全体のグラフの残りの部分をまったく知らず、単に自分が加算操作を行い d を生成したことを知っており、c と e が d に与える局所的な影響だけを知っています。しかし、私たちが実際に求めているのは dL/dc です。

これらの情報をどのように統合して dL/dc を得るのでしょうか?答えは微積分の Chain Rule 連鎖律 にあります。

変数 z が変数 y に依存し、変数 y が変数 x に依存している場合(すなわち y と z は 因変数)、この場合、連鎖律は次のように表されます:
dzdx=dzdydydx{\displaystyle {\frac {dz}{dx}}={\frac {dz}{dy}}\cdot {\frac {dy}{dx}}}

連鎖律は基本的に、これらの導関数を正しくリンクする方法を教えてくれます。

ジョージ・F・シモンズが言ったように、「もし自動車の速度が自転車の 2 倍であり、自転車の速度が歩行者の 4 倍であれば、自動車は人よりも 8 倍速い。」

この例は、この法則の核心を明確に説明しており、それは Multiply です。

加算ノード#

distributor of gradient

神経ネットワークでは、各ノードの操作が勾配の伝播方法に影響を与えます。加算ノードの場合、2 つの入力 aabb があり、これらを加算して c=a+bc = a + b を得るとします。逆伝播の過程で、$c$ に対する aabb の勾配はそれぞれ 1 です。

具体的には:

  • cc の勾配が Lc\frac{\partial L}{\partial c} であり、ここで LL は損失関数である場合、連鎖律により、aabb の勾配を次のように得ることができます:
    La=Lc×ca\frac{\partial L}{\partial a} = \frac{\partial L}{\partial c} \times \frac{\partial c}{\partial a}
    Lb=Lc×cb\frac{\partial L}{\partial b} = \frac{\partial L}{\partial c} \times \frac{\partial c}{\partial b}
  • c=a+bc = a + b であるため、ca=1\frac{\partial c}{\partial a} = 1 および cb=1\frac{\partial c}{\partial b} = 1 です。
  • したがって、La\frac{\partial L}{\partial a}Lb\frac{\partial L}{\partial b} はどちらも Lc\frac{\partial L}{\partial c} に等しくなります。

したがって、加算ノードでは、勾配はすべての入力ノードに「そのまま」伝達されます。これは、加算操作が各入力に対して線形であり、各入力が出力に対する寄与が独立しているためです。

乗算ノード#

a.grad を求めるには、次のように計算できます:dL/da=(dL/de)×(de/da)=2.0×b.data=6.0dL/da=(dL/de)\times(de/da)=-2.0\times b.data=6.0

したがって、逆伝播の核心的な考え方は次のように要約できます:recursively multiply on the local derivatives(局所的な導関数に再帰的に乗算する)。

入力の微調整#

入力を微調整し、L 値を増加させようとします。

a 点で操作を行う場合、勾配方向に進むだけで済みます。
葉ノード を調整する必要があります。通常、私たちはそれらを制御できます。
L が上昇することを期待しています:

a.data += 0.01 * a.grad
b.data += 0.01 * b.grad
c.data += 0.01 * c.grad
f.data += 0.01 * f.grad

e = a * b
d = e + c
L = d * f

print(L.data)

-7.286496

実際の例を使用します:

# 入力 x1,x2
x1 = Value(2.0, label='x1')
x2 = Value(0.0, label='x2')

# 重み w1,w2、各入力のシナプス強度
w1 = Value(-3.0, label='w1')
w2 = Value(1.0, label='w2')

# バイアス b
b = Value(6.8813735870195432, label='b')

# x1*w1 + x2*w2 + b
x1w1 = x1 * w1; x1w1.label = 'x1w1'
x2w2 = x2 * w2; x2w2.label = 'x2w2'
x1w1x2w2 = x1w1 + x2w2; x1w1x2w2.label = 'x1w1x2w2' 
n = x1w1x2w2 + b; n.label = 'n' # 細胞体の原始的な活性化値
# ここで活性化関数を処理します。ここでは tanh を使用し、前のクラスで実装します。
def tanh(self):
        n = self.data
        t = (math.exp(2*n) - 1)/(math.exp(2*n) + 1)
        out = Value(t, (self,), 'tanh')
        return out

o = n.tanh(); o.label = 'o'
draw_dot(o)

neural

  • o.grad=1.0
  • n.grad を計算します
    o=tanh(n),do/dn=1tanh(n)2=1o2=0.5o=tanh(n),do/dn=1-tanh(n)^2=1-o^2=0.5
o.grad = 1.0
n.grad = 0.5
x1w1x2w2.grad = 0.5
b.grad = 0.5
x1w1.grad = 0.5
x2w2.grad = 0.5
x1.grad = x1w1.grad * w1.data
w1.grad = x1w1.grad * x1.data
x2.grad = x2w2.grad * w2.data 
w2.grad = x2w2.grad * x2.data

Screenshot 2023-11-22 at 15.23.20

導関数は常に最終出力に対する影響を教えてくれるので、もし w2 を変更しても出力は変わりません。なぜなら x2 は 0 だからです。この神経元の出力を増加させたい場合、w2 は現在の神経元にとって重要ではありませんが、x1 の重みは増加すべきです。

自動化#

手動で逆伝播を行うのは非現実的です。今、より自動的に逆伝播を実装する方法を見てみましょう。

各入力を受け取り出力を生成する小さなノードで、前述の小さな連鎖律を実行する特別な self._backward を保存します。すべてのノードで ._backward メソッドをトポロジカル順序で呼び出します:

o.grad = 1.0

topo = []
visited = set()
def build_topo(v):
    if v not in visited:
        visited.add(v)
        for child in v._prev:
            build_topo(child)
        topo.append(v)
build_topo(o)

for node in reversed(topo):
    node._backward()
topo:
[Value(6.881373587019543), Value(0.0), Value(1.0), Value(0.0), Value(-3.0), Value(2.0), Value(-6.0), Value(-6.0), Value(0.8813735870195432), Value(0.7071067811865476)]

Value クラスに追加すると、単に o.backward() を呼び出すだけで神経元の BP を完了できます。

この時点で、Value クラスのコンストラクタは次のようになります:

 class Value:
 '''
 data は実際の数値;
 _children タプルは子ノードを維持するために使用され、追跡します;
 _op は操作タイプを指定します
 label は視覚化ラベルです
 '''
    def __init__(self, data, _children=(), _op='', label=''):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None
        self._prev = set(_children)
        self._op = _op
        self.label = label

問題点#

現在のアルゴリズムには重大な問題がありますが、現在の例では見えません。理由は、例の各ノードがちょうど 1 回だけ使用されているためです。もし複数回呼び出すと、現在保存されている勾配が上書きされます。

Screenshot 2023-11-27 at 23.17.29

導関数は明らかに 2 であり、1 ではありません。

解決策は、連鎖律の多変数の場合とその一般化にあります。基本的な考え方は「これらの勾配を累積する」ことです:

self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad

Screenshot 2023-11-27 at 23.21.58

問題を修正しました。

操作レベル#

フレームワークの実行操作レベルは完全にあなた自身に依存します

各操作の前向き伝播と逆伝播を実装できれば、その操作がどれほど複雑であっても重要ではありません。つまり、局所的な勾配を実装できれば、これらの関数の設計は完全にあなたが決定できます:

def tanh(self):
        n = self.data
        t = (math.exp(2*n) - 1)/(math.exp(2*n) + 1)
        out = Value(t, (self,), 'tanh')

        def _backward():
            self.grad += (1 - t**2) * out.grad
        out._backward = _backward

        return out

Screenshot 2024-01-16 at 22.28.40

tanh を使用しています。

# すべての原子操作の BP を実装する必要があります。
def __repr__(self):
        return f"Value({self.data})"

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), '+')

        def _backward():
            self.grad += 1.0 * out.grad
            other.grad += 1.0 * out.grad
        out._backward = _backward

        return out

    def __sub__(self, other):
        return self + (-other)

    def __radd__(self, other): # r は reverse を意味し、定義された操作の逆演算を実装するために使用され、カスタムクラスのオブジェクトが Python の演算子や組み込み型とより良く相互作用できるようにします。
        return self.__add__(other)
    
    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), '*')

        def _backward():
            self.grad += other.data * out.grad
            other.grad += self.data * out.grad
        out._backward = _backward

        return out
    
    def __pow__(self, other):
        assert isinstance(other, (int, float)), "only supporting int/float powers for now"
        out = Value(self.data**other, (self,), f'**{other}')

        def _backward():
            self.grad += other * self.data ** (other - 1) * out.grad
        out._backward = _backward

        return out

    def __rmul__(self, other):
        return self.__mul__(other)
    
    def __truediv__(self, other):
        return self * other**-1

Screenshot 2024-01-16 at 22.30.21

e2x1/e2x+1e^{2x}-1/{e^{2x}+1} を使用しています。

Torch の対応実装方法#

Screenshot 2024-01-17 at 14.49.08

神経元クラスの実装#

^c9c15a

import random

class Neuron:
    def __init__(self, nin):
        self.w = [Value(random.uniform(-1,1)) for _ in range(nin)]
        self.b = Value(random.uniform(-1,1))

    def __call__(self, x):
        # w * x + b
        act = sum((wi * xi for wi, xi in zip(self.w,x)), self.b)
        out = act.tanh()
        return out
    
x = [2.0, 3.0]
n = Neuron(2)
n(x)

MLP の実装#

^da5567

Pasted image 20240117145319

各層に複数の神経元があり、互いに接続されていないが、すべてが入力に接続されていることがわかります。
したがって、層とは一組の 独立評価 の神経元です。

まず、1 層の Layer を実装します:

Screenshot 2024-01-17 at 15.04.56

図示 に従って MLP を実装します:

Screenshot 2024-01-17 at 15.21.39

3 つの入力、4 つの神経元の 2 層、1 つの出力

簡単なデータセットを定義し、神経ネットワークが xs の 4 つのサンプルを 2 分類し、ys の結果を得ることを望みます:

Screenshot 2024-01-17 at 15.24.27

明らかに結果は目標に達していません。

では、神経ネットワークを構築して、重みを調整し、必要な目標をより高く予測できるようにするにはどうすればよいでしょうか?
深層学習でこれを実現するためのテクニックは、神経ネットワークの全体的な性能を何らかの方法で測定する単一の数字を計算することです。それが損失(the loss)です。

古典的な機械学習アルゴリズムでは、この数字は 一般化線形モデル で使用される平均二乗誤差 MSE に相当し、モデルのパフォーマンスを測定する役割を果たします。

Screenshot 2024-01-17 at 15.34.28

各グループの損失を観察すると、MSE を損失として使用する場合、y 出力 = y 実際値 のときにのみ 0 損失になります。予測がずれるほど、損失は大きくなります。

これにより、最終的な損失が得られます。すなわち、すべての損失の合計です:

Screenshot 2024-01-17 at 15.37.07

重みとバイアスを保存します:

Screenshot 2024-01-17 at 15.48.37

2 つの書き方は同じ効果があります。

MLP クラスも同様に実装します:

def parameters(self):
        return [p for layer in self.layers for p in layer.parameters()]

Screenshot 2024-01-17 at 15.52.07

ここで、勾配情報に基づいて重みを変更できます。しかし、ここでわかるように、w[0].data を増加させると、現在の勾配が負であるため、実際にはそれを減少させ、損失が逆に上昇します。

Screenshot 2024-01-17 at 15.55.46

したがって、現在の勾配降下には - が欠けており、すなわち「勾配の負の方向に沿って」損失を最小化する必要があります。

for p in n.parameters():
	p.data += - 0.01 * p.grad*

損失が確かに減少したことがわかります。私たちの予測効果は少し改善されました:

Screenshot 2024-01-17 at 16.02.59

今必要なのは、このプロセスを繰り返すことです。先ほど計算した損失関数は「前向き伝播」と呼ばれ、次に再び逆伝播を行い、データを更新します。こうすることで、より低い損失が得られます。

これが神経ネットワークにおける「勾配降下」プロセスです:前向き伝播、逆伝播、データの更新。神経ネットワークはこのプロセスを通じて、徐々に予測効果を改善します。

ステップサイズを変更しようとすると、適切な境界を越える可能性があります。なぜなら、私たちは現在の損失関数を基本的に理解しておらず、唯一の認識はこれらのパラメータと損失関数との間の局所的な依存関係だけだからです。ステップが大きすぎると、まったく異なる損失関数の部分に入ってしまい、勾配爆発などの状況を引き起こす可能性があります。

現在の状況では、数回のループ後に損失が 0 に近づいていることがわかります。この時点で ypred の結果は非常に良好です(ここでの学習率またはステップサイズは 0.1 に設定されています)。

Screenshot 2024-01-17 at 16.13.30

再度実行すると、勾配爆発が発生し(損失が再び 5 以上になりました)、予測効果が非常に悪化しました:

Screenshot 2024-01-17 at 16.15.43
しかし、次のイテレーションでは、効果が非常に良好になりました:

Screenshot 2024-01-17 at 16.17.17

したがって、学習率の設定と調整は非常に微妙なアートです。低すぎると収束するのに長い時間がかかり、高すぎると不安定になります。したがって、通常の勾配降下 (gradient descent) では、ステップサイズの設定は確かに重要な問題です。

神経ネットワークにおける一般的な問題#

前のプロセスには、実際には大きな問題がありました。単に私たちが分析している問題があまりにも単純であったため、どこに欠陥があるのかを見つけることができなかったのです。

前述のように、導関数は累積されるため、各逆伝播の前に zero grad 操作を行う必要があります。そうしないと、ステップが大きくなりすぎて、望ましい結果を得るのが難しくなります。

イテレーションプロセスに勾配クリアを追加すると、収束速度が遅くなり、以前のように数ステップで非常に低い損失を得ることができなくなります。これが正常な状況です:

Screenshot 2024-01-17 at 16.30.44

このバグは、時にはそれほど影響を与えないかもしれませんが、複雑な問題に直面すると、損失をうまく最適化できなくなる可能性があります。

一般的なエラーをリストアップすると、先ほど遭遇したのはそのうちの第 3 条です:

Pasted image 20240117163412

まとめ:神経ネットワークとは?#

神経ネットワークは数学的表現であり、多層の干渉の下では非常に単純な式です。データ、神経ネットワークの重み、パラメータを入力として使用します。前向き伝播の数学的表現、損失関数が続き、予測の正確性を測定します。その後、損失を逆伝播し、勾配情報に従って操作を行い、損失を最小化します。

私たちは一群の神経元を持つだけで、何でもさせることができます。現在の例の神経ネットワークには 41 のパラメータしかありませんが、数十億の神経元を使用して、より複雑な神経ネットワークを構築できます。GPT モデルの例を挙げると、インターネットからの大量のテキストを使用して、神経ネットワークがシーケンス内の次の単語を予測するためにいくつかの単語を取得しようとする問題です。このような巨大な神経ネットワークは多くの興味深い特性を持ち、数百億のパラメータを持っていますが、基本的には同じ原理で動作します。更新方法が少し異なり、損失関数も交差エントロピー損失に変更されて次のトークンを予測します。

現在の micrograd ライブラリは、例の中で使用されているものとは異なります。現在使用されているのは ReLU を活性化関数として使用していますが、ここではより滑らかで複雑な tanh を使用しています。これは、局所的な勾配とこれらの導関数の使用を強調するためです。

PyTorch の nn.module クラス API に一致させるために、プロジェクト内のすべての神経元クラスは親クラス Module を継承しています。これらはすべて勾配クリアの機能を持っています:

class Module:
	def zero_grad(self):
		for p in self.parameters():
		p.grad = 0.0

	def parameters(self):
		return [] 

	class Neuron(Module):
		...

micrograd の実装を学ぶことは、PyTorch の基本設計思想を理解するのに役立ちます。自動的な勾配計算に関数タイプを登録する方法を簡単に理解できます。公式ドキュメントは リンク で確認できます。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。