by Yngie

알고리즘 분석 (Algorithm Analysis)

|

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

Algorithm Analysis

알고리즘(Algorithm)이란 무엇일까요? 위키피디아에 나와있는 알고리즘의 정의는 다음과 같습니다.

“알고리즘은 수학과 컴퓨터 과학, 언어학 또는 관련 분야에서 어떠한 문제를 해결하기 위해 정해진 일련의 절차나 방법을 공식화한 형태로 표현한 것, 계산을 실행하기 위한 단계적 절차를 의미한다. 알고리즘은 연산, 데이터 진행 또는 자동화된 추론을 수행한다.”

즉 알고리즘은 문제를 푸는 방법에 대한 지시사항입니다. 모든 알고리즘에는 입력값과 출력값이 존재하는데요. 효율적인 알고리즘 구현을 위해서는 그에 맞는 자료구조(Data structure)가 동반되어야 합니다. 자료구조는 자료를 조직하는 방법으로 많은 프로그램이 일련의 알고리즘과 그에 맞는 자료구조를 조합하여 구현되어 있습니다. 이전에 각각의 자료구조에 대해 구현했던 삽입(Insert), 삭제(Delete), 탐색(Search)과 같은 작업 모두 알고리즘의 범주 내에 들어갑니다.

이 중에서 정렬(Sorting)은 자료구조 내에 존재하는 요소를 일정한 조건을 만족하도록 배치하는 알고리즘입니다. 버블 정렬(Bubble sort), 퀵 정렬(Quick sort)등 다양한 정렬 알고리즘이 있습니다.

Bubble Sort

이후에 이어질 이야기를 위해서 버블 정렬에 대해 알아보겠습니다. 버블 정렬은 여러 정렬 알고리즘 중에서 매우 직관적인 편에 속하는 정렬입니다. 중첩된 반복문을 통해서 모든 두 요소를 비교합니다. 오름차순으로 정렬하는 알고리즘이라면, 기준이 되는 앞쪽 숫자보다 뒷쪽 숫자가 더 클 경우에 둘의 순서를 바꾸어줍니다. 이 과정을 배열의 끝까지 반복하면 정렬이 완료됩니다. 아래는 버블 정렬을 사용하여 리스트를 오름차순으로 정렬하는 파이썬 코드입니다.

def bubbleSort(lst):
    for itr1 in range(len(lst)):
        for itr2 in range(len(lst)-1):
            if lst[itr2] > lst[itr2+1]:
                lst[itr2], lst[itr2+1] = lst[itr2+1], lst[itr2]

    return lst

리스트에 10개의 요소가 있을 때, 위 코드에서 각각의 요소를 몇 번이나 비교할까요? 먼저 itr1이 0일 때는 itr2가 0부터 8까지 9번의 비교를 되풀이하게 됩니다. itr2를 모두 마치고 나면 다시 itr1이 1로 증가하게 됩니다. itr1이 1일 때에는 0부터 7까지 8번의 비교를 되풀이합니다. 이 과정을 itr1이 9가 될 때까지 반복합니다. 각 단계마다 itr2가 순환하면서 요소를 비교하는 횟수는 $9, 8, 7, \cdots, 1$ 번입니다. 이를 모두 더하면 요소가 10개인 리스트를 버블 정렬할 때 비교 횟수를 구할 수 있습니다.

이를 요소가 $n$개인 리스트의 버블 정렬로 일반화해 봅시다. 리스트의 요소가 $n$개일 때는 각 단계마다 $n-1, n-2, \cdots , 1$번의 비교가 일어나며 이를 모두 더하면 총 비교 횟수를 구할 수 있습니다. 수식으로 나타내면 다음과 같습니다.

[(n-1)+(n-2)+\cdots+1 = \frac{n(n-1)}{2} = \frac{1}{2}n^2 - \frac{1}{2}n]

버블 정렬 알고리즘은 다른 정렬 알고리즘과 비교했을 때 매우 비효율적인 알고리즘입니다. 10,000명의 회원에 대한 회원 정보를 버블 정렬을 사용하여 오름차순으로 정렬한다면 몇 번의 반복을 수행해야 할까요? 약 $50,000,000$개의 성분을 비교해야합니다. 만약 모든 요소를 바꿔주어야 한다면 $0.00001$초가 걸리는 컴퓨터를 사용했을 때 500초, 즉 8분이 넘게 걸리게 됩니다(물론 실제로 사용되는 컴퓨터의 성능은 훨씬 좋지만 예를 들어 설명하였습니다).

Analysis

알고리즘 분석(Algorithm analysis)은 알고리즘이 필요로 하는 자원이 얼마나 되는지를 추측하는 것입니다. 이 때 자원에는 메모리, 네트워크의 대역폭(Bandwidth), 계산 시간(Computation time) 등이 모두 포함됩니다. 메모리와 네트워크의 대역폭은 더 늘릴 수 있지만 한 번 설정한 알고리즘의 계산 시간을 줄일 수는 없기 때문에 처음부터 효율적인 계산을 하는 알고리즘을 설계해야 합니다.

같은 알고리즘을 사용하더라도 입력 데이터가 어떤 자료구조의 형태로 들어오는 지에 따라서 계산 시간이 바뀌기도 합니다. 예를 들어, 이진 탐색 트리에서 평균적인 탐색 시간은 $O(\log N)$ 입니다. 그렇다면 이 $O(\log N)$이라는 표기는 무엇을 의미할까요? 이어서 빅-O 표기법에 대해 알아보겠습니다.

Big-O Notation

빅-O 표기법(Big-O Notation)이란 알고리즘이 최악의 경우를 만났을 때 걸리는 시간을 표기하는 방식입니다. 만약 알고리즘 내에 if 조건문이 있으면 이 조건을 모두 만족하여 조건문 이하의 코드를 모두 실행한다고 가정하게 됩니다. 버블 정렬의 경우 if 조건문을 모두 실행한다면 두 요소를 바꾸는 작업을 $\frac{N(N-1)}{2}$ 번 실행하게 됩니다.

def bubbleSort(lst):
    for itr1 in range(len(lst)): #line1
        for itr2 in range(len(lst)-1): #line2
            if lst[itr2] > lst[itr2+1]: #line3
                lst[itr2], lst[itr2+1] = lst[itr2+1], lst[itr2] #line4

    return lst #line5

버블 정렬의 함수 내에서 최악의 경우에 각 코드 Line을 몇 번이나 실행하는지 헤아려 보겠습니다. 먼저 Line1은 몇 번이나 반복될까요? itr1이 $0$ 부터 $N-1$ 까지 반복하니 $N$ 번 반복하게 됩니다. Line2는 조건문과 상관없이 $(N-1) + (N-2) + \cdots + 1 = \frac{N(N-1)}{2}$ 번 반복합니다. Line3,4는 조건이 안맞을 경우에는 실행하지 않지만 최악의 경우, 즉 모든 if문을 실행하는 경우를 생각하는 것이므로 두 줄 모두 $\frac{N(N-1)}{2}$ 실행됩니다. 마지막 Line5는 함수를 마칠 때 한 번 사용되므로 $1$번 실행하게 됩니다. 이를 모두 합한 $\frac{3}{2}N^2 - \frac{1}{2}N + 1$이 버블 정렬의 반복 연산 횟수가 됩니다.

이제 빅-$O$ 표기법에 대해 본격적으로 알아보겠습니다. 빅-O 표기법을 나타내는 방법은 다음과 같습니다. 우선 테스트할 알고리즘의 연산 횟수를 $f(N)$이라고 합니다. 그기고 그에 맞는 빅-$O$ 표기법이 $O(g(N))$으로 나타난다고 해보겠습니다. 이 때 $N \geq n_0$ 인 $N$ 에 대하여 $f(N) \leq c \cdot g(N)$ 을 만족시키는 양의 상수 $c, n_0$ 가 있어야 합니다.

말로 하면 어려우니 버블 정렬의 예를 들어 설명해보겠습니다. 위에서 구한 버블 정렬의 연산 반복 수 $f(N) = \frac{3}{2}N^2 - \frac{1}{2}N + 1$이었습니다. 이에 해당하는 $g(N) = N^2$으로 가정해보겠습니다. $c$ 에 적당한 값인 $5/2$ 를 대입하고 $f(N) \leq c \cdot g(N)$ 식을 정리하면 다음과 같은 식이 됩니다. ( $c$ 에 다른 수를 대입해도 됩니다.)

[f(N) \leq c \cdot g(N)
\frac{3}{2}N^2 - \frac{1}{2}N + 1 \leq \frac{5}{2} \cdot N^2
N^2 + \frac{1}{2}N -1 \geq 0]

마지막 식을 그래프로 나타내면 아래와 같이 나타낼 수 있습니다. 아래와 같은 포물선 그래프에서 오른쪽 근은 약 $0.781$ 입니다. 따라서 $n_0 = 0.781$ 이라고 하면 $N \geq n_0$ 인 모든 $N$ 에 대하여 $f(N) \leq c \cdot g(N)$을 만족하므로 $O(N^2)$ 는 버블 정렬 알고리즘을 빅-$O$ 표기법으로 잘 나타낸 것이라 할 수 있습니다.

bubblesort_graph

이미지 출처 : wolframalpha.com

$g(N)$ 의 차수가 더 작아지면 어떻게 될까요? $g(N) = N$ 이라고 가정하면 $f(N) \leq c \cdot g(N)$을 다음과 같이 정리할 수 있습니다.

[\frac{3}{2}N^2 - \frac{1}{2}N + 1 \leq c \cdot N
-\frac{3}{2}N^2 + (c-\frac{1}{2})N -1 \geq 0]

이 식은 위로 볼록한 포물선이 되므로 언젠가는 0보다 작아지게 됩니다. 이 경우에는 $N \geq n_0$ 인 모든 $N$ 에 대해 $f(N) \leq c \cdot g(N)$을 만족하는 $n_0$ 를 찾을 수 없습니다. 따라서 $O(N)$ 은 버블 정렬의 빅-$O$ 표기법으로 적절하지 않습니다.

반대로 $g(N)$ 의 차수가 더 커지면 어떻게 될까요? $g(N) = N^3$ 이라고 하면 $f(N) \leq c \cdot g(N)$ 을 다음과 같이 정리할 수 있습니다.

[\frac{3}{2}N^2 - \frac{1}{2}N + 1 \leq c \cdot N^3
c \cdot N^3-\frac{3}{2}N^2 + \frac{1}{2}N -1 \geq 0]

$c$ 는 임의의 양의 상수이므로 마지막 식은 우상향 하는 삼차함수의 식을 그리게 된다. $c=1$ 로 가정했을 때의 그래프는 다음과 같습니다. 아래와 같은 그래프에서 근은 약 $1.584$ 입니다. $n_0 = 1.584$ 이라고 하면 $N \geq n_0$ 인 모든 $N$ 에 대하여 $f(N) \leq c \cdot g(N)$ 을 만족합니다. 따라서 $O(N^3)$ 는 버블 정렬 알고리즘을 빅-$O$ 표기법으로 잘 나타낸 것이라 할 수 있습니다.

