by Yngie

재귀와 병합 정렬(Recursion & Merge Sort)

|

본 게시물은 문일철 교수님의 데이터 구조 및 분석을 참조하여 작성하였습니다.

Recursions

Divide & Conquer

fractal

이미지 출처 : wikipedia : Fractal

어떤 문제들은 위 그림에서 볼 수 있는 것처럼 작게 나누더라도 동일한 구조를 가지는 경우가 있습니다. 예를 들어, 그림과 같은 조직도를 가진 회사에서 예산을 나누는 경우를 생각해보겠습니다.

org_chart

이미지 출처 : swuc21.com

정해진 총 예산을 $\mathbf{N}$ 이라 합시다. 이를 Sales, Manufacturing, Customer Support에 $N_1, N_2, N_3$ 로 나눠 배정하게 됩니다. 그리고 Manufacturing 내에 있는 Department는 $N_2$ 를 또 다시 부서 내부의 각 팀에 $n_1, n_2,n_3$ 만큼 배정합니다.

이렇게 어떤 문제는 더 작게 나누더라도 구조적으로는 동일한 문제가 반복됩니다. 이렇게 문제를 나누어 생각해도 동일한 구조를 가지는(Self-similar) 문제를 Repeating Problem 이라고 합니다. 이 때 문제를 작게 나누는 행위를 분할(Divide) , 그리고 나눈 문제를 해결하는 것을 정복(Conquer) 이라고 합니다. 마치 아래의 마트료시카 인형처럼 큰 문제를 계속 작게 분할해나가고, 가장 작은 문제부터 정복해 올라오게 되지요.

Matryoshka

이미지 출처 : dookinternational.com

이러한 Repeating Problem에 해당하는 문제는 어떤 것들이 있을까요? 대표적인 형태는 수학에서 점화식(Mathmatical Induction)으로 나타나는 것들입니다. 가장 대표적인 예시로 팩토리얼(Factorial)을 구하는 과정이 이에 해당합니다. 팩토리얼을 구하는 과정을 수식으로 나타내면 아래와 같습니다.

[\text{Factorial}(n) = \begin{cases} 1 \qquad \qquad \qquad \qquad \qquad \text{if} \quad n = 0\ n \times n-1 \times \cdots \times 2 \times 1 \quad \text{if} \quad n > 0 \end{cases}
\text{Factorial}(n) = \begin{cases} 1 \qquad \qquad \qquad \qquad \qquad \text{if} \quad n = 0\ n \times\text{Factorial}(n-1) \qquad \text{if} \quad n > 0 \end{cases}]

Recursion

위와 같이 Repeating problem을 분할과 정복을 사용하여 푸는 가장 일반적인 방법이 바로 재귀(Recursion)입니다. 재귀의 코드는 일반적으로 다음과 같은 형태를 띠고 있습니다.

def recursionFunction(target):
    if escapeCondition:	# 탈출을 위한 조건문
        return Value
    # ... 함수 내용
    recursionFunction(target_) # 재귀 호출

재귀 함수에는 두 가지의 필수적인 요소가 있습니다. 하나는 탈출을 위한 조건문입니다. 이 조건문이 있어야 분할한 문제를 하나씩 정복하여 원하는 값을 얻을 수 있습니다. 두 번째는 다시 자신을 호출하는 함수 호출문입니다. 대신 함수 안에서 호출되는 함수의 인자는 원래 함수의 인자를 축소한 것이어야 합니다. 더 작은 문제로 나아가기 위함입니다.

글로만 이해하기는 어려우니 예시 코드를 보겠습니다. 아래는 재귀 문제의 대표적인 예시인 피보나치 수열을 파이썬 코드로 구현한 것입니다.

def Fibonacci(n):
    if n == 0:
        return 0
    if n == 1:
        return 1

    intRet = Fibonacci(n-1) + Fibonacci(n-2)
    return intRet

함수 코드의 상단에는 각 상황에서의 탈출문이 구현되어 있는 것을 볼 수 있습니다. 그 아래에는 더 작은 인자를 가지는 피보나치 함수를 다시 호출하고 있는 것을 볼 수 있습니다. 임의의 인자를 넣어 이 재귀 함수의 인자가 어떻게 작동하는 지 보도록 하겠습니다. $n=5$, 즉 Fibonacci(5) 일 때 이 함수가 호출되는 과정을 그림으로 나타내면 아래와 같이 됩니다.

fibo

이미지 출처 : andreagrandi.it

가장 먼저 $fib(5)$ 로부터 $fib(4), fib(3)$ 이 호출됩니다. 이 중에서 $fib(4)$가 있는 왼쪽을 먼저 보겠습니다. $fib(4)$는 다시 $fib(3)$과 $fib(2)$를 호출합니다. 여기서 $fib(4)$ 왼쪽 아래의 $fib(3)$ 이하의 형태와 $fib(5)$ 오른쪽 아래의 $fib(3)$ 이하의 형태가 동일한 것을 볼 수 있습니다. $fib(4)$ 오른쪽 아래의 $fib(2)$ 이하의 형태와 $fib(3)$ 왼쪽 아래의 $fib(2)$ 이하의 형태 역시 동일한 것도 볼 수 있지요. 이렇게 동일한 형태가 계속 반복되면서 호출 그래프의 맨 아래에는 $fib(1)$과 $fib(0)$만 남게 됩니다.

이 $fib(1)$과 $fib(0)$은 탈출 조건문에 의해 값을 구할 수 있습니다. 이 값으로부터 $fib(2),fib(3),fib(4),fib(5)$의 값을 차례대로 구해나가게 됩니다.

in Stackframe

이렇게 재귀 함수를 호출하면 컴퓨터 안에서는 어떤 일이 일어나는지 알아보겠습니다. 재귀 함수가 호출되면 컴퓨터는 스택 프레임(Stack frame) 내부에 함수 호출 아이템(Item)을 쌓아갑니다. 스택 프래임이란 함수 호출 이력을 저장하는 스택이며 쌓이는 아이템은 함수 내에 있는 지역 변수와 함수 호출 인자가 포함되어 있습니다. 함수가 호출되면 아이템이 push 되고, 함수가 끝나거나 리턴되면 호출 요청되면 아이템이 pop 됩니다.

