Profile image
Jinyoung
Dev

TIL-06: Deep Learning 02

90% Human
10% AI
TIL-06: Deep Learning 02
0 views
18 min read

이 글은 안드레아 카파시의 Neural network 강의 영상 중의 하나인 'The spelled-out intro to neural networks and backpropagation: building micrograd'를 보고 정리한 것입니다.


micrograd

micrograd는 카파시가 수년 전에 공개한 라이브러리로, 아주 작고 직관적인 스칼라 값 기반의 자동 미분(Autograd) 엔진입니다.

이것은 신경망의 핵심 원리인 역전파(backpropagation) 알고리즘을 구현하여, 신경망의 가중치에 대한 손실 함수의 기울기(gradient)를 계산해줍니다.

PyTorch, JAX 같은 현대의 딥러닝 라이브러리들이 다차원 배열인 '텐서(Tensor)'를 다루는 것과 달리, micrograd는 -4, 2 같은 개별 스칼라(단일 숫자) 값 수준에서 작동하도록 구현되어 있습니다. 카파시는 "텐서 연산이 아닌, 스칼라 연산 수준에서 역전파를 구현하는 것이 딥러닝의 핵심 원리를 이해하는 데 더 도움이 된다"고 말합니다. 즉, 처음부터 복잡한 텐서로 신경망을 배우는 것은 교육적으로 유용하지 않다고 판단한 것입니다.

이 라이브러리를 통해 복잡한 텐서 연산을 걷어내고 아주 기초적인 수준에서 신경망을 구축해 봄으로써, 저와 같은 비전공자가 역전파와 연쇄 법칙(Chain Rule)이 작동하는 근본적인 원리를 완벽하게 이해할 수 있도록 합니다.

micrograd가 여러분이 신경망을 학습시키는 데 필요한 전부이며, 나머지는 단지 효율성일 뿐이라는 것입니다. - Andrej Karpathy


미분: 변화율을 구하는 방법

아주 간단한 예제부터 시작합니다.

f(x)=limh0f(x+h)f(x)hf'(x) = \lim_{h \to 0} \frac{f(x + h) - f(x)}{h}

현재 관심 있는 어떤 지점 x에서 입력값을 아주 조금(h) 올렸을 때, 함수 결과값이 얼마나 큰 민감도로 반응하는지를 묻는 과정입니다. 즉, 특정 지점에서의 기울기는 해당 지점에서의 변화에 대한 민감도를 의미합니다.

이제 이 내용을 파이썬 코드로 설명합니다.

import numpy as np
import matplotlib.pyplot as plt

def f(x):
  return 3*x**2 - 4*x + 5

xs = np.arange(-5, 5, 0.25)
ys = f(xs)
plt.plot(xs, ys)

함수 f를 정의합니다. 그리고 -5부터 0.25 간격으로 5까지의 값을 xs에 할당하고, ys에 f(xs)를 할당합니다.

xs, ys를 그래프로 나타내면 다음과 같습니다.

example_1

이제 h=0.001, x=3에서의 기울기를 계산해 봅시다.

h = 0.001
x = 3.0
f(x) # 20.0
f(x + h) # 20.014003000000002
f(x + h) - f(x) # 0.01400300000000243

x를 양의 방향으로 약간 밀어내면 함수는 어떻게 반응할까요? f(x) 값은 20.0 입니다. 여기에 h 만큼의 변화를 주면 20.014003000000002가 됩니다. 함수가 반응한 정도는 이 값의 차이(0.01400300000000243) 입니다.

(f(x + h) - f(x)) / h # 14.00300000000243

기울기의 근사치는 이 식을 통해 구할 수 있습니다. h를 0.001이 아니라 0에 무한히 가깝게 할수록 이 값은 정확히 14에 수렴하게 됩니다.

그래서 결과적으로 기울기는 14가 되는 것입니다.

이것을 다시 말로 풀어서 설명하면 x가 3인 지점에서 x를 아주 조금(h) 증가시켰을 때, 함수 f(x)의 값은 h의 14배만큼 증가한다는 것입니다.

이제 좀 더 복잡한 예제를 살펴봅시다.

a = 2.0
b = -3.0
c = 10.0
d = a*b + c