bubblesort_graph2

이미지 출처 : wolframalpha.com

결론적으로 $g(N)$은 $f(N)$ 의 상한선(Upper bound)이 되는 함수입니다. 그렇다면 특정 $f(N)$ 에 대해 $g(N)$ 의 개수는 매우 많아질 수 있습니다. $N^k$ 와 같은 다항함수 말고도 $k^N$ 과 같은 지수함수까지 있기 때문입니다. 이 많은 $g(N)$ 중 가장 작은 경우, 즉 Tight한 상한선의 경우가 가장 좋은 답이며 일반적으로도 이 경우가 사용됩니다. 즉 버블 정렬의 빅-$O$ 표기법으로 가장 올바른 것은 $O(N^2)$ 가 됩니다.

Growth rate

아래는 여러 $g(N)$에 대한 성장률(Growth rate)을 그래프로 나타낸 것입니다.

growth_rate

이미지 출처 : wikipedia - Time Complexity

그래프에서도 볼 수 있듯 각 성장률 순서(Growth rate order)는 다음과 같이 나타낼 수 있습니다.

[N! > C^N > N^k > N^2 > N \log N > N > \log N > C \qquad \text{if, } \quad C \geq 2 , k > 2]

여러 알고리즘이 결합된 형태의 빅-$O$ 표기법은 아래와 같습니다. 시간 알고리즘이 $f_1(N) = O(g(N)), f_2(N) = O(h(N))$ 와 같은 두 알고리즘에 대하여 다음과 같은 규칙을 만족합니다.

[f_1(N) + f_2(N) = \max(O(g(N)),O(h(N)))
f_1(N) \cdot f_2(N) = O(g(N))*O(h(N))]

성장률 순서와 결합 규칙을 알면 빅-O 표기법을 쉽게 구할 수 있습니다. 덧셈 결합 규칙을 적용하여 성장률 순서에서 복잡한 순서대로 정렬했을 때, 가장 앞쪽 순서에 위치한 항만 남기면 그것이 $g(N)$이 됩니다. 아래는 $f(N)$을 빅-$O$ 표기법으로 나타내는 몇 가지 예시입니다.

$f(3N+2 \cdot N^3+3) \Rightarrow O(N^3)$

$f(3N\log N + \log N + 1024) \Rightarrow O(N\log N)$

$f(3N^3 + 2^N) \Rightarrow O(2^N)$

$f(100001) \Rightarrow O(1)$

Complexity in List, Stack, Queue

다음은 Array(List), Stack, Queue 자료구조에 대하여 삽입(Push, Enqueue), 삭제(Pop, Dequeue) 및 탐색을 빅-$O$ 표기법으로 나타낸 표입니다.

  Array(List) Stack Queue
Push X $O(1)$ X
Pop X $O(1)$ X
Enqueue X X $O(1)$
Dequeue X X $O(1)$
Search $O(N)$ X X

다음은 연결된 리스트(Linked List), 평균적인 이진 탐색 트리(Binary search tree), 이진 탐색 트리의 가장 좋지 않은 경우에 대해서 탐색, 삭제, 트래버스(Traverse)를 빅-$O$ 표기법으로 나타낸 표입니다.

  Linked List Binary Search Tree in Average Binary Search Tree in Worst Case
Search $O(n)$ $O(\log n)$ $O(n)$
Insert after search $O(1)$ $O(1)$ $O(1)$
Delete after search $O(1)$ $O(1)$ $O(1)$
Traverse $O(n)$ $O(n)$ $O(n)$

빅-$O$ 표기법 이외에도 빅-$\Theta$ 표기법 $\Theta(N)$, 스몰-o 표기법, 스몰- $\theta$ 표기법 등이 있습니다. 하지만 가장 중요하게 고려되는 사항은 역시 최악의 경우입니다. 이것만 줄이면 다른 경우는 쉽게 커버할 수 있기 때문이지요. 그러므로 최악의 경우에 대해 시간 복잡도를 구하는 빅-$O$ 표기법이 일반적으로 가장 많이 사용됩니다.

Comment  Read more

2020년 상반기 회고

|

2020 - 1

벌써 반 년이 지났습니다. 뭇 프로그래머들이 그러하듯 그동안에 한 것들을 짧게나마 회고 식으로 적어보려 합니다. 반기 회고지만 코딩을 작년 11월 중순부터 시작했기 때문에, 11월 중순부터 연말까지의 기간을 포함하여 약 7개월 동안의 일을 (의식의 흐름대로) 적습니다.

2019. Dec

11월 25일에 교육을 시작했고 2주 전부터 예습을 시작했으니, 아마 처음으로 print("Hello python!") 을 한 것은 11월 11일 전후가 되겠습니다. 교육 시작 전까지는 인프런에 있는 최성철 교수님의 강의 를 들으며 파이썬 기초 지식을 공부하였습니다. 야구게임 문제를 푸느라 이틀 머리를 싸맸던 기억이 나네요. (지금이라면 금방 풀텐데 ㅠㅠ)

작년 11월 20일에는 충무로에서 빅데이터 커리어톡 을 들었습니다. 동국대에서 진행하는 프로그램 수료자 + 추가 인원을 모집하여 빅데이터 현직자의 이야기를 들을 수 있었던 자리였습니다. 마이뮤직테이스트 그로스팀 리드 김명수님, 하이퍼커넥트 ML엔지니어 서석준님, 쏘카 ML엔지니어 변성윤님이 연사로 서주셨고 데이터 사이언스에 대한 이런저런 이야기를 들을 수 있었습니다. 다만, 당시에는 데이터에 대한 지식이 너무 부족해서 일단 받아적기만 했던 게 아쉽네요. 지금 비슷한 자리가 있다면 오히려 그 때보다 훨씬 더 좋은 자리가 될 것 같다는 생각이 듭니다.

그리고 25일 교육을 시작했습니다. 11월 말부터 12월 초까지 HTML, CSS, JS, Java 등을 배웠습니다. 너무 잡다하게(?) 배웠던지라 커리큘럼이 마음에 들지는 않았지만 코딩에 대해서 아는 것도 없었고 열심히 듣기만 했던 기억이 있네요.

12월 중순부터 약 4주간 R을 배웠습니다. 이것도 지금 생각해보면 ‘그 시간에 파이썬이나 좀 더 할 걸’이라는 생각이 들기는 합니다. 지금와서 생각해보니 교육의 커리큘럼이 더욱 마음에 들지 않네요. 그래도 R 수업을 통해서 전반적인 기초 통계학 내용이나 다양한 분석을 해볼 수 있었던 것은 좋았습니다. 통계학을 좀 더 공부해봐야 겠다는 생각이 들기도 했고요.

따라하며 배우는 데이터 과학 책을 보며 공부했던 것이 많은 도움이 되었습니다. 교육에서도 제대로 알려주지 않는 p-value, 신뢰구간에 대한 정의를 알 수 있었습니다. 그리고 어떤 데이터가 주어졌을 때 어떤 분석방법을 사용해야 하는 지도 자세하게 나와있어 도움이 되었고요.

2020. Jan

돌고돌아 1월 중순에 R수업이 끝나고 파이썬 기초를 시작했습니다. 시간이 너무 짧았기 때문에 디테일하게 공부하지는 못했습니다. 다만 교육 시작전에 2주 동안 공부했던 내용을 복습하기도 하는 시간이었습니다. 이맘때 쯤 학원 내에서도 파이썬 스터디를 꾸려 기본적인 알고리즘 문제를 풀기도 했고요. 그리고 당시 데이터 분석의 기본이 되는 Numpy, Pandas 등에 대해서도 배웠습니다. 이것도 너무 짧게 배운 것이 좀 아쉽네요. 당시에 Numpy를 좀 더 열심히 했으면 모델의 코드를 좀 더 빨리 볼 수 있지 않을까하는 생각도 듭니다. (이제라도 열심히… !!)

그리고 설날에 쉬는 동안 이 깃허브 블로그를 만들었습니다. 당시에 NLP를 배우지는 않았습니다만 애초부터 관심이 있기도 했고 ML에 대해서도 정말 정리가 잘 되어있는 (그리고 저와 이름이 같은 분이 하시는) Ratsgo님의 블로그 를 (메일로 허락맡은 뒤에) 포크하여 만들게 되었습니다.

개인적으로 뭘 쓰면서 해야 정리가 되는 스타일이라 만들고 부터 지금까지 뭘 공부하면 대강대강 정리해서 적고 있습니다. 지금은 독자를 상정하지 않고 저만 보기 위해서 써놓는 글이라 매우 불친절하지만 언젠가 한 번 대대적인 고쳐쓰기를 해야겠다는 마음을 가지고 있네요.

2020. Feb

2월부터는 교육에서는 파이썬 머신러닝 완벽 가이드 책으로 진도를 나갔습니다. 요때 당시에 엄청 열심히 공부했던 기억이 있네요. 맨날 ICT COC가서 밤새서 핸즈온 머신러닝 보고 정리하고 예제코드 쳐보고 하면서 열심히 했던 것 같습니다. 그리고 교육에서도 학습조장(?)을 맡게 돼서 저도 헷갈리는 내용을 설명해줘야 했기 때문에 음청 빡세게 공부했네요. 그래도 어려운 내용은 어려웠고 지금 생각해보면 오개념도 꽤나 있었던 것 같습니다. 그리고 SVM처럼 독학이 어려웠던 부분은 대강의 감만 익히고 넘어갔던 것 같습니다.(지금도 커널에 대한 수학적 트릭은 잘 모르겠습니다 ㅠㅠ)

요 당시에는 캐글 데이터도 많이 만져보고 프로젝트도 있어서 이런 저런 알고리즘을 돌려보는 기회가 되었습니다. 다만 당시에는 종류에 집착해서 너무 막 돌려본 감이 없지 않아 있습니다. 지금 돌린다면 데이터의 특성에 좀 더 집중해서 맞는 알고리즘을 사용해야 겠다는 생각이 있긴 하네요.

그리고 2월 24일부터는 코로나 때문에 교육을 쉬게 되었습니다. 집에 있으면 공부를 잘 못하는 스타일이라(굳이 교육을 신청한 이유도 이 때문) 한 1주일 동안은 공부를 많이 못했습니다. 당시에는 밖에도 거의 못나갔으니…

2020. Mar

3월 초부터는 같이 교육을 듣는 형책들어와써 프로젝트를 시작했습니다. 크롤링 배울 때 연습삼아 미리 짜놓았던 코드를 활용하여 알라딘 중고매장 검색 결과를 크롤링하여 알림을 보내주는 웹사이트입니다. 프로젝트 초반에는 마음이 심난해서 잘 집중을 못했었네요. 아마 이 글의 대상이 되는 7개월 동안 가장 집중이 안되던 2주가 아닐까 합니다. (같이 프로젝트 했던 형에게는 이 자리를 빌어 한 번 더 감사와 사과를…)