아래는 재귀로 구현한 피보나치 함수를 호출했을 때 스택 프레임 $fib(5)$ 는 너무 복잡하므로 한 단계 낮은 인자인 $fib(4)$를 컴퓨터가 어떻게 처리하는 지에 대한 그림입니다.

Call-stack-of-Fibonacci

이미지 출처 : knowledge-cess.com

위 그림을 보면 가장 먼저 호출된 $fib(4)$ 가 push 됩니다. 그 위로 $fib(3), fib(2)$ 가 호출되어 push 되고 있는 것을 볼 수 있습니다. 계속해서 이렇게 호출된 함수들이 분할한 함수 호출을 계속해서 쌓아나갑니다. 이렇게 호출된 모든 함수가 스택 프레임에 쌓이게 되면 맨 위쪽부터 pop 이 되며 빠져나갑니다. 아이템이 차례대로 pop 되면 스택 프레임의 맨 아래에서 처음에 호출했던 $fib(4)$의 값을 구할 수 있게 됩니다.

Merge Sort

병합 정렬(Merge sort)은 다양한 정렬 중에서 재귀를 이용합 정렬 방법을 사용합니다. 병합 정렬은 분할에 해당하는 분해(Decomposition)와 정복에 해당하는 통합(Aggregation)으로 이루어져 있는 분할-정복 알고리즘 중 하나입니다. 분해는 하나의 리스트를 반씩 토막내는 메커니즘이고 통합은 2개의 리스트의 요소를 작은 순서대로 하나의 리스트로 합쳐 배열하는 메커니즘입니다.

merge_sort

이미지 출처 : wikipedia.org

병합 정렬을 파이썬 코드로 구현하면 다음과 같이 쓸 수 있다.

import random

def performMergeSort(lstElementToSort):
    # 탈출을 위한 조건문
    if len(lstElementToSort) == 1:
        return lstElementToSort

    """
    Decomposition
    1개를 길이가 같은 2개의 리스트로 분리한다
    """
    lstSubElementToSort1 = []
    lstSubElementToSort2 = []

    for itr in range(len(lstElementToSort)):
        if len(lstElementToSort)/2 > itr:
            lstElementToSort1.append(lstElementToSort[itr])
        else:
            lstElementToSort2.append(lstElementToSort[itr])
	
    """
    Recursion
    리스트의 원소가 1개가 될 때까지 분리 과정을 반복한다
    """
    lstSubElementToSort1 = performMergeSort(lstSubElementToSort1)
    lstSubElementToSort2 = performMergeSort(lstSubElementToSort2)

    """
    Aggregation
    각 리스트 앞부분 부터 요소의 크기를 순차적으로 비교한 뒤
    크기가 작은 것부터 새로운 리스트의 앞부분에 배치한다
    """
    idxCount1 = 0
    idxCount2 = 0

    for itr in range(len(lstElementToSort)):
        if idxCount1 == len(lstSubElementToSort1):
            lstElementToSort[itr] = lstSubElementToSort2[idxCount2]
            idxCount2 += 1
        elif idxCount2 == len(lstSubElementToSort2):
            lstElementToSort[itr] = lstSubElementToSort1[idxCount1]
            idxCount1 += 1
        elif lstSubElementToSort1[idxCount1] > lstSubElementToSort2[idxCount2]:
            lstElementToSort[itr] = lstSubElementToSort2[idxCount2]
            idxCount2 += 1
        else:
            lstElementToSort[itr] = lstSubElementToSort1[idxCount1]
            idxCount1 += 1
    return lstElementToSort

Problems

재귀 호출에도 문제점이 있습니다. 재귀 호출의 문제점을 단적으로 보여줄 수 있는 사례가 바로 피보나치 수열입니다. 위에서 보았던 피보나치 함수의 호출 그래프를 다시 가져와 보겠습니다.

fib

이미지 출처 : andreagrandi.it

이어서 피보나치 함수의 인자를 5에서 7로 늘리면 어떻게 되는 지도 보겠습니다.

fib7

이미지 출처 : semanticscholar.org

인자가 5에서 7로만 늘어났는데도 함수 호출 그래프가 엄청나게 복잡해진 것을 볼 수 있습니다. 만약 $fib(10), fib(20)$ 정도를 호출한다면 지면이 허용하지 않을 정도로 함수 그래프가 복잡해질 것입니다. 함수 그래프가 이렇게 복잡해지는 이유는 무엇일까요? 바로 탈출문이 나올 때까지 분할 과정을 계속하기 때문입니다. 위 그림에서도 $fib(0), fib(1)$이 나올 때까지 모두 분할하기 때문에 $fib(0)$은 8번, $fib(1)$은 무려 13번이나 호출되고 있습니다.

이 때문에 재귀를 사용했을 때 걸리는 시간은 $N$이 일정 수준 이상으로 커지면 기하급수적으로 늘어나게 됩니다. 아래는 재귀로 구현된 피보나치 함수를 사용했을 때 걸리는 시간을 그래프로 나타낸 것입니다. $N \geq 30$ 일 때는 엄청나게 많은 시간이 걸리는 것을 볼 수 있지요.

time_comp

이미지 출처 : proc-x.com

이런 문제를 어떻게 해결할 수 있을까요? 재귀의 문제는 모든 함수를 탈출문이 나올 때까지 쪼갠다는 것이었습니다. 끝까지 쪼개지 않고 $fib(2)$를 한 번 구한다면 분할 없이 그 값을 바로 가져오고, $fib(3)$도 값을 알고난 후에는 분할 없이 그 값을 그대로 가져올 수 있다면 훨씬 빠르게 원하는 값을 구할 수 있을 것입니다. $fib(2), fib(3), \cdots$ 등의 값을 구하여 어디엔가 저장해 놓고 가져오기만 하는 것이지요.