이 예제는 세 개의 스칼라 입력의 함수입니다. a, b, c는 특정 값들이고 이 표현식에서 세 가지 입력을 뜻합니다. 그리고 하나의 출력 d가 있습니다.

여기서 우리가 할 일은 a, b, c에 대한 d의 미분값을 살펴보는 것입니다.

미분이 우리에게 알려주는 것을 직관적으로 이해하기 위해 카파시는 여기서 약간의 편법을 사용합니다.

h = 0.0001

# inputs
a = 2.0
b = -3.0
c = 10.0

d1 = a*b + c
a += h # a를 h만큼 증가시킴
d2 = a*b + c # a가 h만큼 증가했을 때의 d값

print('d1', d1) # 4
print('d2', d2)
print('slope', (d2 - d1) / h)

여기서 중요한 것은 d2입니다. 직관적으로 생각해보자면, d1(4) 보다 큰 값일까 작은 값일까요? 이에 따라 미분값의 부호를 알 수 있습니다. a를 h만큼 늘렸고 여기에 음수값인 b(-3)을 곱했기 때문에 d2는 d1(4) 보다 작을 수밖에 없습니다.

  • d1: 4.0
  • d2: 3.999699999999999

원래 d1(4.0)에서 d2(3.999699999999999)로 변했습니다. 즉, d1보다 d2가 더 작아졌습니다. 그렇다면 기울기는 어떻게 될까요?

  • d2 - d1: 값을 살짝 올렸을 때 함수가 얼마나 반응했는지
  • (d2 - d1) / h: 기울기
    • -3.000000000010772
    • 즉, 기울기는 -3이 되는 것입니다.
  • 이것은 다시 말해, a를 h만큼 증가시켰을 때, 함수 d는 h의 -3배만큼 증가한다는 것입니다. (민감도가 -3)

a에 대한 d의 미분값을 살펴봤으니, b와 c에 대한 d의 미분값을 살펴봅시다.

d1 = a*b + c
b += h
d2 = a*b + c

print('d1', d1) # 4
print('d2', d2)
print('slope', (d2 - d1) / h)
  • d1: 4.0
  • d2: 4.0002
  • (d2 - d1) / h: 2.0000000000042206
    • 즉, 기울기는 2가 되는 것입니다.
    • b를 h만큼 증가시켰을 때, 함수 d는 h의 2배만큼 증가한다는 것입니다. (민감도가 2)
d1 = a*b + c
c += h
d2 = a*b + c

print('d1', d1) # 4
print('d2', d2)
print('slope', (d2 - d1) / h)
  • d1: 4.0
  • d2: 4.0001
  • (d2 - d1) / h: 0.9999999999976694
  • 기울기는 1이 됩니다.
    • 직관적으로 생각해보면, c는 d에 더해지는 값이므로 c를 h만큼 증가시키면 d도 h만큼 증가할 것입니다. 따라서 기울기는 1이 되는 것입니다.

micrograd: Value 객체

카파시는 위와 같은 연산들을 파이썬 코드로 구현하기 위해 Value 객체를 만들었습니다. 이를 통해 계산의 역사를 추적할 수 있게 합니다.

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

  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

Value는 숫자(float)값의 Wrapper 클래스로서 -4, 2와 같은 단일 스칼라(숫자) 값을 내부의 data 속성으로 감싸서 보관합니다.

Value 객체가 하는 일 (계산 그래프 구축)

  • data: 현재의 숫자 값 (ex: -6.0)
  • grad: 미분값. 초기값은 0.0으로 설정합니다.
  • _children: 이 숫자를 만드는 데 사용된 부모 숫자들 (ex: a, b). 일종의 포인터 역할을 합니다.
  • _op: 이 숫자를 만드는 데 사용된 연산자 (ex: '*')
  • label: 이 노드의 이름. 디버깅을 위해 사용됩니다.

Value를 따로 만들어서 계산에 사용하는 이유

1. 일반 숫자의 한계 (정보 손실)

파이썬에서 a = 2.0; b = -3.0; d = a * b 라고 계산하면, d에는 오직 -6.0 이라는 결과값만 저장됩니다. 이 계산이 끝나면 -6이 a와 b의 곱셈으로 만들어졌다는 사실을 잊어버립니다.