당시에 너무 집중을 못해서 빅데이터 커리어톡에서 뵈었던 성윤님께 다짜고짜 DM도 드렸는데 엄청 답장 잘해주셔서, 그리고 당시에 하던 프로젝트 과제 하나씩 해나가다 보니 멘붕에서 헤어나올 수 있었던 것 같습니다. 성윤님께도 이 자리를 빌어 감사를 드립니다.

프로젝트가 끝날 즈음에는 그래도 제정신을 차려서 이거저거 많이 해본거 같습니다. 특히 웹 개발에 대해서 아는 바가 하나도 없었는데 한 달 동안의 프로젝트를 통해서 플라스크나 DB기초에 대해서 뭔가를 해볼 수 있는 기회였던 것 같네요. 그리고 서비스는 애초부터 내가 만들고 싶었던 거라 지금까지도 너무 잘쓰고 있습니다. (by 소득 대비 알라딘 헤비 유저)

2020. Apr

책들어와써 프로젝트가 3월 말을 끝으로 어느 정도 마무리되었고 4월 중순에는 코로나 때문에 중단되었던 교육도 다시 재개되었습니다. 사실 최종 프로젝트를 위해서 조를 합치기도 하고, 주제를 정하느라 거의 2주 정도를 날리긴 했습니다. (교육 진행하는 쪽이랑 나름의 마찰도 좀 있었고…) 그래도 지금 와서 생각해보면 그 때 프로젝트 주제를 기획하는 데 오랜 시간을 쓰길 잘한 것 같다. (이 프로젝트 주제도 예전부터 내가 하고 싶은 걸 한 기분이 없지 않아 있지만…)

그렇게 주제는 돌고 돌아 ‘자소서 작성 도우미 프로젝트’가 되었고 주제를 정하고 나니 어느 새 5월이 되었습니다.

2020. May

거의 프로젝트에 올인한 달입니다. 다른 조보다 주제 정하는 게 늦기도 했고, 우리 조에서 쓰기로 한 KoGPT-2 같은 건 책에서는 자료를 구할 수가 없어서 구글링을 엄청했던 기억이 나네요. Finetuning 코드 작성하신 분에게 깃헙 이슈로 많이 여쭤보기도 하고 문제를 풀기 위해서 온갖 수를(?) 다 써본 것 같습니다.

(자연어 데이터가 늘 그렇듯) 가장 큰 문제는 손으로 해야하는 전처리였는데 VScode 덕분에 완벽하진 않지만 많은 부분을 정제할 수 있었습니다. 약 2주 간의 전처리 끝에 학습도 제대로 돌릴 수 있었고 학습 시 나오는 샘플 문장도 꽤 만족스럽게 나왔습니다. 학습하고 모델 연구하는 동안 나머지 팀원들이 웹 개발 열심히 해줘서 웹에다가 모델 얹는 것도 생각보다 빠르게 되었습니다.

뭔가 시간은 엄청 부족한 느낌이었는데 후다닥 하다보니 이래저래 다 된 한 달이었습니다. 개인적으로는 GPT2 등 트랜스포머 변형 모델을 (이론으로든 실전으로든) 처음 접했던 기회가 되었습니다.

2020. Jun

6월 12일까지 자잘한 오류 수정과 발표를 하고 1등이라는 결과를 얻어냈네요. 교육과정 전체가 마음에 들지는 않았습니다만 어쨌든 만족스러운 결과를 얻는 것은 항상 좋은 듯합니다.

교육이 끝나고 나서는 원격 출첵 스터디도 만들어서 열심히 공부하고 있습니다. 사실 아직까지는 코드보다는 이론 위주로 공부하고 있는데 7월 부터는 코드의 비중을 많이 늘려나갈 예정입니다. 물론 공부도 꾸준히 하고요. 그래도 강필성 교수님 자연어 강의 쭉 들으면서 자연어처리에 대한 대략적인 개념을 잡아나갈 수 있었다. 기회가 된다면 강의에서 소개해주신 논문도 좀 보고 다른 자료 참고하면서 구현도 해 볼 예정.

다음 반기는 이번 반기보다 코드를 좀 더 많이 쳤으면 한다. 그리고 책이나 인강같은 피상적인 공부가 아니라 실제적인 공부로 넘어가는 시기가 되었으면… + 구직 활동도 열심히 해야하는데 공부가 더 재미있으니 큰일이다 ㅠㅠ (자소서 도우미 만들어놓고도 자소서 쓰기가 싫기도 하고…)

Comment  Read more

이진 탐색 트리 (Binary Search Tree)

|

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

이진 탐색 트리 (Binary Search Tree)

이진 탐색 트리(Binary Search Tree, BST) 는 차수(Degree)가 2인 트리입니다. 연결된 리스트(Linked List)가 해결해주지 못하는 탐색(Search)을 최적화하기 위해 고안된 자료구조입니다. 이런 특성 때문에 이름에도 탐색(Search)이 붙었습니다. 이진 탐색 트리가 데이터를 빠르게 탐색할 수 있는 비결은 무엇일까요? 해답은 이진 탐색 트리가 데이터를 보관하는 방법에 대한 특별한 규칙(Rule)에 있습니다. 아래는 한 이진 탐색 트리의 예시입니다.

bst1

이미지 출처 : 위키피디아 - 이진 탐색 트리

위 트리에는 어떤 규칙이 있는 것일까요? 루트와 그 자식 노드에 해당하는 두 노드를 봅시다. 루트의 값은 8이며 왼쪽 자식 노드의 값은 3, 오른쪽 자식 노드의 값은 10입니다. 루트를 기준으로 더 작은 값은 왼쪽 자식 노드로, 더 큰 값은 오른쪽 자식 노드로 배치된 것을 볼 수 있습니다. 다음에는 아래로 내려가 값이 3인 노드와 그 자식 노드를 봅시다. 이 세 노드도 마찬가지로 3보다 작은 값인 1은 왼쪽 자식 노드로, 6은 오른쪽 자식 노드로 배치된 것을 볼 수 있습니다.

이진 탐색 트리의 모든 값은 이 규칙을 따라 배치됩니다. 부모 노드보다 값이 큰 노드는 오른쪽에, 값이 작은 노드는 왼쪽에 배치됩니다. 이런 규칙이 있기 때문에 더욱 빠르게 찾을 수 있습니다. 연결된 리스트에서의 탐색 과정과 비교해 보겠습니다. $8, 3, 10, 1, 6, 14, 4, 7, 13$ 이 차례대로 저장된 연결된 리스트와 이진 탐색 트리가 있다고 해봅시다. 연결된 리스트에서 $13$ 을 찾기 위해서는 모든 요소와 비교를 해야 하므로 총 9번의 Operation을 거쳐야 합니다. 하지만 이진 탐색 트리에서는 $8, 10, 14, 13$ 만 비교하면 되니 4번의 Operation 만으로 원하는 탐색을 할 수 있습니다. 이진 탐색 트리의 탐색에 대해서는 아래에서 더 자세히 살펴보겠습니다.

Structure

연결된 리스트의 노드 하나에는 총 2개의 레퍼런스가 있었습니다. 하나는 값을 가리키는 레퍼런스이고 나머지 하나는 다음 노드를 가리키는 레퍼런스로, 넥스트(Next)라고 불렀습니다. 이진 탐색 트리는 가리켜야 하는 노드가 2개이기 때문에 총 3개의 레퍼런스를 가지고 있습니다. 그 중 왼쪽 자식 노드를 가리키는 레퍼런스를 LHS(Left Hand Side)라고 하고, 오른쪽 자식 노드를 가리키는 레퍼런스는 RHS(Right Hand Side)라고 합니다.

연결된 리스트가 헤드를 통해서만 다른 노드에 접근할 수 있었던 것처럼 트리 역시 첫 번째에 해당하는 루트를 통해서만 다른 노드에 접근할 수 있습니다. 아래는 이진 탐색 트리의 노드를 파이썬 코드로 구현한 것입니다.

class TreeNode:
    nodeLHS = None
    nodeRHS = None
    nodeParent = None
    value = None

    def __init__(self, value, nodeParent):
        self.value = value
        self.nodeParent = nodeParent
	
    """각 레퍼런스 설정하기"""
    def getLHS(self):
        return self.nodeLHS
    def getRHS(self):
        return self.nodeRHS
    def getParent(self):
        return self.nodeParent
    def getValue(self):
        return self.value
    def setLHS(self):
        self.nodeLHS = nodeLHS
    def setRHS(self):
        self.nodeRHS = nodeRHS
    def setParent(self):
        self.nodeParent = Parent
    def setValue(self):
        self.value = value

아래는 이진 탐색 트리의 루트와 이진 탐색 트리에서 가능한 여러 Operation을 파이썬 코드로 적어놓은 것입니다.

from bst import TreeNode

class BinarySearchTree:
    root = None

    def __init__(self):
        pass
    def insert(self, value, node=None):
        "..."
    def search(self, value, node=None):
        "..."
    def delete(self, value, node=None):
        "..."
    def findMax(self, node=None):
        "..."
    def findMin(self, node=None):
        "..."
    def traverseLevelOrder(self):
        "..."
    def traverseInOrder(self, node=None):
        "..."
    def traversePreOrder(self, node=None):
        "..."
    def traversePostOrder(self, node=None):
        "..."

이진 탐색 트리에서의 탐색

이진 탐색 트리 속에 우리가 원하는 값이 있는지 없는지 탐색(Search)하는 과정을 알아봅시다. 이진 탐색 트리에서 데이터를 저장할 때 세웠던 규칙이 빛을 발할 때가 되었습니다. 탐색하려는 값이 우리가 보고 있는 노드의 값보다 클 경우에는 RHS를 따라 이동하고, 작을 경우에는 LHS를 따라 이동합니다. 이전처럼 우리가 원하는 값을 발견할 경우에는 True를 반환하고 그렇지 않은 경우에는 다시 같은 과정을 재귀적으로(Recursive) 수행합니다. 재귀적으로 아래쪽으로 내려가다가 자식 노드가 없을 경우, 즉 리프 노드의 값이 우리가 원하는 값이 아닐 경우에는 False를 반환하고 함수를 끝마칩니다.

위에서 사용한 예시를 다시 가져와 보겠습니다.

bst1

이미지 출처 : 위키피디아 - 이진 탐색 트리

위와 같은 이진 탐색 트리에서 $13$ 을 찾기 위해서는 어떻게 해야할까요? 가장 먼저 할 수 있는 것은 루트 노드로 접근하여 값을 비교하는 것입니다. $13$ 은 $8$ 보다 크므로 RHS를 따라 이동합니다. 다음 노드의 값은 $10$ 입니다. $13$ 은 $10$ 보다도 크기 때문에 또 RHS를 따라 이동합니다. 다음 노드의 값은 $14$ 입니다. $13$ 은 $14$ 보다 작기 때문에 LHS를 따라 이동합니다. 그 다음 노드의 값이 우리가 찾던 $13$ 이므로 True를 반환하고 함수를 마칩니다.