이런 방식을 사용하여 재귀의 함수 호출 문제를 풀어낼 수 있습니다. 이것이 바로 다음에 등장할 동적 프로그래밍(Dynamic programming)입니다.

Comment  Read more

LSTM

|

본 포스트의 내용은 cs231n 강의자료 와 책 밑바닥에서 시작하는 딥러닝 2 를 참고하였습니다.

LSTM(Long Short Term Memory)

RNN에서의 장기 의존성 문제

RNN은 목표하고자 하는 단어가 멀리 떨어져 있을 때 기울기 소실 때문에 해당 단어를 학습하지 못하는 장기 의존성(Long-term dependency) 문제가 있었다. 이를 수식을 통해 좀 더 자세히 알아보자. RNN은닉층에서 일어나는 일을 편향(bias) 항을 제외하고 수식으로 나타내면 다음과 같이 나타낼 수 있다.

[h_t = f_W (h_{t-1}, x_t)
h_t = \tanh (h_{t-1}W_h + x_tW_x)]

$h_t$ 를 이전 단계의 $h$ 로 미분하면 다음과 같이 나타낼 수 있다.

[\frac{\partial h_t}{\partial h_{t-1}} = \tanh^\prime (h_{t-1}W_h + x_tW_x)W_h]

연쇄 법칙과 위 식을 활용하여 목표 시퀀스까지의 총 시간 $T$ 에 해당하는 출력값이 학습되는 과정을 다음과 같이 나타낼 수 있다.

[\frac{\partial L_T}{\partial W} = \sum_{t=1}^T \frac{\partial L_t}{\partial W}
\frac{\partial L_T}{\partial W} = \frac{\partial L_T}{\partial h_T} \frac{\partial h_T}{\partial h_{T-1}} \cdots \frac{\partial h_1}{\partial W} = \frac{\partial L_T}{\partial h_T} \bigg(\prod^T_{t=2} \frac{\partial h_t}{\partial h_{t-1}} \bigg) \frac{\partial h_1}{\partial W}
\frac{\partial L_T}{\partial W} = \frac{\partial L_T}{\partial h_T} \bigg(\prod^T_{t=2} \tanh^\prime (h_{t-1}W_h + x_tW_x) \bigg) W_h^{T-1} \frac{\partial h_1}{\partial W}]

위 식에서 괄호 안에 속해있는 부분 $\tanh^\prime (h_{t-1}W_h + x_tW_x)$ 은 $\tanh^\prime$ 의 특성에 따라 항상 0보다 같거나 작은 값을 나타낸다. (아래 그래프를 참조) 때문에 $T$ 가 커지면 그 값이 서로 곱해지면서 시퀀스에서 멀리 떨어진 토큰은 거의 학습되지 않는다. 이를 기울기 소실(Gradient Descent)이라고 한다.

tanh^prime

RNN의 활성화 함수(Activation function)인 tanh를 미분한 그래프이다. 최댓값이 1임을 알 수 있다.
이미지 출처 : ml-cheatsheet

LSTM (Long Short Term Memory)

장기 의존성 문제를 해결하고자 등장한 것이 LSTM(Long Short Term Memory)이다. 문제 해결을 위해 RNN의 구조를 변형해야 했고, 그리하여 게이트를 추가한 것이 바로 LSTM이다.

기본적인 형태의 RNN에는 입력 경로가 $h$ 하나 뿐이었지만, LSTM에서는 $c$ 라는 새로운 경로가 추가되었다. 아래는 LSTM의 계층이 동작하는 방식을 도식화하여 나타낸 것이다.

lstm1

이미지 출처 : cs231n 강의자료

여기서 새로운 경로 $c$ 에는 시각 $t$ 에서 LSTM의 기억이 저장되어 있다. 이렇게 새로운 경로가 생긴 이유는 새로운 게이트(Gate)가 추가되었기 때문인데 각각의 게이트에 대해서 더 자세히 살펴보도록 하자.

출력 게이트(Output Gate) : LSTM계층의 출력값은 일련의 계산을 거쳐 나온 $c$ 에 활성화 함수를 취해준 $\tanh (c_t)$ 이다. 출력 게이트는 이 값을 다음 계층에 얼마나 허용해 줄 것인지(얼마나 영향을 미치도록 할 지)를 결정하는 게이트이다. 출력 게이트는 시그모이드(Sigmoid) 함수로 작동하는데 게이트가 작동하는 수식을 다음과 같이 나타낼 수 있다.

[o = \sigma(x_tW_x^o + h_{t-1}W_h^o + b^o)]

이렇게 구한 $o$ 값에 $\tanh(c_t)$ 를 원소별로 곱(아다마르 곱1)해주어 최종 출력값인 $h_t$ 를 구할 수 있게 된다. 수식으로는 다음과 같이 나타낼 수 있다.

[h_t = o \circ \tanh(c_t)]

망각 게이트(Forget Gate) : 이전 셀까지의 기억 $c_{t-1}$ 중 필요하지 않은 기억을 얼마나 지울 지를 결정하는 게이트이다. 망각 게이트 역시 시그모이드 함수를 통해 작동하며 수식으로는 다음과 같이 나타낼 수 있다.

[f = \sigma(x_tW_x^f + h_{t-1}W_h^f + b^f)]

이렇게 구해진 $f$ 값에 $c_{t-1}$ 과 아다마르 곱을 해주어 해당 셀까지의 기억인 $c_t$ 값의 일부를 구한다.

[f \circ c_{t-1}]

게이트 게이트(Gate gate) : 망각 게이트만 있다면 새로운 토큰 $x_t$ 로부터 오는 값은 영향을 많이 주지 못하게 된다. 때문에 새로운 정보를 만들어 낼 게이트가 필요한데 이를 $g$ 라고 한다. 이는 정보량을 조절하기 위한 것이 아니라 정보를 만들기 위한 것이므로 활성화 함수로 RNN과 동일한 $\tanh$ 를 사용하게 된다.

