■ 미분은 머신러닝, 딥러닝의 여러 분야에서 중요한 역할을 하며, 텐서플로와 파이토치같은 딥러닝 프레임워크는 일종의 미분 계산 도구이다. 미분을 계산하기 위해서는 '변수'와 '함수'가 필요하다.
1. 변수
■ 변수의 역할은 다음과 같은 상자와 같다.
■ 변수(여기서는 상자)에 데이터를 넣을 수 있고(=할당할 수 있고), 상자 속을 보면 상자 속에 들어 있는 데이터가 무엇인지 확인할 수 있다(=참조할 수 있다).
1.1 Variable 클래스 구현
■ 상자와 같은 역할을 하는 변수라는 개념을 다음과 같이 클래스로 구현할 수 있다.
class Variable:
def __init__(self, data):
self.data = data
■ 이렇게 초기화 함수 __init__에서 self.data라는 인스턴스 변수에 data를 대입하면, Variable 클래스를 위의 상자처럼 사용할 수 있다. 실제 데이터는 다음과 같이 Variable 클래스의 self.data에 보관되기 때문이다.
import numpy as np
data = np.array(1.0)
x = Variable(data)
x.data
```#결과#```
array(1.)
````````````
- Variable 클래스의 인스턴스 x를 위와 같이 생성하면, x의 인스턴스 변수인 self.data에 실제 데이터인 1.0이 담겨 있다.
- 즉, x는 데이터를 담은 상자가 된다. 상자 속을 보는 방법은 인스턴스에 .data로 접근해서 확인하면 된다.
■ x라는 상자에 새로운 데이터를 담고 싶으면 다음과 같이 할 수 있다.
x.data = np.array(2.0)
x.data
```#결과#```
array(2.)
````````````
1.2 다차원 배열
■ 다차원 배열은 순서에 방향이 있는 원소들이 모여 있는 데이터 구조를 말한다. 이 방향을 차원(dimension) 또는 축(axis)이라고 부른다.
- 예를 들어, 스칼라는 하나의 숫자이므로 방향이 없어서 스칼라를 0차원 배열이라고도 부른다.
- 벡터의 경우 축이 1개이므로 1차원 배열, 행렬은 axis = 0 방향(열 방향)과 axis = 1 방향(행 방향). 2개의 축을 가지므로 2차원 배열이라고도 부른다.
- 3차원 배열은 2차원 배열인 행렬에 axis = 2라는 축이 하나 더 생긴 배열을 말한다.
- 다차원 배열을 텐서(tensor)라고도 한다. 즉, 0차원 배열, 1차원 배열, 2차원 배열, 3차원 배열을 0차원 텐서, 1차원 텐서, 2차원 텐서, 3차원 텐서라고도 부른다.
■ 넘파이의 ndarray 인스턴스에는 ndim(number of dimensions)라는 '차원 수'를 확인할 수 있는 변수가 있다.
print(type(scalar), type(vector), type(matrix))
print(np.shape(scalar), np.shape(vector), np.shape(matrix))
print(scalar.ndim, vector.ndim, matrix.ndim)
```#결과#```
<class 'numpy.ndarray'> <class 'numpy.ndarray'> <class 'numpy.ndarray'>
() (3,) (2, 3)
0 1 2
````````````
- shape을 통해 배열의 형상을 확인할 수도 있다.
cf) 차원과 벡터의 차원은 다르다. 벡터의 차원은 벡터의 원소의 수를 말한다. 이 예시에서 vector라는 '벡터의 차원'은 3이다.
2. 함수
■ 1.에서 Variable 클래스를 통해 '변수'를 통해 데이터를 저장할 수 있게 되었다. '변수'에 담긴 데이터를 활용하기 위해 필요한 것이 바로 '함수'이다.
2.1 함수란
■ 함수란 다음 그림과 같이 어떤 (입력) 변수로부터 다른 (출력) 변수로의 대응 관계를 나타내는 것이라고 할 수 있다.
■ 예를 들어, \( y = f(x) \)라는 변수 \( x \)와 변수 \( y \)의 관계를 정의한 함수 \( f \)가 있다고 하자. 만약, \( f(x) = x^2 \)이라면, 이는 함수 \( f \)에 의해 변수 \( y \)는 변수 \( x \)의 제곱이다.라는 대응 관계가 성립한다. 이렇게 함수는 변수 사이의 대응 관계를 정의하는 역할을 한다.
■ 다음 그림과 같이 변수와 함수의 관계를 노드(node)와 엣지(edge)로 구성된 그래프로 나타낸 것을 계산 그래프(computational graph)라고 한다.
- 위의 그림처럼 화살표에 방향이 있는 그래프를 directed graph, 방향이 없는 그래프를 무방향 그래프(undirected graph)라고 부른다.
2.2 Function 클래스 구현
■ Function 클래스가 ① Function 클래스 외부에서 정의된 Variable 인스턴스를 입력받으면, ② Function 클래스 내부에서는 입력받은 Variable 인스턴스의 데이터를 꺼내서 연산을 수행한 다음, ③ 그 연산 결과값(데이터)을 새로운 Variable 인스턴스에 담아서 출력하도록 만들 수 있다.
■ 예를 들어, 함수가 \( y = f(x) \)이고 \( f(x) = x^2 \)라는 연산을 수행할 때, 입력값이 다음과 같다면
x = Variable(np.array(5))
print(x.data)
```#결과#```
5
````````````
■ Function 클래스를 다음과 같이 정의할 수 있다.
class Function:
def __call__(self, input):
x = input.data # Variable 클래스의 인스턴스에서 실제 데이터를 꺼낸다.
y = x**2 # 연산
output = Variable(y) # 연산 결과를 Variable 클래스의 인스턴스로 출력한다.
return output
- 파이썬 클래스의 __call__ 메서드는 클래스의 객체도 호출할 수 있도록 만들어주는 메서드이다.
- 다음과 같이 클래스의 인스턴스를 생성한 다음,
f = Function()
- 생성된 인스턴스(f)를 호출하면 __call__ 메서드가 작동하여 __call__ 메서드의 return 값이 반환된다. 다음과 같이 의도한대로 Variable 인스턴스가 반환되는 것을 확인할 수 있다.
f(x)
```#결과#```
<__main__.Variable at 0x21bd8e2f320>
````````````
- 이렇게 __call__ 메서드를 통해 클래스의 인스턴스를 함수로 취급하여 Variable 클래스와 Function 클래스를 연계할 수 있다.
f = Function() # Function 클래스 인스턴스 생성
y = f(x) # f( ) <=> Function 클래스 인스턴스 호출 -> __call__ 메서드 실행, 입력은 x
print(type(y)); print(y.data)
```#결과#```
<class '__main__.Variable'>
25
````````````
■ 단, Function 클래스를 이렇게 구현하면, 이 클래스는 \( y = x^2 \)만 연산하는 클래스가 된다. 그러나 사용자가 원하는 연산은 매번 다를 수 있다.
■ 그러므로 사용자가 원하는 연산은 forward라는 메서드에서 수행되게 만들고, 이 forward 메서드에서 사용자가 원하는 연산이 수행하되도록 사용자가 직접 forward 메서드를 구현하는 방법이 있다.
class Function:
def __call__(self, input):
x = input.data
y = self.forward(x) # 사용자가 원하는 연산은 forward 메서드에서 진행된다.
output = Variable(y)
return output
def forward(self, x):
raise NotImplementedError()
- 이렇게 정의하면, Function의 구조는 텐서플로우나 파이토치에서 모델을 설계하는 구조와 비슷한 것을 볼 수 있다.
■ 위의 새롭게 정의한 Function 클래스에서 __call__ 메서드는 Variable 클래스의 인스턴스에서 데이터를 꺼내오고, 구체적인 연산은 forward 메서드를 호출하여 수행한다. 그리고 연산 결과를 새로운 Variable 클래스의 인스턴스에 저장하는 역할을 수행한다.
- NotImplementedError는 이름에서 알 수 있듯이 아직 구현되지 않았음을 의미한다.
■ 이제, 사용자가 원하는 연산이 매번 다를 때, 새롭게 정의한 Function 클래스를 상속받아서 수행할 수 있다.
class Square(Function):
def forward(self, x):
return x**2
class Add10(Function):
def forward(self, x):
return x + 10
f_square = Square()
f_add = Add10()
y_square = f_square(x)
y_add = f_add(x)
print(y_square.data); print(y_add.data)
```#결과#```
25
15
````````````
- Square 클래스와 Add10 클래스는 Function 클래스를 상속하기 때문에 Function 클래스의 매직 메서드인 __call__ 메서드는 그대로 상속된다.
- 즉, Square 클래스와 Add10 클래스의 forward 메서드에 구체적인 연산 로직을 넣으면 그것으로 연산이 수행된다.
- 이 예에서는 부모 클래스인 Function은 입력을 input 한 개만 받고 출력도 output 한 개만 출력한다. 입력을 여러 개 늘리고 출력을 여러 개 늘려 다중 입력 - 다중 출력의 관계를 정의하는 함수를 만들 수도 있다.
3. 함수 연결
■ 2.에서 연산용 함수 클래스로 Square와 Add10 클래스를 구현했다. 이렇게 구현한 연산용 함수들을 연결해서 사용할 수 있다.
■ 3개의 함수를 조립하기 위해 \( y = e^x \)를 연산하는 연산용 함수 클래스를 하나 더 만들려고 한다.
■ \( e \)는 '자연로그의 밑'으로 2.718...의 값을 갖는 상수이다. 이 상수를 자연 상수라고 하며
- 이 자연 상수 \( e \)를 오일러의 수(Euler's number) 또는 네이피어의 상수(Nepier's constant)라고도 한다.
class EXP(Function):
def forward(self, x):
return np.exp(x)
3.1 함수 연결 예시
■ Function 클래스의 __call__ 메서드는 입력과 출력이 모두 Variable 클래스의 인스턴스이므로 Function 클래스를 상속받는 Square, Add10, EXP라는 연산용 함수 클래스를 다음과 같이 연이어 사용해서 계산할 수 있다.
■ 예를 들어 \( y = ( e^{x^2} + 10 )^2 \)이라는 연산을 수행하고 싶다면
- 먼저, Square 클래스를 통해 입력(\( x \))의 제곱을 연산한 다음, 이 결과값을 EXP 클래스의 입력으로 넣어서 \( e^{x^2} \)을 계산한다.
- 그리고 이 결과값은 다시 Add10 클래스의 입력으로 넣어서 \( e^{x^2} + 10 \)이라는 연산을 수행하고, 여기서 나온 결과값을 다시 Square 클래스에 넣으면 된다.
■ 만약, 입력인 \( x \)가 \( x = 1 \)이라면, \( y = \left( e^{1^2} + 10 \right)^2 \)의 연산이 수행된다.
## 연산용 함수 클래스들의 인스턴스 생성
f_square = Square()
f_add_10 = Add10()
f_exp = EXP()
x = Variable(np.array(1)) # 입력 데이터
## 함수 연결 과정
x_square = f_square(x)
exp_x_square = f_exp(x_square)
exp_x_square_add_10 = f_add_10(exp_x_square)
exp_x_square_add_10_square = f_square(exp_x_square_add_10)
## 연산 결과
print(exp_x_square_add_10_square.data)
```#결과#```
161.75469266811155
````````````
- x, x_square, exp_x_square_add_10, exp_x_square_add_10_square라는 변수는 모두 Variable 클래스의 인스턴스이다.
- 연산용 함수 클래스들은 Function 클래스의 __call__ 메서드를 상속받는데, Function 클래스의 __call__ 메서드의 입출력은 모두 Variable 인스턴스이므로, 위와 같이 여러 함수를 연속해서 적용할 수 있는 것이다.
- 이렇게 여러 함수를 순서대로 적용하여 만들어진 것을 하나의 큰 하나의 함수로 볼 수 있다. 수학에서는 이렇게 여러 함수로 구성된 함수를 '합성 함수(composite function)'라고 부른다.
- 이렇게 합성 함수를 이용하면, 간단한 함수(이 예에서는 제곱, 더하기, exp)를 연속으로 수행해서 더 복잡한 연산(이 예에서는 \( y = ( e^{x^2} + 10)^2 \) )을 수행할 수 있다.
■ 이 예시와 같은 일련의 연산. 즉, 합성 함수의 연산을 그래프로 나타낸 것이 바로 계산 그래프이다. 계산 그래프를 사용하는 이유는 다음과 같이 역전파 과정에서 각 변수에 대한 미분 계산을 쉽게 파악할 수 있기 때문이다.
'딥러닝' 카테고리의 다른 글
미분 자동 계산 (3) (0) | 2025.03.13 |
---|---|
미분 자동 계산 (2) (0) | 2025.03.11 |
트랜스포머 (Transformer) (3) (0) | 2025.01.10 |
트랜스포머 (Transformer) (2) (0) | 2025.01.09 |
트랜스포머 (Transformer) (1) (0) | 2025.01.09 |