트리에 없는 값을 탐색하는 과정도 보겠습니다. $5$ 는 어떻게 탐색할 수 있을까요? 나머지 규칙은 위와 같습니다. 루트로 접근하여 $5$ 가 $8$ 보다 작으므로 LHS를 따라 이동, $3$ 보다는 크므로 RHS를 따라 이동, $6$ 보다는 작으므로 LHS를 따라 이동합니다. 다음 노드의 값은 $4$ 입니다. $5$ 가 $4$ 보다 크기 때문에 RHS를 따라 이동해야 하는데 더 이상 이동할 노드가 없습니다. 그러므로 False를 반환하고 함수를 마치게 됩니다.

아래의 코드는 이진 탐색 트리에서의 탐색을 파이썬 코드로 구현한 것입니다.

def search(self, value, node=None):
    """
    같은 값을 발견하면 True를 반환하도록 합니다.
    """
    if node is None:
        node = self.root
    if value == node.getValue():
        return True
    """
    그렇지 않은 경우 크기비교를 하며 RHS, LHS로 나아가게 되고
    일치하는 값을 찾지 못하고 빈 노드를 만나면 False를 반환합니다.
    """
    if value > node.getValue():
        if node.getRHS() is None:
            return False
        else:
            return self.search(value, node.getRHS())
    if value < node.getValue():
        if node.getLHS() is None:
            return False
        else:
            return self.search(value, node.getLHS())

이진 탐색 트리에서의 삽입

이진 탐색 트리에서의 삽입(Insert)하는 과정에 대해 알아봅시다. 삽입은 탐색과 매우 유사합니다. 삽입을 규칙에 맞게 하기 때문에 탐색이 쉬운 것이니까요.

삽입 역시 삽입 하려는 값이 해당 노드보다 작으면 LHS를 따라 이동하고, 크면 RHS를 따라 이동하는 과정을 재귀적으로 반복합니다. 탐색에서 원하는 값이 발견되면 True를 반환하였지만, 삽입은 원하는 값이 있으면 아무것도 반환하지 않고 그대로 함수를 마칩니다. 그리고 탐색에서는 더 이상 내려갈 자식 노드가 없으면 False를 반환했지만, 삽입에서는 그 자리에 우리가 원하는 값이 담긴 노드를 배치하고 함수를 마치게 됩니다. 삽입도 같은 예시를 통해 알아봅시다.

bst1

이미지 출처 : 위키피디아 - 이진 탐색 트리

탐색 과정에서 트리에 없었던 $5$ 를 삽입해 보겠습니다. 탐색과 같은 과정을 거쳐 $8, 3, 6, 4$ 를 따라 이동합니다. 탐색은 $4$ 이하에 RHS를 따라 이동할 노드가 없었기 때문에 False를 반환했지만 삽입은 그 자리에 $5$ 를 삽입하고 함수를 마치게 됩니다.

아래의 코드는 이진 탐색 트리에서의 삽입을 파이썬 코드로 구현한 것입니다.

def insert(self, value, node=None):
    """
    재귀 탈출 조건문 설정하기
    노드가 채워져 있지 않을 경우에는 값을 할당하도록 되어있습니다.
    """
    if node is None:
        node = self.root
    if self.root is None:
        self.root = TreeNode(value, None)
        return
    """
    값이 같을 경우 그대로 반환
    값이 큰 경우 RHS를 따라 이동하여 재귀를 통해 같은 함수를 실행
    값이 작은 경우 LHS를 따라 이동하여 재귀를 통해 같은 함수를 실행
    둘 모두 값이 존재하지 않을 경우에는 해당 값을 할당하도록 합니다.
    """
    if value == node.getValue():
        return
    if value > node.getValue():
        if node.getRHS() is None:
            node.setRHS(TreeNode(value, node))
        else:
            self.insert(value, node.getRHS())
    if value < node.getValue():
        if node.getLHS() is None:
            node.setLHS(TreeNode(value, node))
        else:
            self.insert(value, node.getLHS())
    return

이진 탐색 트리에서의 삭제

다음은 삭제를 알아볼 차례입니다. 이진 탐색 트리에서의 삭제는 이전의 자료 구조보다 훨씬 더 까다롭습니다. 이진 탐색 트리에서 노드를 삭제하는 경우의 수는 3개이므로 각각을 나누어 생각해야 합니다.

첫 번째는 자식 노드를 갖지 않는, 즉 리프 노드의 값을 삭제하는 경우입니다. 가장 간단한 케이스이기도 합니다. 이 때는 해당 노드의 부모 노드가 가리키는 레퍼런스를 제거함으로써 값을 삭제할 수 있습니다. 아래 그림을 봅시다.

deletion_bst1

이미지 출처 : javatpoint.com

위 그림은 85를 삭제하는 과정을 이미지로 나타낸 것입니다. 이 노드를 가리키고 있는 부모 노드의 레퍼런스를 제거하면 아무 노드의 가리킴도 받지 않는 85가 있는 노드는 가비지 컬렉터(Garbage collector)에 의해서 삭제됩니다.

두 번째는 하나의 자식 노드를 갖는 노드의 값을 삭제하는 경우입니다. 이 케이스는 연결된 리스트에서 요소를 삭제하는 경우와도 유사합니다. 방법은 삭제하려는 값이 담긴 노드를 가리키는 레퍼런스를 그 자식 노드를 가리키도록 하게 됩니다. 아래 그림을 보겠습니다.

deletion_bst2

이미지 출처 : javatpoint.com

위 그림은 12를 삭제하는 과정을 이미지로 나타낸 것입니다. 이 노드를 가리키고 있는 레퍼런스를 12가 있는 자식 노드인 6을 가리키도록 합니다. 이전 케이스와 같이 아무 레퍼런스의 가리킴도 받지 못하게 된 12는 가비지 컬렉터에 의해서 사라지게 됩니다.

마지막은 삭제하려는 값이 있는 노드가 두 개의 자식 노드를 갖는 경우입니다. 가장 까다로운 경우입니다. 기본적인 아이디어는 기존에 트리에 존재하는 다른 값으로 대체하여 넣어준 뒤에 중복되는 값을 삭제하는 것입니다. 아래 그림을 보겠습니다.

tree_del3

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

이번 케이스는 2가지 경우의 수가 있습니다. 위 그림은 맨 아래에 있는 이진 탐색 트리에서 $3$ 을 제거할 때의 2가지 경우의 수를 나타낸 것입니다.

왼쪽 경로는 $3$ 대신 $4$ 를 복사해 넣은 후 $4$ 에 해당하는 리프 노드를 삭제하는 과정으로 이루어져 있습니다. 리프 노드는 첫 번째로 알아보았던 그리고 오른쪽 경로는 $3$ 대신 $2$ 를 복사해 넣은 후 원래 $2$ 가 있던 노드를 삭제하는 과정으로 이루어져 있지요. 이렇게 새롭게 생성된 이진 탐색 트리는 기존의 규칙을 깨뜨리지 않습니다. 왼쪽 경로에서 $4$ , 오른쪽 경로에서 $2$ 라는 값은 어떻게 선택되는 것일까요?

일단 아무 값이나 선택하는 것은 안됩니다. 예를 들어, $3$ 이 있던 자리에 $0$ 을 넣고 원래 $0$ 이 있는 노드를 삭제하게 되면 트리의 규칙이 깨져버리기 때문입니다. 선택하는 논리는 비교적 간단합니다. $3$ 보다 왼쪽에 있는 하위 트리(Sub tree)에서는 가장 큰 값을, 오른쪽에 있는 하위 트리에서는 가장 작은 값을 선택하면 됩니다.

왼쪽에 있는 하위 트리에서 가장 큰 값이라도 오른쪽 하위 트리의 모든 값보다는 작으므로 이 값을 삭제하려는 노드에 중복시켜 넣어준 후 원래 노드를 삭제시키면 트리의 규칙을 어기지 않습니다. 오른쪽에 있는 하위 트리에서 가장 작은 값 역시 왼쪽 하위 트리에 있는 모든 값보다는 크기 때문에 삭제하려는 노드에 중복해준 후 원래 값이 있는 노드를 삭제시켜도 트리의 규칙을 어기지 않음을 알 수 있습니다.

트리에서 이런 값을 찾는 방법 역시 간단합니다. 먼저 왼쪽 하위 트리의 최댓값을 찾는 경우 (그림에서 오른쪽 경로) 부터 생각해봅시다. 이 경우는 삭제하려는 값이 있는 노드의 LHS를 따라 한 번 내려간 뒤, 이후부터는 항상 RHS만을 따라갑니다. 만약 더 이상 RHS가 없는 노드가 있다면 그 노드의 값이 중복해줄 값으로 선택됩니다. 위의 예시에서도 $3$ 이 있는 노드의 LHS를 따라 한 번 내려가면 $2$ 가 있는 노드가 됩니다. 다음부터는 RHS 만을 따라 내려가야 하지만 해당 노드에서 RHS가 없으므로 해당 노드의 값인 $2$ 가 삭제할 값을 대체하는 값이 됩니다. 다음으로 오른쪽 하위 트리의 최솟값을 찾는 경우 (그림에서 왼쪽 경로) 를 생각해봅시다. 위 그림에서 왼쪽 방향에 있는 3을 4로 대체한 것은 첫 번째 방법을 따른 것이고, 오른쪽 방향에 있는 3을 2로 대체한 것은 두 번째 방향을 따른 것이다. 이 때는 반대로 삭제하려는 값이 있는 노드의 RHS를 따라 한 번 내려간 뒤, 이후부터는 항상 LHS만을 따라갑니다. 그리고 더 이상 LHS가 없는 노드가 있다면 그 노드의 값이 중복해줄 값으로 선택됩니다. 위의 예시에서도 $3$ 이 있는 노드의 RHS를 따라 한 번 내려가면 $5$ 가 있는 노드가 됩니다. 다음부터는 LHS 만을 따라 내려가야 하므로 한 번 더 이동한 노드의 값은 $4$ 입니다. 다음에 또 LHS를 따라 이동해야 하지만 해당 노드에서 LHS가 없으므로 해당 노드의 값인 $4$ 가 삭제할 값을 대체하는 값이 됩니다.

이렇게 복제된 값을 담고 있는 노드는 LHS나 RHS 중 하나의 레퍼런스가 없는 노드이므로 위에서 나왔던 리프 노드를 삭제하는 방법이나, 자식 노드가 하나인 노드를 삭제하는 방법을 사용하여 처리가 가능합니다. 아래는 모든 삭제 과정을 파이썬 코드로 구현한 것입니다.