[g = \tanh (x_tW_x^g + h_{t-1}W_h^g + b^g)]

입력 게이트(Input Gate) : 새로 추가되는 정보가 얼마나 중요한지를 결정하는 게이트이다. $g$ 로부터 만들어진 값을 조절하므로 활성화 함수로 시그모이드 함수를 사용하며, 원소별 곱을 통해 $g$ 의 값을 조정하게 된다.

[i = \sigma(x_tW_x^i + h_{t-1}W_h^i + b^i)
i \circ g]

이 게이트들이 출력값 $c_t, h_t$ 에 영향을 미치는 과정을 다음과 같은 수식으로 나타낼 수 있다.

[\left(\begin{array}{c} i \ f \ o \ g \end{array}\right) = \left(\begin{array}{c} \sigma \ \sigma \ \sigma \ \tanh \end{array}\right) W \left(\begin{array}{c} h_{t-1} \ x_t\end{array}\right)
c_t = f \circ c_{t-1} + i \circ g
h_t = o \circ \tanh(c_t)]

LSTM의 장점

LSTM은 어떻게 기울기 소실로 발생하는 장기 의존성 문제를 해결할 수 있는 것일까? LSTM의 역전파 과정을 살펴보자.

lstm2

이미지 출처 : cs231n 강의자료

그림을 보면 $c$ 가 학습되는 과정에서는 $+$ 와 $\circ$ 노드만을 지난다는 것을 알 수 있다. $+$ 노드의 경우 동일한 기울기를 그대로 흘려주므로 아무리 개수가 많더라도 기울기를 소실시키지 않는다. 아다마르 곱이 있는 노드의 경우에는 행렬 곱보다 기울기를 덜 소실시키며, 아다마르 곱이 매번 다른 망각 게이트 값에 의해 결정되므로 곱셈의 효과가 누적되지 않는다는 장점이 있다. 망각 게이트의 값은 매번 동일한 값이 아니라 학습을 통해 변하기 때문에 기울기 소실이 적은 최적의 값을 적용할 수 있다.

  1. 크기가 같은 행렬을 각 원소의 행과 열에 맞추어 곱해주는 연산 위키피디아 : 아다마르 곱 

Comment  Read more

순환 신경망(Recurrent Neural Network, RNN)

|

본 포스트의 내용은 cs231n 강의자료 와 책 밑바닥에서 시작하는 딥러닝 2 를 참고하였습니다.

RNN (Recurrent Neural Network)

이번 시간에는 시퀀스를 다루기 위한 신경망인 순환 신경망(Recurrent Neural Network, RNN)에 대해 알아보도록 하겠습니다.

Sequential Data

우리가 실제로 맞닥뜨리는 데이터는 여러 형태를 가지고 있습니다. 그 중 하나가 바로 아래와 같은 이미지 데이터입니다. 합성곱 신경망은 이와 같은 데이터를 잘 다루지요.

non_sequential

위와 같은 데이터는 데이터가 등장하는 순서가 바뀌더라도 데이터의 의미가 달라지지 않습니다. 어떤 고양이 사진을 처리하기 전에 강아지 사진이 등장하든, 고양이 사진이 등장하든, 아무 상관없는 빌딩 사진이 등장하든 원래 데이터가 고양이 사진이라는 것은 달라지지 않지요. 이런 데이터와는 달리 등장 순서에 따라 의미가 달라지는 데이터가 있습니다. 아래와 같은 자연어와 시계열 데이터가 대표적인 사례입니다.

sequential

자연어와 시계열 데이터는 앞,뒤로 어떤 데이터가 등장하는 지에 따라서 특정 데이터의 의미가 달라집니다. 좀 더 간단한 아래의 예시를 보겠습니다.

“The stolen car sped into the arena.” : 도난 차량이 경기장 안으로 돌진했다.

“The clown car sped into the arena.” : 광대 차가 공연장 안으로 들어왔다.

두 문장은 “stolen”“clown” 으로 바뀐 것 외에는 아무런 바뀐 것이 없습니다. 하지만 문장의 의미는 완전히 달라졌네요. 바뀐 단어 이후에 있는 “sped into”, “arena” 의 의미까지 바뀌어버렸습니다. 이렇게 앞,뒤에 어떤 단어가 오느냐에 따라서 뒤따라오는 데이터의 의미도 변화하는 데이터를 연속형(Sequential) 데이터라고 합니다. 대표적인 연속형 데이터가 바로 위에서 설명한 자연어와 시계열 데이터입니다.

RNN

연속형 데이터는 데이터가 등장하는 순서가 중요하기 때문에 이 순서를 체크할 수 있는 신경망을 사용해주어야 합니다. 순환 신경망은 이런 데이터를 학습하기 위해 등장한 신경망입니다. 먼저 순환 신경망의 구조를 살펴보도록 하겠습니다.

rnn2

이미지 출처 : cs231n.stanford.edu

가장 단순한 순환 신경망은 은닉층이 1개인 구조를 가지고 있습니다. 하지만 단층 퍼셉트론 신경망과는 다릅니다. 단층 퍼셉트론 레이어는 은닉층의 벡터를 출력층으로 넘겨주고 끝내지만 순환 신겸망은 은닉층의 벡터를 다음 Time-step에 다시 사용합니다. 이전에 생성된 은닉층 벡터를 재활용(?) 하기 때문에 순환(Recurrent) 신경망이라는 이름이 붙었습니다. Time-step 에 따라서 순환 신경망이 어떻게 동작하는 지를 펼쳐보면 아래와 같습니다.

rnn4

이미지 출처 : cs231n.stanford.edu

