banner
Nagi-ovo

Nagi-ovo

Breezing
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.__add__(b)).__mul__(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 の導数は何ですか?言い換えれば、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 の間の+ノードは、全体のグラフの残りの部分をまったく知らず、単に自分が add 操作を行い d を生成したことを知っており、c、e が d に与える局所的な影響だけを知っています。しかし、私たちが実際に求めているのは dL/dc です。

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

もし変数 z が変数 y に依存し、変数 y が変数 x に依存している場合(すなわち y と z は因変数)、この場合、z は中間変数 y を介して x にも依存します。この場合、連鎖律は次のように表されます:
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を使用しています。なぜなら、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 の基本的な設計思想を理解するのに役立ち、関数タイプを PyTorch に登録して自動勾配計算を行う方法を簡単に理解できます。公式ドキュメントはリンクを参照してください。

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