def delete(self, value, node=None):
    if node is None:
        node = self.root
    """
    삭제하려는 값을 찾아가는 과정
    """
    if node.getValue() < value:
        return self.delete(value, node.getRHS())
    if node.getValue() > value:
        return self.delete(value, node.getLHS())
    """
    삭제하려는 값이 있는 노드를 만났을 때
    """
    if node.getValue() == value:
        """
        2개의 자식을 갖는 노드의 값 삭제하기
        여기서는 2가지 방법 중
        RHS로 이동 후 RHS에서 가장 왼쪽 값(최솟값)을
        찾아 대체하는 방법을 사용하였습니다.
        """
        if node.getLHS() is not None and node.getRHS() is not None:
            nodeMin = self.findMin(node.getRHS())
            node.setValue(nodeMin.getValue())
            self.delete(nodeMin.getValue(), nodeRHS())
            return
        parent = node.getParent()
        """
        자식 노드를 1개 갖는 노드의 값을 삭제하는 코드입니다.
        """
        if node.getLHS() is not None:
            if node == self.root:
                self.root = node.getLHS()
            elif parent.getLHS() == node:
                parent.getLHS(node.getLHS())
                node.getLHS().setParent(parent)
            else:
                parent.setRHS(node.getLHS())
                node.getLHS().setParent(parent)
            return
        if node.getRHS() is not None:
            if node == self.root:
                self.root = node.getRHS()
            elif parent.getLHS() == node:
                parent.getLHS(node.getRHS())
                node.getRHS().setParent(parent)
            else:
                parent.setRHS(node.getRHS())
                node.getRHS().setParent(parent)
            return
        """
        자식을 갖지 않는 노드의 값을 삭제하는 경우입니다.
        """
        if node == self.root:
            self.root = None
        elif parent.getLHS() == node:
            parent.setLHS(None)
        else:
            parent.setRHS(None)
        return

위 코드에 있는 findMaxfindMin 함수 역시 따로 정의를 해주어야 합니다. 아래와 같이 각 함수를 정의할 수 있습니다.

def findMax(self, node=None):
    if node is None:
        node = self.root
    if node.getRHS() is None:
        return node
    return self.findMax(node.getRHS())

def findMin(self, node=None):
    if node is None:
        node = self.root
    if node.getLHS() is None:
        return node
    return self.findMin(node.getLHS())

Traversing

Traverse는 ‘가로지르다’라는 뜻을 가진 단어입니다. 연결된 리스트나 배열에서 존재하지 않았던 Traversing 이란 말 그대로 모든 노드를 가로지르며 값을 훑는 것입니다. 이전에 배웠던 자료구조는 중간에 갈라지는 길이 없으므로 탐색(Search)하면서 모든 노드를 다 거칠 수 있었습니다. 하지만 트리에서는 길이 나누어지므로 값을 찾는 탐색만으로 모든 노드를 거쳐갈 수 없게 되지요.

Traversing의 방법은 크게 깊이 우선 탐색법(Depth first search, DFS)너비 우선 탐색법(Breadth first search, BFS) 두 가지로 나뉩니다. 먼저 깊이 우선 탐색법에 대해 알아봅시다. 깊이 우선 탐색법은 순서에 따라 3가지 방법이 있습니다. 첫 번째가 현재 노드의 값을 탐색한 후 LHS, RHS 순서로 탐색하는 Pre-order Traverse 입니다. 두 번째는 LHS를 먼저 탐색하고 현재 노드의 값을 탐색한 후 RHS값을 탐색하는 In-order Traverse 입니다. 마지막 방법으로는 LHS와 RHS를 모두 탐색한 후 맨 마지막에 현재 노드의 값을 탐색하는 Post-order Traverse가 있습니다. 아래의 트리를 보며 설명을 이어나가겠습니다. 아래의 노드를 각 방법으로 Traversing 해보겠습니다.

traversing

이미지 출처 : towardsdatascience.com

먼저 Pre-order 방식입니다. 앞서 말했던 것처럼 이 방식은 현재 노드 - LHS를 따라 내려간 노드 - RHS를 따라 내려간 노드 순으로 탐색합니다. 아래 그림과 같은 방법으로 탐색이 이루어집니다. 접근하는 노드의 값을 무조건 얻기 때문에 루트 노드 부터 탐색하는 것을 볼 수 있습니다.

pre-order

이미지 출처 : towardsdatascience.com

다음은 In-order 방식입니다. 이 방식은 LHS를 따라 내려간 노드 - 현재 노드 - RHS를 따라 내려간 노드 순으로 탐색합니다. 아래 그림과 같은 방법으로 탐색이 이루어집니다. 현재 노드의 값을 얻지 않고 일단 LHS가 없을 때까지 내려가기 때문에 가장 왼쪽 아래에서부터 탐색이 시작되는 것을 볼 수 있습니다.

in-order

이미지 출처 : towardsdatascience.com

다음은 Post-order 방식입니다. 이 방식은 LHS를 따라 내려간 노드 - RHS를 따라 내려간 노드 - 현재 노드 순으로 탐색합니다. 아래 그림과 같은 방법으로 탐색이 이루어집니다. 현재 노드의 값을 얻지 않고 일단 LHS와 RHS가 모두 없을 때까지 내려가기 때문에 가장 왼쪽 아래에서부터 아래쪽을 다 훑고 난 후에 위로 올라오는 방향으로 탐색이 되는 것을 볼 수 있습니다.

post-order

이미지 출처 : towardsdatascience.com

각각의 깊이 우선 탐색 과정을 파이썬 코드로 구현하면 다음과 같습니다.

"""
일단 현재 노드의 값을 추가한 뒤
LHS, RHS 순으로 탐색합니다.
"""
def traversePreOrder(self, node=None):
    if node is None:
        node = self.root
    ret = []
    ret.append(node.getValue())
    if node.getLHS() is not None:
        ret = ret + self.traversePreOrder(node.getLHS())
    if node.getRHS() is not None:
        ret = ret + self.traversePreOrder(node.getRHS())
    return ret

"""
일단 현재 LHS 부분부터 최대한 탐색한 뒤
현재 노드의 값을 추가하고 그 다음 RHS 부분을 탐색합니다.
"""
def traverseInOrder(self, node=None):
    if node is None:
        node = self.root
    ret = []
    if node.getLHS() is not None:
        ret = ret + self.traversePreOrder(node.getLHS())
    ret.append(node.getValue())
    if node.getRHS() is not None:
        ret = ret + self.traversePreOrder(node.getRHS())
    return ret

"""
일단 현재 LHS 부분부터 최대한 탐색한 뒤
그 다음 RHS 부분을 탐색하고
둘 다 존재하지 않을 때, 혹은 이미 탐색한 노드일 때
현재 노드의 값을 추가합니다.
"""
def traversePostOrder(self, node=None):
    if node is None:
        node = self.root
    ret = []
    if node.getLHS() is not None:
        ret = ret + self.traversePreOrder(node.getLHS())
    if node.getRHS() is not None:
        ret = ret + self.traversePreOrder(node.getRHS())
    ret.append(node.getValue())
    return ret

다음은 너비 우선 탐색법에 대해 알아보겠습니다. 이 방법은 같은 높이에 있는 것부터 탐색합니다. 루트 노드부터 탐색을 하게 되며 위쪽 레벨(level)의 노드를 모두 거치고 난 후 아래쪽 레벨로 내려갑니다. 아래 그림처럼 탐색이 이루어집니다.

bfs

이미지 출처 : towardsdatascience.com

너비 우선 탐색법은 큐(Queue)를 사용하여 값을 뽑아냅니다. 가장 먼저 루트 노드의 값을 Enqueue 해줍니다. 다음 단계부터는 만들어진 큐에서 하나씩을 Dequeue 하게 되며 동시에 Dequeue 하는 값이 있는 노드의 자식 노드의 값을 Enqueue 해줍니다. Dequeue한 값이 있는 노드가 리프 노드라면 아무것도 Enqueue 해주지 않습니다. 위 그림에서는 다음과 같이 큐의 요소가 변하며 너비 우선 탐색이 이루어지는 것을 알 수 있습니다.

Current Queue
  1
1 2, 3
2 3, 4, 5
3 4, 5, 6, 7
4 5, 6, 7, 8
5 6, 7, 8
6 7, 8, 9, 10
7 8, 9, 10
8 9, 10
9 10
10  

아래는 Breadth First Traverse를 파이썬 코드로 구현한 것입니다.

def traverseLevelOrder(self):
    """
    먼저 큐를 만들고 루트 노드의 값을 추가해줍니다.
    """
    ret = []
    Q = Queue()
    Q.enqueue(self.root)
    
    """
    아래의 과정을 큐가 비어있을 때까지 반복합니다.
    """
    while not Q.isEmpty():
        """
    	첫 번째 값을 Dequeue하여 뽑아냅니다.
    	"""
        node = Q.dequeue()
        if node is None:
            continue
		ret.append(node.getValue())
        """
    	그 노드의 자식 노드,
    	즉 LHS와 RHS가 가리키는 값을 Enqueue합니다.
    	"""
        if node.getLHS() is not None:
            Q.enequeue(node.getLHS())
        if node.getRHS() is not None:
            Q.enequeue(node.getRHS())
    return ret

트리 구조의 성능

다음은 연결된 리스트와 일반적인 이진 탐색 트리, 그리고 최악의 경우인 이진 탐색 트리에서의 탐색, 삽입, 삭제, Traversing Operation의 성능을 비교한 표입니다. 아래에서 최악의 경우인 이진 탐색 트리는 어떻게 생겼을까요? 이곳 에서 다루었던 Degenerate Tree의 경우가 최악의 경우에 속합니다. 리프 노드 이전 노드까지의 모든 노드가 자식 노드를 하나만 갖는다면 레퍼런스가 갈라지는 부분이 없기 때문에 연결된 리스트와 탐색 시간이 같아지게 됩니다. 하지만 대부분의 경우 이진 탐색 트리에서 특정 요소를 찾는 Operation의 시간 복잡도는 $O(\log n)$ 으로 연결된 리스트의 $O(n)$ 보다 이상적입니다.

  Linked List Binary Search Tree in Average Binary Search Tree in Worst Case
Search $O(n)$ $O(\log n)$ $O(n)$
Insert after search $O(1)$ $O(1)$ $O(1)$
Delete after search $O(1)$ $O(1)$ $O(1)$
Traversing $O(n)$ $O(n)$ $O(n)$

Comment  Read more

GPT, GPT-2 (Generative Pre-Training of a language model)

|

본 포스트의 내용은 고려대학교 강필성 교수님의 강의김기현의 자연어처리 딥러닝 캠프 , 밑바닥에서 시작하는 딥러닝 2 , 한국어 임베딩 책을 참고하였습니다.

GPT

openai

이미지 출처 : openai.com

GPT(Generative Pre-Training of a Language Model)는 2018년 6월 OpenAI에서 “Improving Language Understanding by Generative Pre-Training” 논문을 통해서 발표한 모델입니다. GPT는 시간적으로 ELMo이후에 BERT보다는 전에 발표되었습니다. GPT의 기본이 되는 아이디어는 좋은 임베딩 표현을 강조했던 ELMo와 유사하다고 할 수 있습니다.