첫 번째 Time-step, $t=0$ 에는 입력 벡터 $\mathbf{x}1$ 와 초기화 되어있는 은닉층의 상태(Hidden-state) 벡터 $\mathbf{h}_0$ 가 은닉층에 입력됩니다. 은닉층은 두 개의 벡터를 입력받아 새로운 Hidden-state 벡터 $\mathbf{h}_1$ 을 만들어내고 벡터 $\mathbf{y}_1$ 을 출력합니다. 다음 Time-step인 $t=1$ 에는 $t=0$ 에서 생성했던 $\mathbf{h}_1$ 을 다시 활용합니다. 이 $\mathbf{h}_1$ 과 해당 순서의 입력 벡터인 $\mathbf{x}_2$ 가 은닉층에 입력되지요. 순환 신경망은 이 과정을 문장 끝까지 반복합니다. 만약 $n$ 개로 이루어진 시퀀스라면 마지막 Time-step에서는 은닉입력 벡터 $\mathbf{x}_n$ 와 이전 Time-step에서 생성된 Hidden-state 벡터 $\mathbf{h}{n-1}$ 이 은닉층으로 입력됩니다. 은닉층은 이 둘을 받아 $\mathbf{y}_n$ 를 출력한 후 생성을 마치고 모든 출력에 대한 손실을 계산합니다.

Time-step $t$ 에서 순환 신경망이 은닉 상태 벡터 $\mathbf{h}_t$ 가 생성되는 과정을 수식으로 나타내면 아래와 같습니다.

[\begin{aligned} \mathbf{h}t &= f_W(\mathbf{h}{t-1}, \mathbf{x}t)
&= \tanh(\mathbf{h}
{t-1}W_\mathbf{h} + \mathbf{x}tW\mathbf{x} + b) \end{aligned}]

위 식에서 $\mathbf{x}t$ 은 해당 Time-step에서 입력되는 벡터이고, $\mathbf{h}{t-1}$ 은 이전 Time-step 에서 생성된 은닉 상태 벡터입니다. $b$ 는 편향 벡터입니다. 순환 신경망은 활성화 함수로 하이퍼탄젠트 함수 $\tanh$ 를 사용합니다. 은닉층에는 Hidden-state 벡터에 곱해지는 가중치 $W_\mathbf{h}$ 와 입력 벡터에 곱해지는 가중치 $W_\mathbf{x}$ 가 있으며 이 가중치는 역전파로 갱신되기 전까지 모든 Time-step에 동일한 값이 적용됩니다. (순환 신경망의 활성화 함수로 하이퍼탄젠트 함수를 사용하는 이유는 이곳을 참고하시면 좋습니다.)

BPTT(BackPropagation Through Time)

순환 신경망의 순전파를 알아보았으니 이제 역전파가 어떻게 일어나는지 알아보도록 하겠습니다.

rnn5

이미지 출처 : cs231n.stanford.edu

순환 신경망의 역전파는 Time-step마다 생성된 순전파의 역순으로 진행됩니다. Time-step의 역방향을 따라 진행되기 때문에 BPTT(Backpropagation Through Time) 이라고 불립니다.

Advantage & Problems

Advantage

순환 신경망은 여러 장점을 가지고 있습니다. 첫 번째로 이론상으로는 아무리 긴 시퀀스라도 처리가 가능합니다. 물론 엄청난 메모리를 필요로 하기 때문에 실제로는 일정 길이 이상의 시퀀스를 다루는 데에는 현실적인 한계가 있습니다. 두 번째로 시퀀스의 길이가 길어지더라도 모델 사이즈가 커지지 않습니다. 위에서 살펴본 가장 단순한 순환 신경망의 경우 은닉층 1개만으로 긴 시퀀스를 처리할 수 있습니다. 마지막으로 이론상으로는 Time-step $t$ 이전에 입력된 모든 입력 벡터의 정보를 사용할 수 있습니다. $\mathbf{h}_t$ 는 매 Time-step 마다의 입력 벡터 $\mathbf{x}_1, \mathbf{x}_2, \cdots, \mathbf{x}_t$ 를 정보를 모두 가지고 있습니다. Hidden-state 벡터가 잘 작동하기만 한다면 모든 입력 벡터의 정보를 끝까지 잘 전달할 수 있을 것입니다.

Problems

문제는 순환 신경망의 이런 장점이 이론상으로만 가능하다는 데에 있습니다. 첫 번째 문제는 학습에 걸리는 시간이 너무 느리다는 것입니다. 순환 신경망은 입력 벡터가 순차적으로 입력되기 때문에 모든 벡터에 대한 정보를 한꺼번에 계산할 수 없습니다. 이 문제는 모든 토큰을 동시에 입력한 뒤에 위치 벡터를 따로 처리해주는 트랜스포머(Transformer)에 가서야 해결됩니다.

기본적인 순환 신경망(Vanilla RNN)의 가장 큰 단점은 장기 의존성(Long-Term Dependency) 문제 입니다. 이 장기 의존성 문제 때문에 실제로는 마지막 장점이 발휘되지 못하지요. 장기 의존성 문제란 시퀀스가 길어질 때 Hidden-state 벡터 $\mathbf{h}_t$ 가 시퀀스 앞쪽 입력 벡터의 정보를 제대로 간직하지 못하는 현상입니다. 아래의 예를 보겠습니다.

“Tom was watching TV in his room. Mary came into the room. Mary said hi to ( ? ).”

괄호 안에 들어갈 단어는 “Tom” 입니다. “Tom” 을 정확히 예측하기 위해서는 이전에 등장하는 단어들의 의미를 모두 기억하고 있어야 합니다. 하지만 역전파 과정에서 활성화 함수의 역전파를 거칠 때마다 정보가 점점 사라지는 기울기 소실(Gradient vanishing)이 발생합니다. 장기 의존성 문제를 해결하기 위해 수많은 연구가 있었습니다. 그 결과 은닉층에 게이트를 추가한 장단기 기억망(Long Short Term Memory, LSTM)게이트 순환 유닛(Gated Recurrent Unit, GRU)등이 탄생하였습니다. 게다가 모든 Time-step에 대한 Hidden state 벡터를 사용하는 어텐션(Attention) 방법도 제시되었습니다.