그런데 이것은 딥러닝의 핵심 개념인 역전파(backpropagation)를 구현하는 데 있어 치명적인 문제가 됩니다. a와 b의 미분값을 구하고 싶어도, 그 연결고리가 끊겨 있기 때문에 불가능합니다.

2. Value를 사용하는 이유

이렇게 계산할 때마다 "누구로부터 어떤 연산을 통해 내가 태어났는지"를 기록해 두면, 전체 계산이 끝난 후 거꾸로(Backward) 하나씩 짚어 내려갈 수 있습니다.

  1. 출력값에서 시작해서,
  2. 기록된 연산(++, * 등)을 보고,
  3. 부모들에게 "나의 기울기가 이만큼이니, 너희들의 기울기는 이 정도야"라고 알려줍니다. (Chain Rule 활용)

결과적으로 사용자는 평소처럼 d = a * b + c와 같이 수식을 쓰기만 하면, Value 객체가 뒤에서 알아서 거대한 **계산 그래프(Computational Graph)**를 만들고, 마지막에 d.backward() 명령어를 통해 모든 입력값의 미분값이 자동으로 계산되는 것입니다.

그래프 시각화

카파시는 graphviz 라이브러리를 활용하여 이 Value 객체들의 관계를 시각화할 수 있는 함수를 구현했습니다. 이 함수를 통해 계산 그래프를 시각화하면 다음과 같습니다.

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'

시각화 된 그래프:

example_2

위 그래프에서 각 노드는 Value 객체를 나타내며, 화살표는 연산의 흐름을 나타냅니다. 예를 들어, e 노드는 ab의 곱셈으로 만들어졌음을 알 수 있습니다. 여러 입력값(a, b, c, f)이 연산을 통해 하나의 출력값(L)을 만들어 내는 흐름 즉, 순전파(Forward Pass)를 시각화 한 것입니다.

다음으로 우리가 해야 할 것은 역전파(backpropagation)를 실행하는 것이며, 역전파에서는 끝(L)에서 시작해서 거꾸로(Backward) 가면서 모든 중간 값들에 대해 gradient를 계산할 것입니다.


역전파(Backpropagation)

example_2

L의 미분값은 그냥 1입니다.

L의 f에 대한 미분값, L의 d에 대한 미분값, L의 c에 대한 미분값, L의 e에 대한 미분값, L의 b에 대한 미분값, L의 a에 대한 미분값을 구해야 합니다.

신경망에서는 각 노드에 대한 L의 미분값을 구하는 것이 중요합니다. 이를 통해 각 가중치들이 최종 출력값(L)에 어떠한 영향을 미치는지 알 수 있습니다.

노드는 크게 2가지로 분류할 수 있습니다:

  1. 데이터 노드: 외부에서 주어진 데이터로서 고정된(Fixed) 값이므로 임의로 변경할 수 없습니다. 따라서 데이터에 대한 기울기는 구하더라도 실제로 사용하지 않습니다.
  2. 가중치 노드: 가중치와 편향은 '우리가 통제하고 마음대로 바꿀 수 있는 값'입니다. gradient에 따라서 값을 업데이트하여 최종 출력값을 개선해 나갑니다.

위 수식에서 a,b,c,f 중에서 a와 b를 데이터 노드(입력)로 지정하면 c,f는 가중치 노드가 되는 것입니다. 다만 역전파 과정에서는 다른 중간 노드들(d, e)의 미분값도 모두 계산해야 합니다.

역전파 계산

이제 각 노드에 대한 L의 미분값을 수동으로 계산하는 방식으로 역전파 방식이 어떻게 동작하는지 이해해 봅시다. 이를 위해 각 노드에 grad 라는 새로운 속성을 추가하고, 이 속성에 미분값을 저장할 것입니다.

class Value:
  def __init__(self, data, _children=(), _op='', label=''):
      self.data = data
      self.grad = 0.0
      self._prev = set(_children)
      self._op = _op
      self.label = label
  • grad를 추가합니다. 초기값은 0.0입니다.

역전파는 L에서 시작해서 거꾸로(Backward) 가면서 모든 중간 값들에 대해 gradient를 계산하는 것입니다. 그래서 가장 첫 시작은 L에 대한 L의 미분값을 구하는 것입니다. 각 노드에 대한 미분값을 구하기 위한 함수 lol을 작성해봅시다.