먼저 특정한 목적 태스크가 없는 대량의 자연어 데이터셋(Unlabeled corpora)을 언어 모델에 학습시킵니다. 이 과정을 Pre-training(사전 학습)이라고 합니다. pre-training을 마친 모델에 추가로 태스크에 맞는 데이터셋(Labeled corpora)을 추가 학습시킵니다. 이를 Fine-tuning이라고 합니다. GPT는 이러한 “전이 학습(Transfer learning) 방법으로 이전의 모델보다 태스크에 상관없이(Task-agnostic) 더 좋은 성능을 낼 수 있을 것이다”라는 아이디어에서 고안되었습니다.

Structure & Pre-training

GPT는 트랜스포머(Transformer)의 디코더 블록을 여러 겹 쌓아 만들어 언어 모델(Language models)에 적용하였습니다. 하지만 GPT의 디코더 블록은 트랜스포머의 디코더 블록과 완전히 동일한 구조를 가지고 있지는 않습니다. 트랜스포머에서는 디코더 블록 내에 총 3개의 서브레이어(Sub-layer)를 가지고 있었습니다. 각각 Masked Self-Attention, Encoder-Decoder Attention, Feed Forward Neural Network 였습니다.

하지만 GPT에서는 인코더를 사용하지 않으므로 Encoder-Decoder Attention 층을 필요로 하지 않습니다. 그래서 GPT의 디코더 블록 내부에는 2개의 서브레이어(Masked Self-Attention, FFNN)만 존재하게 됩니다. 아래는 GPT에서 사용된 트랜스포머의 디코더 구조를 나타낸 이미지입니다.

gpt_decoder

이미지 출처 : jalammar.github.io

pre-training은 레이블링되지 않은 말뭉치 $U = (u_1, u_2, \cdots ,u_n)$ 에 대하여 다음의 로그 우도를 $L_1$을 최대화 하는 과정으로 진행됩니다. 아래 수식에서 $k$는 윈도우의 사이즈이며, $\Theta$는 학습 파라미터입니다.

[L_1(U) = \sum_i \log P(u_i \vert u_{i-k}, \cdots, u_{i-1};\Theta)]

학습과정 역시 트랜스포머의 디코더와 유사합니다. 단어 임베딩 $W_e$와 포지셔널 인코딩 $W_p$를 사용하여 첫 번째 디코더에 들어갈 은닉벡터 $h_0$를 마련합니다. 그리고 $n$개의 디코더 블록을 통과시킨 뒤에 최종적으로 나오는 은닉 벡터 $h_n$을 마지막으로 소프트맥스로 통과시켜 각 토큰이 등장할 확률을 구하게 됩니다. 수식으로 나타내면 다음과 같습니다.

[\begin{aligned} h_0 &= UW_e + W_p
h_l &= \text{TransformerDecoderBlock}(h_{l-1}), \quad \forall i\in [1,n]
P(u) &= \text{Softmax}(h_nW_e^T) \end{aligned}]

Fine-Tuning

fine-tuning에서는 레이블링된 데이터셋 $C = (x_1, \cdots, x_m;y)$를 사용합니다. 각 입력된 데이터는 pre-trained 모델을 지나면서 레이블 $y$를 예측하는 방향으로 학습을 진행해나가게 됩니다. 로그 우도 $L_2$를 최대화 하는 과정입니다. 수식으로 나타내면 다음과 같습니다.

[P(y x_1, \cdots, x_m) = \text{softmax}(h_l^m W_y) \
L_2(C) = \sum_{(x,y)} \log P(y x_1, \cdots, x_m)]

논문에서는 fine-tuning에서 $L_2$뿐만 아니라 $L_1$을 조합하여 사용합니다. 논문에서는 방법이 지도학습 모델의 일반화를 돕고 수렴 속도를 더 빠르게 하는 효과가 있다고 합니다. 아래의 식은 두 목적함수를 결합한 새로운 목적함수 $L_3$를 수식으로 나타낸 것입니다.

[L_3(C) = L_2(C) + \lambda \times L_1(C)]

fine-tuning 에서 GPT의 모델 구조는 동일하게 유지됩니다. 대신 태스크마다 다른(task-specific) 형태로 데이터셋을 입력해야 하지요. 아래는 태스크에 따라서 달라지는 데이터셋의 형태를 나타낸 이미지입니다.

gpt_input_data

이미지 출처 : jalammar.github.io

먼저 분류(Classification)의 경우에는 별도의 구분자(Delim) 없이 텍스트 데이터를 넣습니다. 함의(Entailment)와 관련된 태스크는 전제(Premise)와 가설(Hypothesis)를 구분자로 나누어 입력합니다. 두 텍스트 간의 유사도(Similarity)를 파악하는 태스크는 두 텍스트를 구분자로 나누어 입력하되, 순서를 바꾼 데이터셋을 하나 더 구성하여 함께 입력하게 됩니다. 마지막으로 객관식(Multiple choice)와 관련된 태스크의 경우에는 N개 만큼의 세트를 만듭니다. 각 세트는 구분자를 기준으로 앞쪽에는 동일한 컨텍스트가 위치하고 뒤쪽은 각각의 Answer가 위치합니다. 모든 세트를 각각 다른 트랜스포머에 넣은 뒤 출력되는 벡터에 소프트맥스를 취해주어 최종 답안을 구합니다.

Evalution

논문에서는 지도학습 데이터셋을 사용하여 이전의 모델과 비교하였습니다. 대부분의 태스크에서 State-of-the-Art(SOTA) 를 달성하였습니다.

gpt_perf1

gpt_perf2

gpt_perf3

이미지 출처 : Improving Language Understanding by Generative Pre-Training

Ablation Study

논문에서는 이외에도 몇 가지 추가적인 연구를 수행하였습니다. 먼저 디코더 층의 개수를 변화시키면서 2개의 데이터셋(RACE, MultiNLI)에 대해 성능을 측정하였습니다. 아래 이미지의 왼쪽이 결과를 나타낸 그래프입니다. 층의 개수가 늘어나면서 성능이 늘어남을 확인할 수 있습니다. 전이 학습된 모든 디코더 층이 더 좋은 성능에 영향을 미치고 있는 것을 확인할 수 있습니다.

오른쪽은 4개의 태스크(감성 분석, 객관식, 문법, 질의응답)에 대하여 pre-training 만으로 어떤 성능을 보이는 지를 나타낸 그래프입니다. 그리고 LSTM을 활용하여 동일한 태스크에 대해 동일한 횟수만큼 pre-training한 후 성능을 비교하였습니다. 이 실험을 통해 모델의 휴리스틱(Heuristic)이 어떻게 변화하는 지를 평가하고 있습니다. pre-training 학습량이 늘어날수록 모델의 휴리스틱이 더 좋아지는 것을 볼 수 있습니다. 그리고 LSTM은 트랜스포머에 비해 Zero-shot 성능이 떨어짐을 확인할 수 있습니다.

gpt_perf4

이미지 출처 : Improving Language Understanding by Generative Pre-Training

아래는 여러 데이터셋에 대해 학습 방법이나 다른 학습 방법을 사용한 4가지 경우를 비교하고 있습니다. 첫 번째는 pre-training 이후 보조 손실($\lambda \times L_1$)을 활용하여 fine-tuning을 모두 수행한 모델입니다. 두 번째는 pre-training을 사용하지 않은 모델이고, 세 번째는 fine-tuning 과정에서 보조 손실을 사용하지 않은(only $L_2$) 모델입니다. 마지막은 다른 조건은 동일하되 트랜스포머 대신 2048개의 유닛을 사용한 LSTM 모델입니다.

먼저 pre-training을 하지 않은 모델은 모든 태스크에 대해 낮은 성능을 나타냅니다. LSTM 역시 하나의 데이터셋(MRPC)을 제외하고는 대부분의 태스크에서 트랜스포머에 비해 떨어지는 성능을 보여줍니다. 마지막으로 보조 손실을 사용한 모델(full)과 보조 손실을 적용하지 않은 모델을 비교해보겠습니다. 상대적으로 작은 데이터셋(CoLA, SST2, MRPC, STSB)에 대해서는 보조 손실을 적용하지 않은 모델이 오히려 더 좋은 성능을 보여줍니다. 하지만 일정 이상으로 커지게 되면 보조 함수를 적용한 데이터셋의 성능이 더 높아짐을 확인할 수 있습니다.

gpt_perf5

이미지 출처 : Improving Language Understanding by Generative Pre-Training

GPT-2

OpenAI는 GPT에 이어 2019년 2월 “Language Models are Unsupervised Multitask Learners” 라는 논문을 통해 GPT-2 모델을 발표하였습니다. 구조상으로는 이전에 발표했던 GPT와 별 차이가 없지만 더 많은 데이터를 통해서 Pre-training 되었다는 차이점을 가지고 있습니다. GPT-2의 Pre-training에는 무려 40GB정도의 말뭉치가 사용되었습니다.

모델의 크기에 따라 4개의 GPT-2 모델을 발표하였습니다. GPT-2 SMALL의 경우 파라미터의 개수가 1억 1700만(117M)개로 BERT BASE가 사용했던 110M과 비슷한 개수로 맞추어 발표하였습니다. GPT-2 MEDIUM은 3억 4500만(345M)으로 BERT LARGE의 파라미터 개수인 340M과 비슷한 개수로 맞추었지요. OpenAI는 이보다 더 큰 사이즈의 LARGE와 EXTRA LARGE 모델도 공개하였는데 각각 7억 6200만(762M)개, 15억 4200만(1542M)개의 파라미터를 가지고 있습니다. 아래는 GPT-2의 대략적인 크기를 잘 보여주는 이미지입니다.

gpt2_models

이미지 출처 : jalammar.github.io

Structure

GPT-2의 구조는 GPT와 거의 유사합니다. GPT-2 SMALL은 12개의 디코더 블록을 쌓아올린 모델이며 임베딩 벡터의 차원 또한 BERT BASE와 동일한 768차원의 임베딩 벡터를 사용하고 있습니다. GPT-2 MEDIUM은 디코더 24개를 쌓아올렸으며 BERT LARGE와 같은 1024차원의 임베딩 벡터를 사용하였습니다. 나머지 GPT-2 LARGE와 EXTRA LARGE는 각각 36개, 48개의 디코더를 쌓아올렸으며 1280, 1600차원의 임베딩 벡터를 사용하였습니다. 아래는 각 모델마다 사용한 디코더 블록의 개수와 임베딩 벡터의 차원 수를 잘 보여주는 이미지입니다.

gpt2_size

이미지 출처 : jalammar.github.io

GPT-2는 최대 1024개의 토큰을 입력받을 수 있으며 GPT-2는 특정 토큰이 생성될 때 항상 가장 확률이 높은 단어(Top word)만 선택되지 않도록 top_k 파라미터를 설정하여 샘플링 범위를 넓힐 수 있습니다. 이 파라미터를 조정하면 단어를 생성할 때 $k$개의 단어 보기 중에서 선택하게 되므로 $k$를 키울수록 모델이 다양한 문장을 생성하게 됩니다.