여러가지 RNN

위에서 살펴본 순환 신경망 구조 외에도 입/출력 벡터를 어떻게 처리할 지에 따라 여러가지 순환 신경망으로 구분할 수 있습니다. 아래는 여러 순환 신경망을 입/출력 벡터의 형태에 따라 구분한 것입니다.

diverse_rnn

이미지 출처 : cs231n.stanford.edu

위 그림에서 2번째 있는 일대다 구조를 가지는 순환 신경망이 사용되는 대표적인 태스크는 이미지 캡셔닝(Image captioning)입니다. 특정 이미지를 입력하면 이를 묘사하는 문장을 생성합니다. Non-sequential 데이터를 입력하여 Seqential 한 데이터를 출력하는 대표적인 태스크입니다.

3번째에 있는 순환 신경망은 다대일 구조를 갖습니다. 이런 순환 신경망이 사용되는 대표적인 태스크는 감성 분석과 같은 문서 분류가 있습니다. 문서 분류에서는 Sequential 데이터인 문서를 입력받은 뒤에 이를 모아 해당 문서의 클래스를 판단합니다.

다대다 구조는 두 가지로 분류됩니다. 4번째와 같이 모든 입력 벡터를 다 받아들인 뒤에 여러 개의 벡터를 출력하는 구조가 있고, 5번째와 같이 입력 벡터마다 즉시 벡터를 출력하는 구조가 있습니다. 전자를 사용하는 대표적인 태스크는 기계 번역(Machine translation)입니다. 번역하고자 하는 문장의 단어를 모두 받아들인 후 번역된 문장의 단어를 출력합니다. 후자를 사용하는 태스크에는 프레임 단위의 영상 분류가 있습니다.

지금가지는 은닉층이 1개인 순환 신경망에 대해서만 알아보았으나 실제로는 층이 여러 개인 다층 순환 신경망(Multi Layer RNN)이 사용됩니다. 다층 순환 신경망의 대략적인 구조는 아래와 같습니다.

multilayer_rnn

이미지 출처 : cs231n.stanford.edu

Comment  Read more

스택(Stack) & 큐(Queue)

|

본 게시물은 문일철 교수님의 데이터 구조 및 분석을 참조하여 작성하였습니다.

스택 (Stack)

이번에는 연결된 리스트(Linked List)의 변형인 스택 과 큐에 대해 알아봅시다. 먼저 스택에 대해 알아보도록 합시다. 스택(Stack) 은 통로가 한 곳으로 지정된 경우에 사용되는 자료구조입니다. 예를 들어 비행기에 짐을 싣는 경우를 생각해봅시다. 비행기에서 짐이 들어가는 통로는 한 곳 뿐입니다. 그렇기 때문에 맨 마지막으로 들어갔던 짐이 꺼낼 때는 가장 먼저 나오게 되고, 가장 처음에 들어갔던 짐은 가장 나중에 나오게 됩니다.

cargo

이미지 출처 : caixinglobal.com

스택의 구조

스택이 연결된 리스트와 차이를 보이는 점은 첫 번째 노드에만 접근할 수 있다는 점입니다. 연결된 리스트에서 삽입과 삭제했던 방식을 생각해보면 idxInsertidxDelete 를 통해서 중간에 있는 노드에도 접근할 수 있었습니다. 하지만 스택은 위에서 말한대로 통로가 하나 뿐이므로 맨 위에 있는 노드에만 접근할 수 있습니다. 아래 그림을 보며 설명을 이어 나가겠습니다.

stack

이미지 출처 : programiz.com

위 그림은 스택에 요소가 삽입되고 삭제되는 과정을 이미지로 나타낸 것입니다. 빈 스택에 3개의 요소를 삽입하면 위 그림에 2, 3, 4번째 그림같이 쌓이게 됩니다. 이 상황에서 삭제를 시도하면 어떻게 될까요? 통로가 하나밖에 없으니 가장 나중에 들어온 3이 빠져나가게 됩니다. 4번째 그림에서 1을 꺼내고 싶다면 삭제를 총 3번 시도해야 되는 것이지요. 스택에서는 마지막에 들어온 것이 가장 먼저 나가게 되므로 LIFO(Last-In-First-Out) 메커니즘을 따른다고 합니다.

스택에서의 삽입과 삭제

스택에서는 삽입과 삭제를 각각 pushpop 이라고 부릅니다. 아래는 연결된 리스트를 활용하여 스택에서의 삽입과 삭제를 구현하는 파이썬 코드입니다. push 에서는 첫 번째 노드만을 통해서 새로운 노드가 삽입됩니다. pop 역시 첫 번째 노드가 삭제되는 것을 알 수 있습니다.

import SinglyLinkedList

class Stack(object):
    lstInstance = SinglyLinkedList()

    def push(self, value):
        self.lstInstance.insertAt(value, 0)
    def pop(self):
        return self.lstInstance.removeAt(0)

큐(Queue)

큐는 스택과는 달리 통로가 2개인 경우에 사용되는 자료구조 입니다. 통로가 2개이기 때문에 데이터가 삽입되는 통로와 삭제되는 통로가 따로 존재합니다. 공항에서 수하물을 맡기기 위해서 줄을 맞춰 대기하는 경우를 생각해봅시다. 이 경우에는 먼저 줄을 선 사람이 먼저 일을 마치고 줄을 빠져나가게 됩니다. 이때 줄을 서는 방향과 빠져 나가는 방향은 다릅니다.

line_up

이미지 출처 : economist.com

큐의 구조

큐 또한 연결된 리스트와 유사합니다. 하지만 큐는 통로가 둘 뿐이므로 맨 앞 혹은 맨 뒷 노드에만 접근할 수 있습니다. 아래 그림을 보며 설명을 이어 나가겠습니다.

queue

이미지 출처 : programiz.com