def lol():

  h = 0.001
  
  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() # 1.000000000000334
  • L에 대한 L의 미분값은 1입니다.
  • L.grad = 1.0 코드를 통해 L 객체의 grad 속성을 1로 초기화합니다.
example_3

그러면 L 다음으로 미분값을 구해야 할 노드는 무엇일까요? 바로 L의 부모 노드인 d와 f입니다.

d에 대한 L의 미분값

// L = d * f
(f(x+h)-f(x))/h
 = ((d+h)*f - d*f)/h
 = (d*f + h*f - d*f) / h
 = h*f / h
 = f
  • dL/dd = f
  • d.grad = -2.0

f에 대한 L의 미분값

// L = d * f
(f(x+h) - f(x)) / h
=> ((f+h)*d - d*f) / h
=> (f*d + h*d - d*f) / h
=> h*d / h
=> d
  • dL/df = d
  • f.grad = 4.0
example_4

L에서부터 시작하여 거꾸로(Backward) 가면서 d와 f의 미분값을 구했습니다.

dL / dc

c에 대한 L의 미분값

카파시는 이제 역전파의 핵심에 도달하고 있으며 이것이 아마도 이해해야 할 가장 중요한 노드일 것이라고 말합니다.

이 노드의 gradient를 이해하면 모든것을 이해하는 것입니다. 기본적으로 역전파와 신경망 학습의 모든 것을 말이죠. - 안드레아 카파시

이제 dL / dc를 유도해봅시다. 수동으로 역전파 계산을 하는 것은 동일합니다.

dL/dc를 바로 계산하기 전에 일단 우리가 알고 있는 것을 생각해봅시다:

  • d에 대한 L의 미분값: L이 d에 얼마나 민감한지
  • c가 d에 얼마나 영향을 미치는지
example_5

직관적으로 c가 d에 미치는 영향과 d가 L에 미치는 영향을 안다면, 그 정보를 어떻게든 하나로 합쳐서 c가 L에 얼마나 영향을 미치는지 알 수 있을 것입니다.

d에 대한 L의 미분값은 우리가 이미 알고 있고 c에 대한 d의 미분값만 계산하면 될 것 같습니다!

c에 대한 d의 미분값

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

이 시점에서 딥러닝의 아주 중요한 핵심 아이디어인 연쇄법칙(Chain Rule)을 적용할 수 있습니다.

연쇄 법칙(Chain Rule)

연쇄법칙은 쉽게 말해 배율의 대물림입니다.

우리는 이미 다음 두 가지 사실을 알고 있습니다:

  1. d가 변할 때 L은 -2배만큼 변한다 (dL/dd = -2.0)
  2. c가 변할 때 d는 1배만큼 변한다(dd/dc = 1.0)

그렇다면 **c가 변할 때 L은 최종적으로 얼마나 변할까?**라는 질문의 답은 아주 간단합니다. 이 두 배율을 그냥 곱하면 됩니다. dL/dc = dL/dd * dd/dc = -2.0 * 1.0 = -2.0

이것이 연쇄법칙의 전부입니다.

연쇄법칙을 설명하는 아주 유명한 비유가 있습니다.

차가 자전거보다 2배 빠르고 자전거가 사람보다 4배 빠르다면, 차는 사람보다 8배 빠르다. (by George F. Simmons)

example_7

즉, 차가 사람보다 몇배나 빠른지 한 번에 계산하는 것이 아니라, 각 단계별로 얼마나 빠른지 계산한 다음, 그 값들을 곱해주면 최종적으로 차가 사람보다 몇배나 빠른지 알 수 있습니다. 이 원리 그대로 우리 그래프에 적용해 봅시다.

  1. 차가 자전거보다 2배 빠르다
    • L에 대한 d의 민감도는 -2이다
  2. 자전거는 사람보다 4배 빠르다
    • d에 대한 c의 민감도는 1이다
  3. 차는 사람보다 8배 빠르다
    • L에 대한 c의 민감도는 -2이다

더하기 노드

위 계산식에서 dd/dc는 단순히 1.0입니다. 이것은 덧셈 연산의 특성 때문입니다. 덧셈 연산은 입력값 중 하나가 1만큼 변하면 출력값도 1만큼 변하기 때문입니다.

