신경망 알고리즘은 익숙하게 사용하는 사람들에게조차도 일종의 블랙박스처럼 느껴진다. 특히 이 알고리즘에 내재된 확률적 특성으로 인해 내부적으로 어떻게 동작하는지 파악하기가 더욱 어렵다.
이 장에서는 TDD를 이용한 간단한 신경망 알고리즘을 구현하고, 이를 통해 TDD 스타일의 머신 러닝 알고리즘이 어떤 것인지 학습한다. 이를 통해 이런 알고리즘이 어떻게 동작하는지 더욱 깊이 있게 이해할 수 있을 것이다.
이 장에서 다룰 내용은 다음과 같다.
- 가능한 한 단순한 형태의 퍼셉트론 모델 개발
- 스프레드시트를 이용해 테스트와 재구성이 가능한 사례 개발
- TDD를 이용한 첫 번째 머신 러닝 알고리즘 개발
- 데이터를 이용한 알고리즘 테스팅
이 장을 학습하기 위해서는 마이크로소프트, 리브레 오피스(Libre Office), 구글 독스(Google Docs) 같은 스프레드시트 프로그램이 필요하다. 이 외에 다른 스프레드시트 제품도 좋다.
시작
퍼셉트론은 일종의 바이너리 선형 분류분석기(데이터를 2개의 그룹으로 분류하는 알고리즘)라고 할 수 있다. 여러 가지 지도형 학습기법과 마찬가지로, 적절한 분류분석 알고리즘에 데이터를 순서대로 입력 한다. 충분히 데이터를 입력하면, 퍼셉트론은 분류되지 않읕 새로운 데이터에 클래스 레이블을 붙이기 시작한다. 특히, 퍼셉트론은 (선형 분류분석기를 이용해) 데이터를 2개의 그룹으로 가능한 한 정확하게 나눌 수 있는 하이퍼플레인을 만드는 형태로 동작한다. 좀 더 쉽게 설명하면 임의의 공간상에 점들이 있다고 가정했을 때, 어떤 분류분석 모델이 가장 잘 맞는지 결정할 때까지 (공간상에서) 어떤 라인을 계속 움직여 보는 것이다.
이 결과를 시각화하려면, 2차원 데이터를 선(곡선, 직선)으로 생각하면 된다.
우리는 아주 큰 문제를 가능한 만큼 세분화시키는 예제의 알고리즘 개발에 TDD를 사용할 계획이다. 나머지 장에서는 기타 구현사항에 필요한 외부 라이브러리에 대해 배우고, 단계적 진행 형태로 머신 러닝 알고리즘을 실행시키는 방법에 대해 알아본다.
전체 알고리즘 개발을 전제로 하지 않고 퍼셉트론을 만들려고 하면, 어디서부터 시작해야 할지 매우 막막할 것이다. 일단 아주 단순한 시나리오를 가지고 시작해보자. 당연히 제대로 동작하지 않을 시나리오로 말이다. 테스트라는 건 시작하는것 자체만으로도 가치 있는 일이다. 다음 코드를 이용해 시작해보자.
def no_training_data_supplied_test():
the_perceptron = Perceptron()
result = the_perceptron.predict([])
nt.assert_equal(result, None, 'Should have no result with no training data.')
이 테스트를 통과시키는 건 무척 간단하다.
class Perceptron:
def predict(self, input):
return None
이보다 더 간단할 수는 없을 것이다. 다음 단계로 가장 간단한 문제를 이용해 퍼셉트론을 학습시켜보자.
우리가 수학식 계산에 테스트 주도 기법을 사용하고 있는 게 아니라는 점을 꼭 기억하기 바란다. 코드 설계에 필요한 알고리즘 지식과 테스트를 통과하는 데 어떤 방법을 사용하는지만 알면 충분하다. 이 경우, 이전 단계로 돌아가서 퍼셉트론 모델에 숨어 있는 수식을 재계산할 수도 있고, 간단한 컴포넌트를 가장 단순한 형태로 더 상세하게 쪼개는 방안을 알아봐야 할 수도 있다.
한 가지 방법은 스프레드시트를 이용해 수동으로 계산식을 수행해서 한 줄씩 계산 결과를 따라가도록 하는 것이다. 앞으로 소개할 예제를 통해 쉽게 할 수 있을 것이다.
스프레드시트의 각 행은 퍼셉트론을 위한 학습 과정에서 단계별로 수행된다. Weights1과 Weights2 열에 임의의 작은 수를 채워넣는다. Weight 1 update와 Weight 2 update는 학습 데이터를 고려해 예상한 가중치다. 수학적으로 표현하면, 다음처럼 형태로 나타낼 수 있다(마슬랜드(Marsland)의 'Machine Learning: An Algorithmic Perspective' 참고)
- w(i+1, j) = w(i, j) + n*w(i, j)*( t(j) - p(j) )
이 수식을 일반적인 표현으로 설명하면 다음과 같다. 업데이트된 가중치( w(i+1, j) )는 현재 가중치에 3개의 변수(학습 비율(n), 현재의 가중치( w(i, j) ), 학습 값( t(j) )과 현재 이미 학습된 예측치( p(j) )의 차이)를 곱해 얻은 값을 더한 것과 같다. 엑셀에서 통용되는 식으로 설명하면 다음과 같다.
각 행에 있는 가중치는 바로 위의 행에 있는 가중치가 업데이트되면 그에 맞추어 업데이트된다. 이런 식으로 각 행은 퍼셉트론의 가중치를 지속적으로 조정해 나간다.
이 값의 예측치는 (맨 끝에 있는 열에서 계산되는데) 수학적으로 다음과 같이 표현할 수 있다.
- p(j) = ∑( w(i) * x(i) ) > 0
여기서, 조건에 대한 결과가 맞았다면 1, 틀렸다면 0으로 나타날 것이다. 좀 더 현실적으로 스프레드시트의 경우, 이는 다음과 같이 설명할 수 있다.
반복문 실행은 스프레드시트상에서 행을 반복시키기만 하면 된다. 이 부분을 좀 더 복잡하게 생각하면 다음과 같이 할 수 있다. 이 시나리오는 학습 데이터를 통해 한 번만 통과하면 되게끔 되어 있다. 이것은 결국 학습 데이터에서 첫 번째 입력값만 필요로 한다는 뜻인데, 가중치가 업데이트하는 데 맞추어 각 행들의 값이 바뀔 것 같지 않기 때문이다. 테스트를 좀 더 단순화하기 위해 이러한 점을 이용할 수 있다.
이 시나리오에 맞추어 다음과 같이 테스트를 작성해보자.
def train_an_OR_function_test():
the_perceptron = Perceptron([1,1]),1)
the_perceptron = Perceptron([1,0]),1)
the_perceptron = Perceptron([0,1]),1)
the_perceptron = Perceptron([0,0]),0)
nt.assert_equal(the_perceptron.predict([1,1]), 1)
nt.assert_equal(the_perceptron.predict([1,0]), 1)
nt.assert_equal(the_perceptron.predict([0,1]), 1)
nt.assert_equal(the_perceptron.predict([0,0]), 0)
이 문제를 해결하려면 우선 엑셀 파일을 통해 알아낸 것을 바로 적용시키는 작업부터 해야 한다. 다음과 같이 해보자.
class Perceptron:
def __init__(self):
self.weight_1= 0.20
self.weight_2= 0.20
def train(self, inputs, labels):
input= inputs[0]
self._weight_1 = self._weight_1 + .25 * (input[0]-label[0]) * self.predict(input)
self._weight_2 = self._weight_2 + .25 * (input[1]-label[0]) * self.predict(input)
def predict(self, input):
if len(input) == 0:
return None
return 0 < self._weight_1 * input[0] + self._weight_2 * input[1]
뭔가 느껴지는 게 있는가? 테스트를 통과는 하지만 코드가 깔끔하진 않다. 간결하게 고칠 수도 있는데 말이다. 사실, 다음처럼 코드 대부분을 삭제하고도 테스트를 통과시킬 수 있다.
class Perceptron:
def __init__(self):
self.weight_1= 0.20
self.weight_2= 0.20
def train(self, inputs, labels):
pass
def predict(self, input):
if len(input) == 0:
return None
return 0 < self._weight_1 * input[0] + self._weight_2 * input[1]
이 버전도 테스트를 성공적으로 통과한다. 훨씬 간결하면서도 실제로 예측값 계산만 하게끔 구현되었다. 우리가 만든 시나리오에서 학습 과정은 별로 중요하지 않다. 학습 과정은 테스트 과정에 포함되어 있지만, 구현되어 있지 않기 때문에 테스트를 통과하려면 궇션을 해야 하는 부담이 있다. 퍼셉트론에 학습용 입력이 필요할 것으로 보이므로, 메소드가 불필요하다고 해도 지금은 이 상태로 그냥 두기로 하자.
다음으로, 새로운 시나리오를 구현해보자. 다시 말하면, 우리가 알고리즘을 이해한 대로 새로운 케이스를 선택할 것이다. 스프레드시트를 사용 중이면, 단계적으로 복잡도를 높일 수 있도록 간단하고 단순한 시나리오를 생각해보기 바란다. 출력값 두 개 중 최소 하나 이상이 0보다 크면 반응을 보이는 퍼셉트론을 만들어보자. 이에 대해 스프레드시트를 이용해 만든 예제는 다음과 같다.
스프레드시트는 앞에서 봤던 수식을 이용해 제작되었다. 첫 번째 행은 초기값 설정을 처리하고 아래로 이어지는 각 행은 바로 윗 행에서 업데이트된 가중치 값을 참조하도록 되어 있다. 스프레드시트상에서 A열 ~ D열은 루프 계산용 입력값으로 사용되는데, 실행될 때마다 하나의 행 단위(예를 들면 A4, B4, C4, D4를 하나로 묶은 형태)로 입력받는다. 이렇게 하는 것이 루프를 실행시키는 가장 쉬운 방법이다.
예제 시나리오가 있으니, 다음 단계 테스트 작성에 이것을 사용하기로 하자. 이 예제는 약간 이상해 보일 수도 있는데, 더미 변수를 사용하고 있고 정확한 솔루션에 근접한 답을 얻기 위해 여러 번 반복 실행을 해야 하는 특성을 지니고 있기 때문이다. 더미 변수를 사용하면 분류기가 가운데 지점이 0 근처에 있지 않도록 강제로 설정할 수 있다. 즉, '맞았다'와 '틀렸다' 사이에 있는 구분선의 위치를 조정할 수 있다.
분석 모델을 위해 이미 2가지 입력값을 추가로 적용했으므로, 테스트에 더미 변수를 포함시킨다. 이와 같이 해 반복문이 제대로 돌아가독록 하는 데만 집중할 수 있게 되었다. 일단 상태가 안정적이면 앞으로도 더미 변수를 기본값으로 포함시켜서 리팩토링 작업을 해나갈 계획이다. 이들을 따로 나눈 이유는 업데이트 결과로 인해 다른 테스트가 실패할 경우, 2가지 원인을 한꺼번에 고민하는 대신 해당 실행 시점에 어떤 테스트가 실패했는지를 알 수 있도록 해주기 때문이다.
스프레드시트 시나리오를 통해 만든 테스트 케이스는 다음과 같다.
def detect_values_greater_than_five_test():
the_perceptron = Perceptron()
the_perceptron.train([
[ 5, -1],
[ 2, -1],
[ 0, -1],
[-2, -1],
],
[1,0,0,0])
nt.assert_equal(the_perceptron.predict([ 8, -1]), 1)
nt.assert_equal(the_perceptron.predict([ 5, -1]), 1)
nt.assert_equal(the_perceptron.predict([ 2, -1]), 0)
nt.assert_equal(the_perceptron.predict([ 0, -1]), 0)
nt.assert_equal(the_perceptron.predict([-2, -1]), 0)
다음 코드를 이용해 테스트를 통과시킬 수 있다.
class Perceptron:
def __init__(self):
self.weight_1= 0.431
self.weight_2= 0.02
def train(self, inputs, labels):
for _ in range(4):
for input, label in zip(inputs, labels):
label_delta = (label - self.predict(input))
self._weight_1 = self._weight_1 + .1 * input[0] * label_delta
self._weight_2 = self._weight_2 + .1 * input[1] * label_delta
def predict(self, input):
if len(input) == 0:
return None
return int(0 < self._weight_1 * input[0] + self._weight_2 * input[1])
생성자 메소드를 자세히 보면, 가중치가 모두 다르게 되어 있다. 이렇게 하면 이 코드가 스프레드시트 예제와 일치하게 되고, 이에 따라 시나리오와 동일하게 동작하게 된다. 결국 가중치에 대해 반복 횟수를 적절하게 정하면 그만큼 가중치 값을 업데이트시킬 수 있게 된다.
리팩토링 관점에서 보면 몇 가지 해야 할 것들이 있다. 이들 중 가장 중요한 사항은 가중치 변수에 대한 이름을 생성하거나, 가중치 변수 각각에 대한 입력값을 처리할 필요가 없도록 하는 것이다. 이 부분을 리팩토링해 입력값의 개수에 대한 제약을 없앤다.
이 테스트에 대한 리팩토링의 첫 번째 단계로, 배열 형태로 되어 있는 가중치 값을 보자.
class Perceptron:
def __init__(self):
self.weights= [0.431, 0.02]
def train(self, train, labels):
for _ in range(4):
for input, label in zip(inputs, labels):
label_delta = (label - self.predict(input))
self._weight_1 = self._weight_1 + .1 * input[0] * label_delta
self._weight_2 = self._weight_2 + .1 * input[1] * label_delta
def predict(self, input):
if len(input) == 0:
return None
return int(0 < self._weight_1 * input[0] + self._weight_2 * input[1])
테스트를 재실행해 성공적으로 통과하는지 확인하기 바란다. 문제없이 잘 되었다면, N개의 입력 변수를 다룰 수 있도록 리팩토링할 준비가 된 것이다. 부가적으로 리팩토링 과정에서 += 연산을 활용해서 다음 예제와 같이 코드를 좀 더 간결하게 다듬어 놓는다.
class Perceptron:
def __init__(self):
self.weights= [0.431, 0.02]
def train(self, train, labels):
for _ in range(4):
for input, label in zip(inputs, labels):
label_delta = (label - self.predict(input))
self._weights[index] += .1 * x * label_delta
def predict(self, input):
if len(input) == 0:
return None
return int(0 < self._weight_1 * input[0] + self._weight_2 * input[1])
자, 이제 초기 가중치 설정값을 train 함수로 옮긴다. 이렇게 하면, 입력 변수의 개수에 따라 자동으로 가중치를 생성할 수 있다. 코드의 최종 모습은 다음과 같다.
class Perceptron:
def train(self, train, labels):
self._weights= [0.431, 0.02]
for _ in range(4):
for input, label in zip(inputs, labels):
label_delta = (label - self.predict(input))
for index, x in enumerate(input):
self._weights[index] += .1 * x * label_delta
def predict(self, input):
if len(input) == 0:
return None
return int(0 < self._weight_1 * input[0] + self._weight_2 * input[1])
다음으로 더미 값인 -1을 퍼셉트론에 넣도록 할 것이다. 이를 위해, 0보다 작은 값을 제거하면 종료하도록 테스트를 수정할 것이다. 그 다음 퍼셉트론 클래스 안에 0보다 작은 값을 넣고 테스트를 통과하는지 확인한다.
def detect_values_greater_than_five_test():
the_perceptron = Perceptron()
the_perceptron.train([
[ 5, -1],
[ 2, -1],
[ 0, -1],
[-2, -1],
],
[1,0,0,0])
nt.assert_equal(the_perceptron.predict([ 8]), 1)
nt.assert_equal(the_perceptron.predict([ 5]), 1)
nt.assert_equal(the_perceptron.predict([ 2]), 0)
nt.assert_equal(the_perceptron.predict([ 0]), 0)
nt.assert_equal(the_perceptron.predict([-2]), 0)
이제 테스트를 실행시키면 list index out of range라는 에러 메시지가 나올 것이다. 아직 predict 함수의 가중치 값이 하드코딩 상태로 남아 있기 때문이다. 따라서 이 부분을 우선 수정하자. 다음처럼 predict 함수를 리팩토링한다.
def predict(self, input):
if len(input) == 0:
return None
weight_input_pairings = zip(self.weights, input)
weight_input_products = [x[0]*x[1] for x in weight_input_pairings]
input = input + [-1]
return int(0 < sum(weight_input_products))
테스트를 재실행시키면 실패하는 결과가 나올텐데, 그럴 만한 이유가 있다. 이 테스트가 완벽하게 통과되도록 해보자.
class Perceptron:
def train(self, train, labels):
self._weights = [0.431, 0.02, 0.2]
dummied_inputs = [x + [-1] for x in inputs]
for _ in range(4):
for input, label in zip(inputs, labels):
label_delta = (label - self.predict(input))
for index, x in enumerate(input):
self._weights[index] += .1 * x * label_delta
def predict(self, input):
if len(input) == 0:
return None
input = input + [-1]
return int(0 < self._weight_1 * input[0] + self._weight_2 * input[1])
입력값을 이용해 가중치를 자동으로 생성시킨다. 신경망 알고리즘 관련 자료를 보면 가중치를 임의로 생성하도록 되어 있지만, 예기치 않은 문제가 일어날 수도 있으니 일단 이렇게 두기로 하자.
이번 테스트는 코드가 조금 길다. 임의의 데이터를 생성해 퍼셉트론을 학습한다. 다음 데이터를 추가 생성해 테스트를 수행한다. 단위 테스트에서 퍼셉트론의 정상 작동 여부를 판단하는 최소 기준치에 대해 어서션을 반영시킨다.
import numpy as np
def detect_a_complicated_example_test():
# Create random variables
training_n = 100
inputs = list(map(list, zip(np.random.uniform(0,100,training_n),
np.random.uniform(0,100,training_n),
np.random.uniform(0,100,training_n))))
labels = [int(x[0] + x[1] + x[2] < 150) for x in inputs]
the_perceptron = Perceptron()
the_perceptron.train(inputs, labels)
testing_n = 2500
test_inputs = list(map(list, zip(np.random.uniform(0,100,testing_n),
np.random.uniform(0,100,testing_n),
np.random.uniform(0,100,testing_n))))
test_labels = [int(x[0] + x[1] + x[2] < 150) for x in test_inputs]
# Create separate test cases
correctly_classified = 0
total_classified = 0
for input, label in zip(test_inputs, test_labels):
prediction = the_perceptron.predict(input)
total_classified += 1
if prediction == 1:
if label == 1:
correctly_classified += 1
else:
if label == 0:
correctly_classified += 1
# Make sure we generated as much data as we'd expect
nt.assert_equal(total_classified, testing_n)
assert correctly_classified >= .9*testing_n, \
"Perceptron should be much better than random. {0} correct".format(correctly_classified)
이 테스트를 처음 실행시키는 데 몇가지 문제점이 있다. 첫 번째는 predict 함수가 여전히 특정 조건에서만 동작한다는 점이다. 이는 우리가 3차원(변수 3개짜리) 문제를 다루도록 했기 때문이다. 만약 이 부분에 대해 제대로 일반화가 되면, 새로운 변수에 대해 걱정할 필요가 없을 것이다. 따라서 이 부분을 우선 바꿔보자. predict 메소드를 다음과 같이 수정한다.
def predict(self, input):
if len(input) == 0:
return None
input = input + [-1]
return int(0 < sum([x[0]*x[1] for x in zip(self._weights, input)]))
이제 이 테스트를 다시 실행시키면, 다음 그림처럼 에러 메시지가 바뀌어 있을 것이다.
우리가 만든 퍼셉트론 분석 모델은 전체 2,500개의 입력 데이터 중 1,367개만 제대로 분류했다. 이건 (동전 던지기처럼) 아무렇게나 분류한 결과와 사실상 별 차이가 없다. 우선 우리가 여태까지 다루지 않았던 이론적 측면에 대해 생각해봐야 할 것 같다. 테스트하기 전에 우리가 알아본 것 중 하나는 학습 반복 횟수였다. 이제 퍼셉트론의 학습 반복 횟수만 4배로 늘려서 실행시킨다. 이렇게 하면, 실행 시간을 적게 들이면서도 결과가 어떻게 다른지 실험해볼 수 있다. 다음 그래프는 학습 반복 횟수에 따른 퍼셉트론 모델의 정확도가 얼마나 향상되는지를 보여준다.
위의 그래프를 통해 무작위로 얻은 값보다 나은 결과를 얻으려면 대략 2,500번 정도는 학습을 반복시켜야 함을 알 수 있다.
요약
이 장에서는 여러 단계를 거쳐 TDD를 이용한 신경망(퍼셉트론) 알고리즘을 개발 했다. 이를 위해 우선 스프레드시트에 시나리오를 구체적으로 작성했다. 스프레드시트를 사용하면 퍼셉트론에서 필요한 여러 가지 계산을 따로 할 수 있다. 또한 수학적인 증명 없이 TDD를 사용할 수 있음을 알아보았다. 즉, 퍼센트론의 성능을 지속적으로 향상시키는 데 필요한 수식을 구현하는 데만 TDD를 사용했다. 이를 통해 퍼셉트론이 어떻게 동작하는지를 더 깊이 있게 이해할 수 있었으면 한다.
다음 장에서는 머신 러닝에 대한 수학적 해결 방법을 알아볼 것이다. 좀 더 구체적으로 설명하면 고객에게 제시할 여러 가지 옵션이 있고, 어느 것이 가장 수익이 좋을지 모르는 상태에서 수익을 극대화하는 솔루션을 개발하려고 한다.
'Fundamentals & Beyond > 테스트 주도 머신러닝' 카테고리의 다른 글
| 4. 회귀분석을 이용한 예측 (0) | 2026.07.22 |
|---|---|
| 3. Multi-armed bandit 알고리즘을 이용한 문제 해결 (0) | 2026.07.22 |
| 1. 테스트 주도 머신 러닝의 소개 (0) | 2026.07.20 |
| 들어가며 (0) | 2026.07.19 |