위 그림에서는 왼쪽에서 데이터가 삽입되고 오른쪽으로 빠져나가고 있습니다. 빈 큐에서 2개의 요소를 삽입하면 3번째 그림과 같이 됩니다. 이 상황에서 요소를 삭제하면 가장 처음에 삽입되었던 1이 꺼내지게 됩니다. 3번째 그림과 같은 큐에서 2를 꺼내고 싶다면 삭제를 2번 실행해주어야 합니다. 큐에서는 먼저 들어온 것일수록 먼저 나가게 되므로 FIFO(First-In-First-Out) 메커니즘을 따른다고 합니다.

큐에서의 삽입과 삭제

큐에서의 삽입과 삭제도 스택과 같이 특별한 이름을 가지고 있습니다. 큐에서는 삽입과 삭제를 각각 enqueuedequeue 이라고 부릅니다. 아래는 연결된 리스트를 활용하여 큐에서의 삽입과 삭제를 구현하는 파이썬 코드입니다. enqueue 에서는 마지막 노드를 통해서 새로운 노드가 삽입되는 것을 볼 수 있습니다. dequeue 는 가장 앞에 있는 요소가 추출되어야 하므로 첫 번째 노드에 접근하여 그 노드를 삭제하는 것을 볼 수 있습니다.

import SinglyLinkedList

class Queue(object):
    lstInstance = SinglyLinkedList()

    def enqueue(self, value):
        self.lstInstance.insertAt(value, self.lstInstance.getSize())
    def dequeue(self):
        return self.lstInstance.removeAt(0)

Comment  Read more

연결된 리스트 (Linked List)

|

본 게시물은 문일철 교수님의 데이터 구조 및 분석을 참조하여 작성하였습니다.

Linked List

지난 시간에 알아본 배열(Array) 은 그 자료 구조가 가진 선형 구조 때문에 요소를 삽입과 삭제하는 과정에서 무조건 $N$ 번의 Operation이 필요했습니다. 이번에 알아볼 연결된 리스트(Linked list)는 삽입과 삭제에서 발생하는 비효율을 개선하기 위해서 만들어진 자료구조입니다. 참고로 여기에 등장하는 연결된 리스트와 파이썬에 쓰이는 리스트(List)는 큰 관련이 없으니 다른 자료구조로 생각해주시면 되겠습니다.

연결된 리스트의 기본 구조

연결된 리스트(Linked List) 는 노드(Node)와 레퍼런스(Reference)로 되어있습니다. 연결된 리스트가 이런 형식을 택한 이유는 인덱스로 구성된 선형 구조를 피하기 위해서 입니다. 연결된 리스트의 노드는 두 가지 변수로 이루어져 있습니다. 첫 번째는 해당 노드에 저장되는 값을 가리키는 레퍼런스를 저장하는 변수입니다. 그리고 두 번째 변수는 다음 노드를 가리키는 레퍼런스를 저장하는 변수입니다. 아래 그림은 간단한 연결된 리스트의 구조를 이미지로 나타낸 것입니다. 이미지의 연결된 리스트는 총 4개의 노드로 구성되어 있는 것을 볼 수 있습니다.

linkedlist0

이미지 출처 : 데이터 구조 및 분석 수업자료

연결된 리스트에는 헤드(Head)와 테일(Tail)이라는 스페셜 노드가 있습니다. 위 이미지에서도 이를 확인할 수 있습니다. 헤드는 연결된 리스트 맨 앞에 위치하는 노드이고, 테일은 연결된 리스트 맨 뒤에 위치하는 노드입니다. 두 노드 모두 값을 가리키는 레퍼런스가 없으며, 헤드는 다음 노드를 가리키는 레퍼런스를 가지고 있고 테일은 그마저도 가지고 있지 않습니다. 대신 테일은 다른 노드의 레퍼런스를 받지만 헤드는 다른 노드의 레퍼런스를 받지 않습니다. 이 두 스페셜 노드가 연결된 리스트를 구성하는 데 있어서 필수적인 요소는 아니지만, 연결된 리스트의 주줏돌과 같은 역할을 담당함으로써 삽입과 삭제를 더욱 용이하게 만드는 역할을 합니다.

아래는 연결된 리스트를 만드는 파이썬 코드입니다.

class Node:
    nodeNext = None
    nodePrev = ''
    objValue = ''
    binHead = False
    binTail = False

    def __init__(self, objValue='', nodeNext=None, binHead=False, binTail=False):
        self.nodeNext = nodeNext
        self.objValue = objValue
        self.binHead = binHead
        self.binTail = binTail

    def getValue(self):
        return self.objValue
    def setValue(self, objValue):
        self.objValue = objValue

    def getNext(self):
        return self.nodeNext
    def setNext(self, nodeNext):
        self.nodeNext = nodeNext

    def isHead(self):
        return self.binHead
    def isTail(self):
        return self.binTail


node1 = Node(objValue='a')
nodeTail = Node(binTail=True)
nodeHead = Node(binHead=True, nodeNext=node1)

연결된 리스트에서의 탐색(Search)

연결된 리스트에서 요소를 탐색하는 과정은 배열과 크게 다르지 않습니다. 연결된 리스트는 배열에서 삽입과 삭제만을 개선하기 위한 자료구조이기 때문이지요. 비록 접근 방법은 다르지만 요소를 하나하나 확인한다는 점에서는 별 차이가 없습니다. 배열에서는 요소를 탐색할 때 인덱스로 접근했지만 연결된 리스트는 인덱스를 사용하지 않기 때문에 노드와 레퍼런스로 접근합니다. 아래 그림을 보며 설명을 이어가겠습니다.

search_in_ll

가장 먼저 헤드를 찾은 뒤 헤드의 레퍼런스가 가리키는 노드로 이동합니다. 그리고 이동한 노드가 참조하는 값을 비교한 뒤 맞으면 거기서 함수를 끝내고 아니면 레퍼런스를 따라 다음 노드로 이동합니다. 이 과정을 찾고자 하는 요소가 나올 때까지 반복하면 됩니다. 만약 요소가 없다면 테일이 나올 때까지 반복한 후 False를 반환하면 됩니다.

