연역적 프로그래밍과 귀납적 프로그래밍
이전 글: 기계학습과 코딩의 종말 #1 - 논리회로와 뉴럴넷
단순화해서 보면 프로그램은 어떤 입력에 대해 특정한 출력을 하는 함수라고 생각할 수 있습니다. 예를 들어 여러분이 이 글을 볼 때 사용하는 웹 브라우저는 마우스 클릭 등의 사용자 행동을 입력으로 받으면 그에 맞는 웹 페이지를 출력하는 함수입니다. 그리고 프로그래머는 프로그래밍 언어를 이용해서 해당 프로그램(함수)의 내부 로직을 구현하죠. 이것을 도식화하면 <그림 7>과 같습니다.
한편, 기계 학습을 이용한다면 방식이 조금 달라집니다. <그림 8>에 나와있듯이 '기계 학습 프로그램'이 있고 이 프로그램에 사용자가 입력값과 그 입력값을 통해 출력되기 원하는 결과값으로 이뤄진 '학습 데이터'를 입력하면 이 '기계 학습 프로그램'이 사용자가 원하는 '프로그램'을 생성합니다. 비록 기계 학습 분야에서는 기계 학습 프로그램이 만든 프로그램을 '프로그램'이라고 부르지 않고 '모델'이라고 부릅니다만 위 관점에서 보자면 기계 학습 모델은 일종의 프로그램(혹은 함수)입니다. 마치 기존에는 사람이 어떤 물건을 직접 만들었다면 요즘에는 '어떤 물건을 만드는 로봇'을 사람이 한번 만들고 나면 그 로봇이 '물건'을 계속해서 생산하는 것과 비슷합니다.
예를 들자면, 우리가 많이 사용하는 네이버나 구글 같은 웹 메일 서비스에서 흔히 제공하는 스팸 메일 분류기를 생각할 수 있습니다. 스팸 메일을 분류하기 위한 규칙을 정규 표현식 같은 방법을 써서 프로그래머가 직접 구현하는 방식이 <그림 7>과 같은 방식입니다(그리고 과거에는 실제 이런 식으로 분류기를 직접 개발하는 경우가 종종 있었습니다).
반면, 나이브 베이지안 알고리즘과 같은 기계 학습 알고리즘을 사용하여 스팸 분류를 위한 기계 학습 프로그램을 구현한 후 이 프로그램에 대량의 스팸 메일과 일반 메일 데이터를 입력하여 학습을 시키는 방식이 <그림 8>과 같은 방식입니다. 이 프로그램은 수많은 메일 데이터를 이용해서 스팸을 적절히 분류할 수 있는 '스팸 메일 분류 프로그램'를 생성합니다. 그러면 웹 메일 서비스 업체에서는 이 기계 학습 프로그램이 생성한 '스팸 분류 프로그램'을 이용하는 것이죠.
대개의 경우 실전에서는 <그림 7>과 <그림 8>의 방식을 혼용해서 사용합니다. 예를 들어 기계 학습이 만든 스팸 필터를 이용해서 1차 분류를 한 다음 좀 더 정교한 분류 규칙은 프로그래머가 추가로 구현하는 것이죠. 그러나 점점 기계 학습 기법이나 성능이 발전하고 학습에 사용할 수 있는 데이터가 많아지면서 후자의 역할은 줄어들고 있습니다. 마치 기존에는 기계가 사람의 노동력을 보조하는 수준이었다면 이제는 점점 로봇에 의해 완전 자동화되고 있는 것처럼요.
'8bit 뎃셈기' 란 말 그대로 0~255 까지의 범위를 갖는 두 개의 숫자를 더한 값을 출력하는 디지털 회로를 의미합니다. '덧셈'은 너무나 단순하고 기본적인 연산이고 보통은 어떤 프로그래밍 언어에서든 기본으로 제공되기 때문에 간과하기 쉽지만, 사실 이것 역시 두 개의 숫자를 입력하면 두 숫자의 합을 출력하는 '프로그램(혹은 함수)' 입니다. <그림 9>는 앞서 1편에서 소개한 논리 회로를 이용해 덧셈 연산을 구현한 도식입니다.
1편에서 소개했듯이 뉴럴 네트워크는 이론적으로 어떤 논리회로도 표현이 가능합니다. 따라서 적절히 학습만 한다면 <그림 9> 에 있는 덧셈 연산기 역시 뉴럴 네트워크를 이용해서 구현할 수 있습니다. 아래 코드는 H2O 라는 딥 러닝 라이브러리를 이용해서 덧셈 연산기를 구현하는 아주 간단한 R 코드입니다.
library(h2o)
h2o.init()
sample.size<-500000
x1<-sample(1:255, sample.size, replace=TRUE)
x2<-sample(1:255, sample.size, replace=TRUE)
y<-x1+x2
train<-data.frame(x1, x2, y)
adder<-h2o.deeplearning(x=1:2, y=3, training_frame=as.h2o(train), hidden = c(200, 200))
보다시피 위 코드에서는 두 개의 숫자를 더하는 작업을 하는데 필요한 로직을 구현하는 코드를 찾아 볼 수 없습니다. 단지 0~255 사이에서 임의로 선택한 두 수와 그 합으로 구성된 데이터 50만개를 '기계 학습 프로그램'의 입력값으로 넣을 뿐입니다. 그러면 '기계 학습 프로그램'은 출력으로 덧셈을 계산하는 '프로그램'(위 코드에서 'adder')을 반환합니다.
어떤 분들은 이런 단순한 연산은 어찌 가능할지 몰라도 우리가 일반적으로 사용하는 복잡한 프로그램을 어떻게 훈련만으로 만들 수 있겠느냐고 생각하실지 모릅니다. 그러나 앞서 1편에서도 언급했듯이 우리가 사용하는 바로 그런 프로그램들 역시 모듈 단위로 점점 세분화해 보면 이런 단순한 연산자들의 조합일 뿐입니다. 따라서 그럼 복잡한 로직을 흉내내는데 필요한 데이터와 훈련 시간만 충분하다면 그런 프로그램을 뉴럴 네트워크를 통해 학습하지 못할 이유는 없습니다.
다만 이것은 기존 관점에서 보면 '프로그래밍'이라고 부르기 힘듭니다. Wired 의 컬럼 제목인 'Soon we won't program computers. We'll train them like dogs' 에서 표현되어 있듯이 이것은 그저 우리가 개를 훈련시킬 때 원하는 행동을 계속 반복해서 가르치는 것과 비슷합니다. 기계 학습 프로그램에게 어떤 입력에 대한 출력을 반복해서 입력함으로써 원하는 로직을 학습시키는 것이죠.
더 나아가 <그림 7>과 <그림 8>의 '프로그램'을 만드는 방식에는 논리적으로도 매우 큰 차이가 있습니다. <그림 7>에서 프로그램 로직은 사람의 연역적 사고에 의해 만들어집니다. 일종의 우리가 알고리즘이라고 부르는, 다시 말해 'A 이면 B이고 B 이면 C 이니 A이면 C이다'와 같이 연역적으로 타당한 규칙의 흐름을 통해 로직을 구성합니다.
반면 <그림 8>의 프로그램은 다릅니다. 기계 학습은 수많은 데이터를 통해 귀납적으로 로직을 구성합니다. 기계 학습 알고리즘은 실제 사용자의 의도에 대한 이해를 바탕으로 로직을 구성하는 것이 아니라 학습 데이터 상에 나온 어떤 입력에 대해서 정답과 최대한 근사한 값이 나오는 것을 목표로 로직을 구성합니다. 비약하자면, 물리 법칙에 맞춰 정확한 설계대로 자동차를 만드는 것이 기존의 프로그래밍 방식이라면 기계 학습은 부품들을 쭉 늘어놓고 마구잡이로 부품을 끼워 맞춰서 어찌됐던 굴러가는 무언가를 만드는 방식입니다. 그게 우연히 기존 자동차와 동일한 설계를 가질 확률은 거의 0에 가깝지만 그렇다고 해서 0은 아닙니다. 게다가 설령 전혀 기존 자동차와 다른 형태를 지녔더라고 해도 일단 굴러간다는 목적만 충족한다면 크게 상관이 없을 수도 있죠.
그런데 바로 이런 점 때문에 (적어도 현재까지의) 기계 학습이 만드는 로직은 귀납법이 가진 한계를 똑같이 갖고 있습니다.
위에서 예로 든 덧셈기는 사실 실제 덧셈기와 동일한 로직을 갖고 있지는 않습니다. 아니 좀 더 정확히 말하면 동일한지 아닌지를 알 수 없습니다. 단지 테스트를 해 보니 실제 덧셈기와 동일한 결과를 출력하니 그런가 보다 하는 것이죠. 이건 마치 존 설이 제기한 '중국어 방' 문제를 연상케 합니다.
그런데 이 때문에 뉴럴 네트워크를 통해 구현한 덧셈기는 오류의 가능성을 내포하고 있습니다. 게다가 실제로도 그렇습니다. 위 덧셈기에 대해서 계속 덧셈 문제를 테스트해보면 약간의 오차가 발생합니다. 예를 들어 제가 아래와 같이 다양한 숫자에 대해서 1000번을 테스트를 해보면 기계 학습이 만든 덧셈기는 대략 0.5정도의 오차가 발생합니다.
test.size<-1000
x1<-sample(1:255, test.size, replace=TRUE)
x2<-sample(1:255, test.size, replace=TRUE)
y<-as.numeric(x1+x2)
test<-data.frame(x1, x2, y)
result<-as.data.frame(h2o.predict(adder.model, as.h2o(test)))
test$pred<-round(result$predict)
sqrt(sum((test$pred-test$y)^2)/nrow(test)
물론 이런 오차는 아마도 더 복잡한 뉴럴 네트워크를 구성하고 더 많은 데이터를 학습시킨다면 점차 줄어 들겠지만 완벽히 없앨 수 있다는 보장은 할 수 없습니다. 연역적으로 구성된 로직이라면 알고리즘에 대한 수학적 검증을 통해 알고리즘이 정확한지 여부를 알 수 있습니다. 반면 귀납적으로 구성된 로직은 모든 경우의 수를 테스트해 보지 않는한 정확성을 보장할 수 없습니다. 이 '모든 경우의 수'는 우리가 프로그램 QA를 할 때의 '모든 테스트 시나리오'와 다릅니다. QA에서 수행하는 테스트는 좀 더 추상화된 수준이지만 귀납적 로직에서의 '경우의 수'는 말 그대로 가능한 모든 경우의 입력 데이터를 말합니다. 따라서 현실적으로는 불가능에 가까운 수준입니다.
바로 이런 점 때문에 기계 학습은 '프로그래밍에 의해 만든 프로그램'을 비슷하게 흉내낼 수는 있지만 정확히 그 프로그램을 '훈련'시킬 수는 없습니다.
쓰고 나니 글이 불필요하게 길어진 것 같은데 짧게 요약하자면, 뉴럴 네트워크 기반의 기계 학습은 이론적으로 보면 기존의 프로그램을 그대로 표현할 수 있습니다. 하지만 기계 학습이 프로그램을 만드는 방식은 기존의 소프트웨어 개발과는 매우 다른 방식입니다. 어떤 복잡한 로직을 구현하기 위해 프로그래머가 머리를 싸맬 필요가 없고 단지 원하는 결과를 기계 학습이 제대로 구현할 때까지 반복해서 학습 시키기만 하면 될 뿐입니다. 이건 마치 프로그래머가 테스트 케이스를 먼저 만들어 놓고 이 테스트를 통과하도록 로직을 구현하는 TDD를 자동화하는 것과 비슷해 보이기도 합니다.
그렇다면 이제 정말 프로그래밍이 더 이상 필요없어질까요? 아쉽게도 (아니 다행스럽게도) 이런 방식에는 현재까지로 볼 때 6장에서 언급한 것과 같은 치명적인 한계가 있습니다(만약 없다면 이 세상의 프로그래머들은 대부분 필요가 없어지겠죠). 그리고 바로 이런 한계를 극복하는 것이 아마도 기계 학습의 과제 중 하나이지 않을까 싶습니다. 다만 제가 생각하기에 초기의 자동차도 형편없는 효율성을 갖고 있었지만 결국 말과 마차를 넘어섰듯이 기계 학습에 의한 프로그램 역시 현재의 프로그래밍을 상당 부분 대체할 수 있지 않을까 생각합니다.