자기 회귀(Auto-regressive)적인 언어 모델 기반으로 작동하므로 이미 생성된 토큰이 다음 토큰의 생성 확률에 영향을 끼치게 됩니다.

gpt2_generate

이미지 출처 : jalammar.github.io

전체 vocab 사이즈의 크기는 50,257개이며 단어 임베딩 사이즈는 모델에 맞게 사용합니다. 포지셔널 인코딩 역시 최대 처리할 수 있는 토큰 사이즈에 맞추어 1024개로 설정되어 있습니다. 이렇게 단어 임베딩과 포지셔널 인코딩 벡터를 더하여 모델에 입력하게 됩니다. 아래는 토큰 임베딩 벡터와 포지셔널 인코딩 벡터가 결합되어 모델에 입력된 후 출력 단어가 생성되는 과정을 나타낸 이미지입니다.

gpt2_word_token

gpt2_position

gpt2_input

gpt2_output

이미지 출처 : jalammar.github.io

GPT-2는 단어 토큰을 만들기 위해서 BPE(Byte pair encoding)을 사용합니다. 입력할 수 있는 최대 토큰의 개수는 1024개 이며 동시에 처리할 수 있는 토큰 수는 절반인 512개입니다.

Ablation Study

아래는 파라미터의 개수, 즉 모델의 사이즈에 따라 성능이 변하는 것을 관측한 그래프입니다. Perplexity(PPL)을 기준으로 측정하였으며 모델 사이즈가 커질수록 성능이 좋아지는 것을 알 수 있습니다.

gpt2_perf1

이미지 출처 : Improving Language Understanding by Generative Pre-Training

아래는 지도학습 데이터셋에 대한 GPT-2의 성능을 측정한 것입니다. GPT-2 SMALL 모델만으로도 몇 가지 태스크에서 SOTA를 달성할 수 있었으며 가장 사이즈가 큰 EXTRA LARGE 모델의 경우 한 가지 데이터셋을 제외하고는 SOTA를 달성한 것을 볼 수 있습니다.

gpt2_perf2

이미지 출처 : Improving Language Understanding by Generative Pre-Training

Post BERT, GPT-2

ELMo, GPT, BERT, GPT-2 이후에도 아래 그림과 같이 수많은 모델이 나오고 있습니다.

gpt2_post_bert

이미지 출처 : Text Analytics 강의자료

아래 그림을 보면 T-NLG가 발;표된 시점까지 파라미터의 개수, 즉 모델의 사이즈가 기하급수적으로 늘고 있는 것을 볼 수 있습니다. 심지어 GPT-2에 이어 2020년 6월에 OpenAI가 발표한 GPT-3에는 약 1750억(175B)개의 파라미터가 사용되었습니다. 이는 그래프에 있는 T-NLG보다도 10배나 많은 파라미터가 사용된 것으로 자연어처리 모델의 스케일이 얼마나 빠르게 커지고 있는 지를 보여주는 사례라고 할 수 있습니다.

gpt2_post_bert2

이미지 출처 : Text-Analytics Github

Comment  Read more

BERT (Bidirectional Encoder Representations from Transformer)

|

본 포스트의 내용은 고려대학교 강필성 교수님의 강의김기현의 자연어처리 딥러닝 캠프 , 밑바닥에서 시작하는 딥러닝 2 , 한국어 임베딩 책을 참고하였습니다.

BERT

이미지 출처 : sesameworkshop.org

BERT(Bidirectional Encoder Representations from Transformer)는 구글이 2018년 10월에 BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding 라는 논문을 통해서 발표한 모델입니다. 모델명을 해석해보면 ‘트랜스포머의 인코더를 양방향으로 해석하여 만들어낸 표현’을 사용하였음을 알 수 있습니다.

Structure

논문에서는 BERT Base 모델과 좀 더 크기를 키운 BERT Large 모델을 발표하였습니다.

bert2

이미지 출처 : jalammar.github.io

둘 모두 트랜스포머의 인코더 블록을 여러 개 중첩한 모델입니다. Base 모델은 총 12개의 인코더 블록(Layer)을 사용하였고, 은닉 벡터(Hidden vector)의 차원은 768, 어텐션 헤드(Attention head)는 12개를 사용하였습니다. $\text{BERT}_\text{BASE} (L=12, H=768, A=12)$ 모델의 파라미터는 총 1억 1천만(110M)개로 이전에 발표되었던 GPT의 Base 모델(117M)과 비슷합니다.

Large 모델은 총 24개의 인코더와 1024 차원의 은닉 벡터, 16개의 어텐션 헤드로 구성됩니다. $\text{BERT}_\text{BASE} (L=24, H=1024, A=16)$ 모델의 파라미터는 3억 4천만(340M)개의 파라미터를 사용합니다. 아래는 각 모델에 사용된 인코더 블록을 도식화한 이미지입니다.

bert3

이미지 출처 : jalammar.github.io

BERT vs GPT vs ELMo

BERT는 단어를 예측할 때 문맥(Context)을 동시에 양방향(Bidirectional)으로 모두 읽어낼 수 있습니다. 아래 그림은 BERT와 이전 모델인 GPT와 ELMo의 구조를 비교하여 보여주고 있습니다. ELMo는 양방향으로 문장을 학습하지만 동시에 양방향을 보고 단어를 예측하지는 않습니다. GPT는 디코더 블록의 Masked Self-Attention을 사용하여 단어를 예측하기 때문에 단방향(Unidirectional)으로 학습을 진행합니다.

bert_str

이미지 출처 : BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding

Input Representation

BERT는 여러 태스크에 적용하기 위해 입력 데이터를 두 가지 형태로 입력 받을 수 있도록 설계되었습니다. 입력 데이터인 “Sequence”는 하나의 “Sentence”가 될 수도 있고, 두 개의 “Sentence”가 될 수도 있지요. 앞 문장에 나오는 “Sentence”는 일반적으로 사용되는 ‘문장’과는 다른 의미를 가집니다. BERT에서 “Sentence”임의로 계속 이어지는 텍스트(Arbitrary span of contiguous text)라는 의미로 사용합니다. 하나의 “Sentence” 안에 (일반적인 의미의)’문장’이 하나만 있을 수도, 여러 개가 있을 수도 있지요.

아래 이미지는 데이터가 모델에 입력되는 모습을 보여주고 있습니다.

bert_input_sequence

이미지 출처 : BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding

BERT에는 특정한 기능을 담당하는 스페셜 토큰이 있습니다. 첫 번째 스페셜 토큰인 [CLS](Classification token)는 모든 입력 토큰의 앞에 위치합니다. [CLS]는 학습을 진행하면서 다음 Sentence를 예측하는 정보를 담은 은닉 벡터(Hidden vector)로 거듭나게 됩니다. 두 번째 스페셜 토큰은 [SEP](Separation token)입니다. [SEP]는 입력 데이터가 두 개의 “Sentence”로 구성될 때, 둘 사이에 위치하여 “Sentence”를 구분하는 역할을 합니다. 만약 하나의 “Sentence”로만 구성된 입력 데이터라면 맨 마지막에 위치합니다.

BERT에 입력되는 벡터는 세 가지 임베딩 벡터의 합으로 구성됩니다. 첫 번째는 3만 개의 단어로 이루어진 사전으로부터 가져온 단어 토큰 임베딩 벡터입니다. 두 번째는 [SEP]을 기준으로 앞에 있는 “Sentence”인지 뒤에 있는 “Sentence”인지를 구분하는 세그먼트(Segment) 임베딩 벡터입니다. 마지막은 각 토큰이 어디에 위치하는 지를 표시하는 위치 임베딩(Positional Embedding) 벡터1입니다. 세 벡터를 모두 더한 벡터를 BERT 안으로 넣어주게 됩니다.

bert5

이미지 출처 : BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding

Pre-training

BERT의 사전 학습에는 약 8억 단어로 구성된 Book corpus와 25억 단어로 구성된 위키피디아 말뭉치를 사용하였습니다. 한 번에 입력할 수 있는 최대 토큰 길이는 512개이며, 배치 사이즈는 256을 사용하였습니다. 옵티마이저(Optimizer)는 Adam을 $\eta = 1.0 \times 10^{-4}, \beta_1 = 0.9, \beta_2 = 0.999$로 설정하여 사용하였습니다. $0.01$ 의 $L2$ 정칙화를 적용하였고 모든 층마다 $0.1$ 의 드롭아웃(Dropout)을 적용하여 과적합을 방지하고자 하였습니다. 활성화 함수로는 GPT와 동일한 GeLU를 사용하였습니다.

BERT는 pre-training 단계에서 두 가지 방법을 사용하여 언어를 학습합니다. 하나는 MLM(Masked Language Model)이고 나머지 하나는 NSP(Next Sentence Prediction)입니다. BERT의 성능을 크게 끌어올린 두 방법에 대해 더 자세히 알아보겠습니다.

MLM(Masked Language Model)

혹시 영어를 공부하면서 아래와 같은 문제를 풀어보신 적이 있으신가요?

blank_reasoning

이미지 출처 : news.mt.co.kr

이런 유형을 흔히 “빈칸 추론”이라고 하는데요. 이 빈칸 추론 문제는 수능 영어나 토익에서 꽤 높은 비중을 차지합니다. 문맥에 맞는 의미에 해당하는 단어를 찾거나, 문법적으로 알맞는 단어를 고르면 됩니다. 이쯤 되면 갑자기 BERT 이야기하다가 갑자기 빈칸 추론 문제가 나와서 당황하신 분이 계실텐데요. “빈칸 추론”을 언급한 이유는 BERT의 MLM이 이와 매우 유사하기 때문입니다.

BERT 이전의 언어 모델은 순차적으로 단어를 예측하는(Auto-Regressive) 방법으로 진행되었습니다. ELMo가 양방향으로 단어를 학습하기 위해서 순방향으로 학습한 임베딩 벡터와 역방향으로 학습한 임베딩 벡터를 이어 붙이기는(Concatenation) 하였습니다. 하지만 동시에 전체 문맥을 학습할 수 없는 한계점이 있었지요. GPT는 단방향(Unidirectional)으로 학습하기 때문에 문맥을 제대로 판단하기 어렵습니다. 아래 예시를 보겠습니다.

“I was (happy) that my mother complimented me.”

위 문장에서 “happy”를 학습해야 한다고 해보겠습니다. 순방향으로 학습한다면 “I was” 만을 보고 단어를 예측해야합니다. 뒤에 “happy” 한 이유를 설명하는 부분은 학습 과정에서 생략되지요. 단어 학습에서 많은 부분을 포기하게 됩니다.