그래서 더하기 노드는 입력값들의 gradient를 그대로 출력값의 gradient로 전달하는 역할을 합니다. 따라서 이 법칙을 그대로 적용하면 dd/de도 1.0이고 이에 따라서 dL/de도 -2.0이 됩니다. 연쇄법칙을 적용하여 계산해도 1.0을 곱하는 것이기 때문에 원본 값이 그대로 유지되는 것입니다.

example_6
  • dL/dd = -2.0이 그대로 dL/dc, dL/de로 전달되는 것을 볼 수 있습니다.

역전파 완성

다시 역전파 계산으로 돌아와서 이제 마지막 남은 노드인 a와 b에 대한 미분값을 계산해 봅시다.

이 계산은 지금까지 수동을 계산한 방식을 그대로 사용해서 직접 해보는 것을 추천합니다. 모든 계산이 끝나서 각 노드 별 grad 속성까지 전부 할당하고 다시 그래프를 그리면 다음과 같습니다.

example_8

자, 이제 수동으로 첫 노드부터 시작하여 마지막까지 모든 노드의 gradient를 계산하여 역전파를 완성했습니다. 우리가 한 것은 단순히 모든 노드를 하나씩 순회하면서 Chain rule을 적용한 것뿐입니다.


L 최적화 및 파라미터 업데이트

우리가 지금까지 각 노드의 기울기(grad)를 구한 이유는 결국 최종 출력값인 L을 우리가 원하는 목표값(0)에 가깝게 만들기 위해서입니다.

공식: step_size 만큼 움직이기

기울기(grad)는 수학적으로 함수값을 가장 빠르게 키우는 방향을 가리킵니다. 따라서 미분값을 이용해 변수를 조정할 때는 우리의 목표가 무엇이냐에 따라 기호(+ 또는 -)가 결정됩니다.

  1. L을 키우고 싶을 때 (현재 우리 상황):
    • 기울기가 가리키는 '커지는 방향'으로 그대로 가주면 되기 때문에 더하기(+=)를 사용합니다. (Gradient Ascent)
  2. L을 줄이고 싶을 때 (일반적인 딥러닝 상황):
    • 기울기가 가리키는 '커지는 방향'의 반대로 도망쳐야 하므로 빼기(-=)를 사용합니다. (Gradient Descent)

현재 우리 예제에서 L은 -8.0입니다. 이를 목표값인 0에 가깝게 만들려면 값을 증가시켜야 하므로, 기울기 방향 그대로 더해주는(+=) 방식을 사용합니다.

step_size = 0.01

# L을 증가시키는 방향으로 변수들을 조정 (+= 사용)
a.data += step_size * a.grad
b.data += step_size * b.grad
c.data += step_size * c.grad
f.data += step_size * f.grad
# e, d는 중간 노드이므로 업데이트하지 않습니다.

# 업데이트 후 다시 계산 (Fast Forward)
e = a * b
d = e + c
L = d * f
print(L.data) # -7.286496 (0에 더 가까워짐)

모든 입력값들을 기울기 방향으로 조정했기 때문에 L값이 좀 더 커진 것을 볼 수 있습니다.

  • 최초 L: -8.0
  • 업데이트 후 L: -7.286496

보통 딥러닝에서는 오차(Loss)가 양수이며 이를 최소화하는 것이 목표이기 때문에 주로 -=를 사용하게 됩니다. 하지만 지금처럼 음수 결과를 0으로 끌어올리는 상황에서는 +=를 통해 '최적화'의 한 단계를 성공적으로 수행한 것입니다.

이것이 '최적화'의 전체 과정입니다. 우리는 각 노드의 기울기를 통해 이들이 최종 결과 L에 어떠한 영향을 미치는지 알고 있습니다. 이 정보를 바탕으로 L을 우리가 목표로 하는 방향(0)으로 변수들의 값을 조정해 나가는 것입니다.


이번 글은 여기서 마무리하겠습니다. 원본 영상의 대략 앞부분 51분 정도를 다뤘습니다.

다음 글에서는 영상의 후반부를 다루도록 하겠습니다.

Comments (0)

Checking login status...

No comments yet. Be the first to comment!