연결된 리스트는 탐색 과정이 비슷하기 때문에 발생하는 Operation 횟수 역시 배열과 동일합니다. 내부 요소의 개수가 $N$ 인 연결된 리스트에서 리스트 내에 없는 요소를 탐색하는 경우에는 $N$ 회, 리스트 내에 있는 요소를 탐색하는 경우에는 최대 $N$ 회가 됩니다.

연결된 리스트에서의 삽입(Insert)

이제부터 알아볼 삽입과 삭제는 연결된 리스트의 구조가 빛을 발하는 부분입니다. 배열에서 $N$ 회 필요했던 Operation을 연결된 리스트에서는 획기적으로 줄일 수 있습니다. 연결된 리스트에서는 단 $3$ 번의 Operation 만으로 요소를 삽입하고 삭제할 수 있습니다. 먼저 요소를 삽입하는 과정부터 알아봅시다. 배열에서 우리가 요소를 넣을 인덱스를 지정해주었던 것처럼, 연결된 리스트에서도 요소가 들어갈 자리는 미리 지정해 준다는 가정 하에서 시작합니다.

삽입의 첫 번째 절차는 요소를 삽입하려는 위치 다음에 있는 노드인 Node_next 를 미리 저장하는 것입니다. 다음으로는 노드 삽입할 위치 이전에 있는 노드인 Node_prev 의 레퍼런스가 가리키는 노드를 Node_next 에서 새로운 노드인 Node_new 로 업데이트 해줍니다. 마지막으로 Node_new 의 레퍼런스가 가리키는 노드를 Node_next 로 바꿔주면 삽입 절차가 끝나게 됩니다.

연결된 리스트에서는 이 $3$ 번의 Operation 만으로 자료구조 내에 요소를 삽입할 수 있습니다. 정해진 길이없이 노드와 레퍼런스만 조작해주면 되기 때문입니다. 아래는 연결된 리스트에서 요소를 삽입하는 과정을 이미지로 나타낸 것입니다.

linkedlist1

이미지 출처 : 데이터 구조 및 분석 수업자료

연결된 리스트에서의 삭제(Delete)

연결된 리스트에서는 삭제를 단 2번의 Operation 만으로 수행할 수 있습니다. 삭제 역시 우리가 삭제할 요소의 자리를 지정한다는 가정 하에서 시작합니다.

첫 번째 Operation은 삭제하려는 요소의 레퍼런스를 받는 노드인 Node_next 를 저장하는 것입니다. 이 과정은 삽입에서 했던 것과 동일합니다. 두 번째 Operation은 Node_prev 의 레퍼런스가 가리키는 노드를 Node_remove 에서 Node_next 로 변경해줍니다. 연결된 리스트에서는 두 번의 Operation만을 거치면 삭제 과정이 완료됩니다.

그런데 우리가 다루어주지 않는 한 노드가 있습니다. 바로 Node_remove 인데요. 이 노드는 이제 어떻게 처리되는 것일까요? 파이썬에는 이런 부분을 처리하는 가비지 콜렉터(Garbage collector)라는 장치가 있습니다. (헤드 노드를 제외한다면) 연결된 리스트에서 어떤 레퍼런스의 지목도 받지 못하는 노드는 바로 이 가비지 콜렉터에 의해서 제거됩니다.

linkedlist2

이미지 출처 : 데이터 구조 및 분석 수업자료

연결된 리스트의 탐색, 삽입, 삭제 구현하기

아래는 연결된 리스트에서 요소를 삽입하고 삭제하는 과정을 파이썬 코드로 구현한 것입니다. 이전에 구현했던 Node 클래스를 활용할 수 있습니다.

class SinglyLinkedList:
    nodeHead = ''
    nodeTail = ''
    size = 0

    def __init__(self):
        self.nodeTail = Node(binTail=True)
        self.nodeHead = Node(binTail=True, nodeNext=self.nodeTail)

    def insertAt(self, objInsert, idxInsert):
        nodeNew = Node(objValue=objInsert)
        nodePrev = self.get(idxInsert-1)
        nodeNext = nodePrev.getNext()
        nodePrev.setNext(nodeNew)
        nodeNew.setNext(nodeNext)
        self.size = self.size+1

    def removeAt(self, idxRemove):
        nodePrev = self.get(idxRemove-1)
        nodeRemove = nodePrev.getNext()
        nodeNext = nodeRemove.getNext()
        nodePrev.setNext(nodeNext)
        self.size = self.size-1
        return nodeRemove.getValue()

    def get(self, idxRemove):
        nodeReturn = self.nodeHead
        
        for itr in range(idxRetrieve+1):
            nodeReturn = nodeReturn.getNext()
        return nodeReturn

    def printStatus(self):
        nodeCurrent = self.nodeHead

        while nodeCurrent.getNext().isTail() == False:
            nodeCurrent = nodeCurrent.getNext()
            print(nodeCurrent.getValue(), end=" ")
        print("")

    def getSize(self):
        return self.size
    

list1 = SinglyLinkedList()
list1.insertAt('a',0)
list1.insertAt('b',1)
list1.insertAt('d',2)
list1.insertAt('e',3)
list1.insertAt('f',4)
list1.printStatus()
>>> a b d e f

list1.insertAt('c',2)
list1.printStatus()
>>> a b c d e f

list1.removeAt(3)
list1.printStatus()
>>> a b c e f

연결된 리스트의 의의

연결된 리스트는 배열이 가진 선형 구조의 한계를 극복하고 삽입과 삭제를 매우 빠르게 실행할 수 있도록 하는 자료구조입니다. 또한 이후에 배울 스택(Stack)과 큐(Queue) 의 기반이 되는 자료구조이기도 합니다.

Comment  Read more