하지만 BERT는 빈칸을 뚫어놓고 다음 단어를 예측합니다. 빈칸 앞과 뒤를 모두 보고 문맥을 파악하여 해당하는 단어를 맞추며 학습하지요. BERT가 위 문장에서 “happy” 를 학습한다면 아래 문장에서 [MASK]에 들어갈 단어를 학습하는 식으로 진행되겠지요.

“I was [MASK] that my mother complimented me.”

BERT는 위와 같이 입력 토큰의 15% 가량을 임의로 [MASK] 토큰으로 마스킹한 뒤에 해당 토큰 주변의 문맥을 양방향(Bidirectional)으로 탐색하여 마스킹된 단어가 실제로 어떤 단어인지를 예측합니다. MLM 방식을 채택함으로써 ELMo나 GPT가 동시에 양방향으로 문맥을 학습할 수 없었다는 한계점을 극복할 수 있었습니다.

아래는 BERT가 “Let’s stick to improvisation in this skit …” 로 시작하는 문장에서 MLM을 통해 학습하는 방법을 도식화한 이미지입니다.

bert6

이미지 출처 : jalammar.github.io

위에서는 “improvisation”이 마스킹되었습니다. [MASK]토큰으로 변환된 상태로 모델에 입력되고 모델은 해당 단어에 들어갈 가장 적절한 단어를 확률로써 찾아냅니다. 예측한 단어가 원래 문장에 있는 단어인 “improvisation”이 되도록 모델이 학습을 진행하지요.

하지만 사뭇 완벽해보이는 MLM에도 문제가 있습니다. fine-tuning 에서는 MLM이 작동하지 않기 때문에 pre-training과 fine-tuning간 학습 불일치(discrepancy)가 발생합니다. 논문에서는 학습 과정상 불일치를 줄이기 위해서 일종의 트릭을 사용하였습니다. 마스킹 대상이 되는 단어 중에 80%만 실제로 [MASK] 토큰으로 바꾸어주는 것이지요. 나머지 20% 중 절반인 10%는 원래 단어로 두고, 남은 10%는 임의의 단어로 치환합니다. 논문에서는 불일치를 줄이기 위해서 (마스킹 : 동일 단어 : 임의의 단어)의 비율을 조정하면서 성능을 비교하였습니다. 실험 결과 80 : 10 : 10 일 때의 성능이 가장 좋음을 아래 표를 통해서 보여주고 있습니다.

“My dog is hairy” $\rightarrow$ “My dog is [MASK]” - (80%)

“My dog is hairy” $\rightarrow$ “My dog is hairy” - (10%)

“My dog is hairy” $\rightarrow$ “My dog is apple - (10%)

bert_mlm

이미지 출처 : BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding

NSP(Next Sentence Prediction)

동문서답 사자성어는 아래와 같은 의미를 가지고 있지요.

nsp_dmsd

이미지 출처 : wordrow.kr

우리는 질문할 때 질문을 받은 사람이 질문 의도에 맞는 적절한 답을 해주기를 원합니다. 질문과는 상관없는 대답, 즉 동문서답을 한다면 짜증나겠지요. BERT도 동문서답을 하지 않도록 학습을 하는데요. NSP가 바로 이를 위한 학습 방법입니다.

NSP는 한 쌍의 “Sentence”가 들어왔을 때 [SEP] 뒤에 있는 “Sentence”가 이전 “Sentence” 바로 다음에 등장하는지를 [CLS] 토큰을 활용하여 이진 분류(IsNext, NotNext)로 예측합니다. 아래 예시를 보겠습니다.

S1 : 자연어 처리 또는 자연 언어 처리는 인간의 언어 현상을 컴퓨터와 같은 기계를 이용해서 묘사할 수 있도록 연구하고 이를 구현하는 인공지능의 주요 분야 중 하나다.

S2 : 자연 언어 처리는 연구 대상이 언어 이기 때문에 당연하게도 언어 자체를 연구하는 언어학과 언어 현상의 내적 기재를 탐구하는 언어 인지 과학과 연관이 깊다.

위 예시에서 S1과 S2는 위키피디아 - 자연어처리의 첫 두 문장으로 실제로 이어지는 문장입니다. BERT는 이런 문장을 IsNext 로 판단하도록 학습합니다. 반대로 NSP가 NotNext로 판단해야 하는 한 쌍의 문장은 다음과 같습니다.

S1 : 자연어 처리 또는 자연 언어 처리는 인간의 언어 현상을 컴퓨터와 같은 기계를 이용해서 묘사할 수 있도록 연구하고 이를 구현하는 인공지능의 주요 분야 중 하나다.

S2` : 전세계 최대 규모의 동영상 공유 사이트로서, 유튜브 이용자가 영상을 시청·업로드·공유할 수 있다.

위 예시의 S2` 는 위키피디아 - 유튜브의 두 번째 문장입니다. S1과 실제로 이어지지 않는 문장이며 문맥적으로도 아무 상관이 없지요.

QA(Question & Answering)나 NLI(Natural Language Inference, 자연어 추론) 등의 복잡한 태스크를 풀기 위해서는 “Sentence” 사이의 관계를 이해해야 합니다. BERT 이전의 모델은 두 텍스트 사이의 관계를 학습하지 않았습니다. 그렇기 때문에 이런 복잡한 문제에 대해 좋은 성능을 보이지 못했지요. BERT의 NSP는 이를 [CLS] 토큰 하나를 학습시키는 간단한 방법으로 극복하였습니다. 아래 이미지는 NSP 학습을 잘 보여주는 이미지입니다.

bert8

이미지 출처 : jalammar.github.io

실제 pre-training 과정에서는 입력할 2개의 “Sentence”를 뽑을 때 50%는 실제로 이어지는 쌍을, 나머지 50%는 이어지지 않는 쌍을 임의로 구성합니다. 그리고 [CLS]로 바로 다음에 이어지는 문장인지(IsNext) 떨어져 있는 문장인지(NotNext) 를 예측하지요. 아래는 NSP 예시 이미지입니다.

bert_nsp1

bert_nsp2

이미지 출처 : github.com/pilsung-kang

위에서 언급한 것처럼 NSP는 매우 간단한 방법을 통해서 성능을 올릴 수 있었습니다. 아래는 pre-training에서 NSP를 학습한 모델과 그렇지 않은 모델의 성능을 비교한 표입니다. NSP 방법으로 학습한 모델이 그렇지 않은 모델보다 NLI(QNLI), QA(SQuAD) 등의 태스크에서 좋은 성능을 보임을 확인할 수 있습니다.

bert_nsp4

이미지 출처 : BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding

Fine-Tuning

이어서 BERT의 fine-Tuning에 대해서 알아보겠습니다. fine-tuning은 pre-trained BERT를 목적에 맞는 태스크에 사용하기 위해서 수행합니다. pre-trained BERT위에 하나의 층을 더 쌓아 원하는 데이터를 학습시키면 태스크를 수행할 수 있는 모델이 만들어집니다.

fine-tuning 방법을 태스크에 따라 크게 4가지로 구분해 볼 수 있습니다. 아래 그림을 통해 각 빙법에 대해 알아보겠습니다.

bert_ft

이미지 출처 : BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding

(a)“Sentence” 쌍을 분류하는 태스크에 대하여 데이터를 입력하는 방식입니다. “Sentence” 쌍을 입력받은 모델은 [CLS] 토큰이 출력하는 Class label을 반환합니다. (b)는 감성분석 등 하나의 문장을 입력하여 해당 문장을 분류하는 태스크에서 데이터를 입력하는 방식입니다. (c)는 질의응답(Question & Answering)에 대해 데이터를 입력하는 방식입니다. 질문과 본문에 해당하는 단락을 [SEP] 토큰으로 나누어 입력한 뒤 답을 출력하도록 합니다. (d)는 토큰마다 답을 내야 하는 개체명 인식(Named Entity Recognition, NER) 등의 태스크를 fine-tuning하는 방법입니다.

아래는 여러 데이터셋에 대하여 BERT와 이전 모델의 성능을 비교하여 나타낸 표입니다. BERT BASE 만으로도 이전에 발표된 모델보다 대부분 더 좋은 성능을 보임을 확인할 수 있습니다. BERT LARGE의 경우 CoLA, RTE등의 데이터셋에는 BASE 보다도 더욱 좋은 성능을 나타내며 SOTA를 달성하였습니다.

bert_perf

이미지 출처 : BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding

Ablation Study

논문에서는 여러 조건을 바꾸어가며 추가적인 연구를 진행했습니다. 첫 번째로 BERT 모델의 사이즈를 바꾸어가며 성능을 평가하였습니다. $#L$은 인코더 블록의 개수이고 $#H$와 $#A$ 는 각각 임베딩 벡터 차원과 어텐션 헤드의 개수입니다. 아래 그림에서 4번째 행에 있는 모델이 BERT BASE이며 맨 아래에 있는 모델이 BERT LARGE입니다. 아래 표에서 모델의 사이즈를 늘릴 수록 성능이 좋아지는 것을 볼 수 있습니다.

bert_size1

이미지 출처 : BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding

논문의 저자들은 모델이 커지면 기계 번역이나 언어 모델과 같은 큰 사이즈의 태스크에 대해서는 이전부터 알려진 사실이지만, 크기가 작은 데이터셋에 대해서도 모델 사이즈를 키웠을 때 성능이 좋아짐을 확인할 수 있었던 사례임을 강조하고 있습니다.

두 번째로는 fine-tuning과 feature-based 방법에 대해 성능을 비교한 표입니다. fine-tuning은 pre-training 이후에 추가 학습 과정에서 단어 임베딩 벡터까지 업데이트 합니다. 하지만 feature-based은 단어 임베딩은 고정한 채 신경망 파라미터만 학습하는 방법입니다.

아래는 fine-tuning 과 다양한 feature-based 케이스에 대해서 모델의 성능을 비교한 표입니다. 실험 대상이 되는 모든 feature-based 모델보다 fine-tuning 모델이 좋은 성능을 보이는 것을 확인할 수 있습니다.

finetune_vs_feature

이미지 출처 : BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding

아래는 실험한 feature-based 케이스에 대해 결과를 잘 나타내주는 이미지입니다.

bert_fb

이미지 출처 : jalammar.github.io

Conclusion

BERT는 MLM과 NSP라는 특이한 pre-training 방법을 사용하여 이전 모델보다 월등한 성능을 보이며 SOTA를 달성하였습니다. 두 방법중 NSP는 이후 모델에서 삭제되기는 하지만 MLM은 문맥을 Bidirectional 학습할 수 있기 때문에 이후 모델에도 계속 사용됩니다. MLM 방법을 그대로 계승하여 BERT 개선하거나 경량화한 “RoBERTa, AlBERT…” 등이 나오게 되지요. BERT는 이러한 모델의 원조로서 자연어처리 분야의 큰 발전을 가져온 기념비적인 모델이라고 할 수 있습니다.

  1. Transformer의 Positional Encoding 과는 다른 방법으로 만들어집니다. 

Comment  Read more