이 글에서는 신경망의 기본 개념과 이를 Ruby로 구현하는 방법을 함께 알아보겠습니다. 인공지능과 딥러닝에 관심이 있지만 어디서부터 시작해야 할지 막막하셨다면, 이 글이 딱 좋은 출발점이 될 것입니다. 핵심 개념을 쉽게 이해할 수 있도록 간단한 예제를 통해 차근차근 살펴볼 예정입니다.
솔직히 말씀드리면, 실무에서 Ruby로 다층 신경망을 직접 작성하는 일은 드뭅니다. 하지만 코드가 단순하고 읽기 쉬워서, 신경망 내부에서 무슨 일이 일어나는지 이해하기에는 더없이 좋은 방법입니다. 먼저 한 걸음 물러나서, 우리가 어떻게 여기까지 왔는지 짧게 돌아보겠습니다.
AI의 시대, 영화에서 현실로
2014년에 개봉한 영화 『Ex Machina(엑스 마키나)』를 떠올려 볼까요? 구글에서 이 영화를 검색하면 장르가 "드라마/판타지"로 분류됩니다. 실제로 처음 영화를 봤을 때는 확실히 공상 과학 영화처럼 느껴졌습니다.
하지만 잠시 생각해 보세요. 인공지능이 인간을 능가하는 날은 정말 멀리 있을까요?
구글에서 활동하는 저명한 미래학자 레이 커즈와일(Ray Kurzweil)에게 묻는다면 답은 다를 수 있습니다. 그는 2029년경 인공지능이 유효한 튜링 테스트(사람이 기계와 사람을 구별할 수 있는지 확인하는 실험)를 통과할 것으로 예측합니다. 나아가 컴퓨터가 인간 지능을 초월하는 특이점(singularity)은 2045년에 도래할 것이라고 전망합니다.
그는 무엇을 근거로 이토록 확신하는 걸까요?
딥러닝의 등장
간단히 말해, 딥러닝(deep learning)은 신경망을 활용해 대량의 데이터에서 통찰을 추출하는 머신러닝의 한 분야입니다. 오늘날 딥러닝은 이미 우리 주변 곳곳에서 활용되고 있습니다.
- 자율주행 자동차
- 암 진단 및 의료 영상 분석
- 시리(Siri), 알렉사(Alexa) 같은 가상 비서
- 지진 같은 극단적인 기상 이변 예측
신경망이란 정확히 무엇일까요?
'신경망(neural network)'이라는 이름은 뉴런, 즉 전기적·화학적 신호를 통해 정보를 처리하고 전달하는 뇌 세포에서 유래했습니다. 재미있는 사실 하나: 인간의 뇌는 무려 800억 개 이상의 뉴런으로 이루어져 있습니다!
컴퓨팅 분야에서 신경망은 보통 다음과 같은 구조로 표현됩니다.
신경망 구조 예시 다이어그램
그림에서 보듯이 신경망은 크게 세 부분으로 구성됩니다.
- 입력층(Input Layer) — 초기 데이터가 들어오는 층
- 은닉층(Hidden Layer) — 1개 이상으로 구성되며, 모든 연산이 이루어지는 핵심 공간입니다.
- 출력층(Output Layer) — 최종 결과 또는 예측값을 내놓는 층
짧은 역사 이야기
신경망은 결코 새로운 개념이 아닙니다. 사실 최초의 학습 가능한 신경망인 '퍼셉트론(Perceptron)'은 1950년대 코넬대학교에서 개발되었습니다. 하지만 당시에는 신경망의 실용성에 대한 회의론이 만연했는데, 주된 이유는 초기 모델이 은닉층을 단 하나만 가지고 있었기 때문입니다. 1969년 출판된 한 저서는 퍼셉트론으로 비교적 단순한 연산조차 처리하기 어렵다는 점을 지적하며 신경망 연구의 침체기를 불러왔습니다.
그렇다면 신경망은 어떻게 부활했을까요? 그 주역은 바로 컴퓨터 게임입니다. 게임 산업의 발전은 고성능 GPU(그래픽 처리 장치)에 대한 수요를 폭발적으로 증가시켰고, GPU의 아키텍처는 놀랍도록 신경망의 구조와 유사했습니다. 차이점이라면 은닉층의 개수 정도입니다. 과거의 단일 은닉층과 달리, 오늘날 학습되는 신경망은 10개, 15개, 심지어 50개 이상의 층을 사용합니다!
실습 시간! Ruby로 신경망 만들기
이제 실제 동작 원리를 이해하기 위해 예제를 살펴보겠습니다. 먼저 ruby-fann 젬(gem)을 설치해야 합니다. 터미널을 열고 작업 디렉터리로 이동한 뒤 다음 명령어를 실행하세요.
gem install ruby-fann
새로운 Ruby 파일을 생성합니다(여기서는 neural-net.rb라고 이름 붙였습니다).
1단계: 데이터 준비하기
이번 예제에서는 Kaggle의 'Student Alcohol Consumption' 데이터셋을 사용합니다. 파일을 내려받은 후 student-mat.csv 파일을 Google 스프레드시트(또는 선호하는 편집기)로 열어 다음 세 컬럼만 남기고 나머지는 모두 삭제하세요.
- Dalc: 평일 음주량 (1 = 매우 낮음, 5 = 매우 높음)
- Walc: 주말 음주량 (1 = 매우 낮음, 5 = 매우 높음)
- G3: 기말 성적 (0~20점)
ruby-fann 젬이 동작하려면 기말 성적 컬럼을 0 또는 1의 이진 값으로 변환해야 합니다. 이번 예제에서는 10점 이하를 "0", 10점 초과를 "1"로 가정하겠습니다. 사용 중인 프로그램에 따라 셀에 수식을 입력해 값을 자동 변환할 수 있습니다. Google 스프레드시트에서는 다음과 같습니다.
=IF(C3 >= 10, 1, 0)
이 데이터를 Ruby 파일과 같은 디렉터리에 CSV 파일로 저장합니다(여기서는 students.csv로 저장했습니다).
우리가 만들 신경망은 다음과 같은 구조를 가집니다.
- 입력층: 2개 노드 (평일 음주량, 주말 음주량)
- 은닉층: 6개 은닉 노드 (시작값으로 적절한 수준이며, 테스트하면서 조정할 수 있습니다)
- 출력층: 1개 노드 (0 또는 1)
2단계: 코드 작성하기
먼저 ruby-fann 젬과 Ruby에 내장된 csv 라이브러리를 불러와야 합니다. 프로그램 첫 줄에 다음을 추가하세요.
require 'ruby-fann'
require 'csv'
다음으로 CSV 파일의 데이터를 배열로 불러옵니다.
# 빈 배열 두 개를 생성합니다. 하나는 독립변수(x_data), 다른 하나는 종속변수(y_data)를 담습니다.
x_data = []
y_data = []
# CSV 데이터를 순회하며 해당 배열에 요소를 추가합니다.
# 참고: .to_f와 .to_i를 붙이지 않으면 배열에 문자열이 담겨 ruby-fann 라이브러리가 정상 동작하지 않습니다.
CSV.foreach("students.csv", headers: false) do |row|
x_data.push([row[0].to_f, row[1].to_f])
y_data.push(row[2].to_i)
end
이제 데이터를 학습용(training)과 테스트용(testing)으로 나눠야 합니다. 일반적으로 80/20 비율이 널리 쓰이며, 데이터의 20%는 테스트에, 80%는 학습에 사용합니다. 여기서 '학습'이란 모델이 해당 데이터를 기반으로 패턴을 익히는 것을 의미하고, 이후 '테스트' 데이터로 모델의 예측 성능을 평가하게 됩니다.
# 데이터를 학습 세트와 테스트 세트로 분할합니다.
testing_percentage = 20.0
# 전체 요소 수에 테스트 비율을 곱합니다.
testing_size = x_data.size * (testing_percentage/100.to_f)
# 배열은 0부터 인덱싱되므로 testing_size - 1까지 잘라냅니다.
x_test_data = x_data[0 .. (testing_size-1)]
y_test_data = y_data[0 .. (testing_size-1)]
# 남은 구간부터 데이터셋 끝까지 가져옵니다.
x_train_data = x_data[testing_size .. x_data.size]
y_train_data = y_data[testing_size .. y_data.size]
좋습니다! 데이터가 준비되었으니 이제 마법 같은 부분으로 넘어가겠습니다.
# 학습 데이터 모델을 설정합니다.
train = RubyFann::TrainData.new(:inputs=> x_train_data, :desired_outputs=>y_train_data)
RubyFann::TrainData 객체에 평일·주말 음주량을 담은 x_train_data와, 기말 성적에 따른 0 또는 1의 값인 y_train_data를 전달합니다.
이제 앞서 논의한 은닉 뉴런 수를 반영해 실제 신경망 모델을 설정합니다.
# 모델을 설정하고 학습 데이터로 훈련합니다.
model = RubyFann::Standard.new(
num_inputs: 2,
hidden_neurons: [6],
num_outputs: 1 );
자, 이제 학습을 시작할 시간입니다!
model.train_on_data(train, 1000, 10, 0.01)
여기서 앞서 생성한 train 변수를 전달합니다. 첫 번째 숫자 1000은 최대 에포크(max_epochs) 수, 두 번째 숫자 10은 리포트 간 에러 출력 횟수, 마지막 0.01은 목표 평균 제곱 오차(mean-squared error)입니다. 에포크(epoch)란 전체 데이터셋이 신경망을 한 번 통과하는 단위를 말하며, 평균 제곱 오차는 우리가 최소화하고자 하는 값입니다.
3단계: 모델 성능 평가하기
다음으로, 모델이 테스트 데이터에 대해 예측한 값과 실제 결과를 비교해 성능이 얼마나 좋은지 확인해 보겠습니다. 다음 코드로 구현할 수 있습니다.
predicted = []
# x_test_data를 순회하며 각 항목에 모델을 실행하고 결과를 predicted 배열에 추가합니다.
x_test_data.each do |params|
predicted.push( model.run(params).map{ |e| e.round } )
end
# 예측 결과와 실제 결과를 비교합니다.
correct = predicted.collect.with_index { |e,i| (e == y_test_data[i]) ? 1 : 0 }.inject{ |sum,e| sum+e }
# 정확도를 출력합니다.
puts "Accuracy: #{((correct.to_f / testing_size) * 100).round(2)}% - test set of size #{testing_percentage}%"
프로그램을 실행해 결과를 확인해 보겠습니다.
ruby neural-net.rb
에포크 관련 출력이 많이 보이겠지만, 맨 아래에 다음과 비슷한 결과가 나타납니다.
Accuracy: 56.82% - test set of size 20.0%
으악, 그다지 좋지 않네요! 하지만 직접 만든 데이터 포인트로 모델을 실행해 보겠습니다.
prediction = model.run( [1, 1] )
# 출력값을 반올림해 예측 결과를 얻습니다.
puts "Algorithm predicted class: #{prediction.map{ |e| e.round }}"
prediction_two = model.run( [5, 4] )
# 출력값을 반올림해 예측 결과를 얻습니다.
puts "Algorithm predicted class: #{prediction_two.map{ |e| e.round }}"
여기서 두 가지 예를 들어 보았습니다. 첫 번째는 평일·주말 음주량으로 1을 입력한 경우입니다. 베팅을 해야 한다면 이 학생의 기말 성적이 10점을 넘을 것(즉, 1)이라고 예상할 수 있습니다. 두 번째는 음주량으로 높은 값(5와 4)을 입력한 경우로, 이 학생의 기말 성적이 10점 이하(즉, 0)일 것으로 예상됩니다. 프로그램을 다시 실행해 결과를 확인해 봅시다.
출력은 다음과 비슷하게 나타납니다.
Algorithm predicted class: [1]
Algorithm predicted class: [0]
모델은 스펙트럼의 양극단에 있는 숫자에 대해서는 기대한 대로 동작하는 것으로 보입니다. 하지만 값이 서로 반대인 경우(1과 5, 또는 2와 3처럼 다양한 조합을 직접 시도해 보세요)나 중간값에서는 고전하는 모습을 보입니다. 에포크 데이터에서도 오차는 감소하지만 여전히 높은 수준(20% 중반)에 머무는 것을 확인할 수 있습니다. 이것이 의미하는 바는, 음주량과 과목 성적 사이에 뚜렷한 상관관계가 없을 수 있다는 점입니다. Kaggle의 원본 데이터셋을 직접 탐색해 보세요. 과목 성적을 예측하는 데 활용할 수 있는 다른 독립변수는 없을까요?
마무리하며
이 모든 것이 동작하기 위해서는 내부적으로 수많은 복잡한 과정(대부분 수학과 관련된)이 숨어 있습니다. 더 깊이 알고 싶으시다면 FANN 공식 문서를 살펴보거나 ruby-fann 젬의 소스 코드를 직접 읽어보시길 강력히 추천합니다. 또한 넷플릭스 다큐멘터리 『AlphaGo』도 놓치지 마세요. 깊은 기술 지식 없이도 즐길 수 있으며, 딥러닝이 컴퓨터의 한계를 어떻게 넓혀가고 있는지 생생하게 보여주는 실제 사례입니다.
커즈와일의 예측은 결국 맞을까요? 시간만이 답을 줄 것입니다!