2026년 5월 12일 화요일

[내 신경망 만들기 (Making My Own Neural Network)/1부] 신경망 기초 이론

내 신경망 만들기(Making My Own Neural Network), 1부

이글의 제목은 다분히 "내 칩 제작 서비스"에서 따왔다.

목차:
1. 시작하며
2. 단위 환산기
3. 직선 한개로는 부족하다
4. 뇌 신경 세포의 결합을 모사한 "인공 신경망"
5. 오류의 역전파
6. 연결 가중치 보정 전략
7. 끝으로...
----------------------------------------------------------------------------------------------
[참고서] Make Your Own Neural Networks, Tariq Rashid [book][검색링크]
----------------------------------------------------------------------------------------------

1. 시작하며

바야흐로 "인공 지능"의 시대다. 일상 다반사가 된 "인공지능"의 기초는 "신경망"이라는데 도데체 그게 뭔가 싶어 찾아보면 알듯 모를듯 하다. 수많은 AI를 내세운 동영상 강좌가 넘쳐난다. 기초라고 하지만 끝까지 시청하기 어렵다. 수식 자랑을 늘어 놓는 탓이다. 그러다가 이 책을 발견 했다.

  
Make Your Own Neural Network                              신경망 첫걸음

한글 번역판도 있다. 이 책의 부제 "수포자도 이해하는 신경망 동작 원리와 딥러닝 기초"라는 문구에 동의한다. 다만 '수포자'의 입문서라기에는 분량이 만만치 않다. 그래서 요약글을 준비했다. 원서의 제목대로 "내 신경망(My Own Neural Network)" 제작이 목표다. 이 책은 1부에서 신경망의 작동 원리를 곱셈과 덧셈 만으로 설명한다. 약간의 고등 수학 '처럼' 보이는 부분이 가미되어 있지만 1차 방정식과 인수분해 만으로도 충분히 이해할만 한 수준이다. 2부는 '파이썬(Python)'으로 내 신경망을 제작한다. "DIY with Python" 라니 장 제목부터 남다르다. '파이썬'이라는 컴퓨팅 언어를 모르는 입문자를 배려하여 "아주 부드럽게 시작(A Very Gentle Start with Python)"한다. 1부가 '수포자' 였다면 2부는 '컴포자(컴퓨팅 언어를 포기한 자)'를 대상으로 쓰였다고 해줄 만 하다. 텐서플로우니 파이토치니 하는 매우 추상적인 패키지(라이브러리)들을 사용하지 않고도 MNIST 라는 손글씨 숫자 영상 인식을 수행하는 "내 신경망"을 충분히 코딩하고 실행할 수 있음을 보여준다. 가장 기본적인 numpy, matplot 만 사용한다. 사설이 길었다. 시작해 보자.

2. 단위 환산기

비례식은 들어봤을 것이다. 예를 들어 거리의 단위로 마일(mile)과 키로미터(km)가 있다. 이 둘 사이의 환산 공식을 아는가? 모른다고 치자. 다행히 두 거리 단위의 관계는 비례한다.

    Mile = A * Kilometer

마일에 숫자(비례 상수 A)를 곱하면 킬로미터가 된다는 의미다. 두 단위의 관계를 그래프로 표현하면 다음과 같다. 단위 환산 그래프다. 가로축에 킬로미터 세로축이 마일이다.


'무지렁이' 기계한테 '비례 관계'라는 힌트 만 주고 비례상수 A 를 구하라고 시켜보자. 기계는 아무렇게 직선을 주욱 긋는다. 이 직선의 기울기가 상수 W 다. 제대로 그었는지 확인하기 위해 한 검증치를 제시한다.

    37mile -> 59.6Km

똑똑한 인간은 단번에 계산할 수 있겠지만 기계는 아무것도 모른다. 다만 자기가 그은 직선과 제시된 정보와 맞춰보고 오차로부터 기울기를 보정할 줄은 안다. 기계가 아무렇게나 그어놓은 직선으로부터 얻은 값을 y 라고 하자. 가로축의 동일한 지점 x 에서 나와야 할 바른 값은 t 라고 하자.


오차 E는 t 와 y의 차분이다. 이로부터 수정해야 할 직선의 기울기를 구할 수 있다.


이 "보정치 구하기"가 바로 기계학습이다. 기계는 t 가 아니라(!) 오류 E 로부터 ∆W 를 학습한다. 예로 돌아가 보자. 기계가 아무렇게 그은 직선의 기울기 W = 0.9 였다고 하자. 제시된 정보에 따르면 x = 37 에서 목표치는 59.6 다. 기계가 그은 직선에 의하면 x = 37 에서 y = 33.3 (=0.9*37) 다. 따라서 오차 E = 26.3(= 59.6 - 33.3) 다. 보정해야 할 기울기 ∆W = 0.7108(= 26.3/37) 다. 기계는 그만큼 보정하여 기울기를 수정한다.


갱신된 기울기를 가지고 제시했던 정보를 검산 해보자.

      37 * W_new = 37 * 1.6108
                 = 59.5996


마일에서 킬로미터로 변환하는 비례상수 A를 단번에 구했다. 처음부터 나눗셈을 할 줄 알았더라면 그리고 비례식을 알았더라면 오차를 구하고 보정치를 생각할 필요가 없었을 것이다. 기계가 정답을 찾아가는 과정을 간략히 요약하면 다음과 같다.

    (1) 기계는 처음에 '임의'로 W를 정한다.
    (2) W와 학습 입력을 곱하여 기계출력을 구한다.
    (3) 기계출력과 학습 목표치를 비교하여 오차를 구한다.
    (4) 오차가 허용치 보다 크면 ∆W를 '적절'히 주고 W 를 갱신 한다.
    (5) 갱신한 W를 가지고 (2)로 간다.

이 반복적인 과정은 방정식의 근을 찾아가는 초보적인 수치해석법과 다를바 없다. '임의'로 정한 초기값이 정답에 가까웠다면 반복을 효과적으로 줄일 수 있을 것이다. '적절'한 보정치 ∆W를 잡기 위해 경사 하강법(미분!)을 동원한다. 인공 신경망이 수치해석과 다른 점이라면 고차 다항식을 배제하고 독립적인 다수(매우 많다!)의 1차식(직선!)만 사용한다는 것이다.

3. 직선 한개로는 부족하다

직선을 사용하여 세상사를 구분하려고 한다. 세상일이 이렇게 단순하면 좋으련만 수많은 변수가 서로 얼키고 설켜있다. 직선 하나 만으로 만사를 구분하기 어렵다는 것은 자명하다. 당장 단 2개의 변수를 가진 XOR 라는 논리 함수를 보자. 두 축의 좌표계 상에 배치된 이 함수의 출력을 직선 한개로 구분할 방법이 없다. 그렇다면 여러개의 직선을 동원하면 가능하지 않을까? 영역을 구분하는 경계를 완성하려면 직선이 유효한 범위도 정의 해야 한다.



4. 뇌 신경 세포의 결합을 모사한 "인공 신경망"

"뇌 신경망"이라고 하면서 생물학(뇌과학)을 들이밀 필요는 없을 것이다. 너무나 많이 들었을 테니까! 동물의 뇌에서 신경 세포들의 작동을 알아보니 단순하다. 신경 세포들 사이의 정보전달은 연결 강도의 세기와 비례한다는 것이다(직선!). 그대신 수많은 신경 세포들이 서로 얽혀있다. 인간의 뇌는 약 8천억개의 신경 세포들이 있단다. 초파리는 장애물을 피해 비행하는데 10만개의 신경세포들을 동원한단다. 엄청난 수의 직선들이 얽혀 신통한 결정을 하는 셈이다. 신경 세포는 별 것 아닐지 몰라도 떼로 모아놓은 "신경망"이 낳는 결과는 상상을 초월한다. 신경 세포들이 층을 이뤄 연결되어 더욱 위력을 발휘한다. 게다가 연결 강도를 유연하게 바꿀 수 있다.



신경세포(뉴런)들의 연결망을 다음과 같이 모사한다. 다수의 신경 세포들은 층으로 나눠져 (겹겹이) 분포되었다. 신경 세포들 사이의 연결을 묘사하는 수식은 1차 함수다. 1차 함수의 기울기를 가중치라 한다. 연결된 두 신경세포의 관계는 이전 신경세포의 출력과 가중치의 곱이다. 한 신경세포에 다수의 신경세포들이 연결되어 있으므로 이전 신경세포의 출력과 연결강도(가중치) 곱의 합이 현재 신경세포의 입력이다.



한 신경 세포는 다수의 입력을 받아 자신의 출력을 결정하는 함수를 가진다. 이 함수를 활성함수(activation function)라 한다. 연결 받을 신경세포의 갯수(함수의 정의구역의 범위)가 특정되지 않았더라도 출력을 제한할 필요가 있다. 한 신경세포가 폭주하면 신경망을 망칠 수도 있다. 적절한 활성함수는 이를 방지한다. 시그모이드 함수는 입력이 무한히 증가 하더라도 그 출력을 수렴 시킬 수 있다.


[출처]How to play around with sigmoid function to increase its y max and shift to the right for positive x only

모사한 뇌의 신경세포 연결망 규모는 유한할 수 밖에 없다. 폭주를 막도록 연결 강도의 범위를 제한 하는 방법도 있다. 따라서 시그모이드 함수를 적용하지 않더라도 신경 세포의 출력은 예측 가능하다. 계산이 복잡한 시그모이드 함수 대신 단순한 직선식을 적용하기도 한다.


[출처] Rectified Linear Unit (ReLU)

두 층 사이 신경 세포의 연결을 수식으로 표현하려면 매우 많은 1차 식이 동원되어야 한다. 수학의 시그마 기호(Σ)는 이를 간략하게 표현하는 방법이다.



W_ij 는 정보전송 시작층의 i번째 신경세포의 출력과 도착층의 j번째 신경세포의 입력 사이에 연결 강도를 나타낸다. 신경망을 2개 층에서 3개 층으로 확장해도 규칙은 같다. 곱의 합 Σ(W_ij*O_i)을 행렬로 표현하면 다음과 같다. 굳이 행렬 형식으로 표현하는 이유가 있을까? 개발자(또는 연구자)마다 선호하는 표기법이 있기 마련이다. 행렬 형식으로 표현할 경우 직관적으로 보일 수도 있고 행렬 계산용 컴퓨팅 라이브러리들이 준비되어 있어서 코딩에 큰 수고를 들이지 않아도 된다.


각 층을 구성하는 신경세포의 출력은,


신경망의 입력에서 출력까지 이어지는 순방향(Forward) 계산에 필요한 수학은 이것이 전부다. 정말 별 것 없다! 신경망을 구성하는 세포들 사이의 관계를 1차 방정식(직선들!)으로 묘사하고 그들의 합이 있을 뿐이다. 수학은 간단하지만 수많은 신경세포들이 가중치를 두고 겹겹이 연결된 망을 형성되었다. 연결 가중치의 학습가능한 유연성이 기계에 지능을 부여하는 원리다.

입력에서 출력을 얻을 때까지 규칙적(연속적)인 곱셈과 덧셈이 이어질 뿐 비교 판단이 없다는 점을 눈치 챘는가? 알고리즘을 기술하는 컴퓨팅 언어의 if~else~과 그에 따른 분기(branch)가 없다. 알다시피 폰-노이만 방식 컴퓨터 구조에서 CPU의 가장큰 손해가 바로 피연산자의 적제및 이동(load & store)과 분기문 이라는 점을 기억하자. 게다가 신경망 계산의 흐름이 한방향으로 만 이어지는 특징을 가지고 있어서 병렬처리 계산기를 구성하기에 매우 적합하다.

5. 오류의 역전파

'지능'은 다수의 층으로 나눠 분포한 신경 세포들과 그들 사이의 유연한(학습 가능한) 연결 가중치가 낳은 결과다. 신경망의 학습은 신경세포들 사이의 연결강도 W_ij 와 W_jk 를 효과적으로 그리고 적절하게 갱신하는 과정이다. 학습하기 전의 연결 강도는 임의로 그은 직선의 기울기다. 학습을 통해 임의로 주어졌던 연결 강도를 보정하기 위한 ∆W_ij 와 ∆W_jk를 '오차 역전파" 법으로 구해보자.

먼저 전방향으로 전달되는 오차를 따져보자. j-층 각 노드의 오류 E_j 는 연결 강도 W_jk의 분량 만큼 분할되어 k-층으로 전달 된다.




신경망의 최종층(k-층)의 오차 E_k 가 발생하는 요인을 따져보면 j-층의 출력 O_j와 연결 강도 W_jk의 곱에서 비롯되었다는 것을 알 수 있다.



k-층의 오차는 j-층의 오차가 연결 가중치를 타고 모인 결과라 할 수 있다.



오차 E_k 를 근거로 연결 가중치 보정 값 ∆W_jk 를 구할 수 있다.



E_k는 신경망의 최종 k-층에서 오차이므로 k번째 노드(신경세포)의 출력 O_k와 학습목표치 t_k와의 차분으로 구할 수 있다. 그런데 t_k 에 대한 근거가 없다. 즉, ∆W_jk를 구하기 위하여 분모에 들어갈 x_k 에 해당하는 값이 주어지지 않았다.


i-층과 j-층 사이의 연결 가중치 W_ij를 갱신 하려면 E_j 를 알아야 한다. 가중치 보정값 ∆W_ij을 구하기 위해 필요한 모든 변수가 모두 미지인 셈이다.



E_j는 물론 x_j 가 알려지지 않았으므로 ∆W_ij를 직접 구할 수 없다. 다행히 E_j 는 k-층 노드의 오차 E_k와 연결가중치 W_jk에서 비롯되었다는 점은 분명하다.



k-층의 오차 E_k 를 가중치 W_ij 를 가지는 연결을 통해 역전파(Error Back-Propagate)하여 E_j를 '추정' 한다. E_j를 추정하기 위한 역방향 연결 가중치 행렬은 전방향 연결 가중치 행렬과 전치(transpose)관계다.


E_k를 구하고 E_j 를 추정해냈다. ∆W_jk를 구하기 위한 x_k와 x_j 는 근거가 없다. 따라서 ∆W_jk를 '적절'히 정한 후 전방향 계산과 오차 구하기를 반복하는 수밖에 없다.

6. 연결 가중치 보정 전략

오차 E 를 감소시키는 방향으로 연결 가중치를 조절해본다. 지도학습에서 k-층의 목표 t_k 가 주어졌으므로 오차 E_k는 노드 출력 O_k에 의존한다. 다시 O_k 는 W_jk에 달렸다. 따라서 W_jk를 오차 E_k의 종속변수로 보고 관계를 설정해보자.

목표치가 주어진 k-층의 모든 노드에서 발생한 오차의 합을 신경망 오차로 정의하자. 오차 E_k 가 음수 또는 양수가 될 수 있으므로 단순 합은 적절치 않다. 각노드의 오차 절대값을 취한 합을 신경망의 결과를 평가하는 총 오차 E_total로 삼을 수 있다.


연결강도 W_jk를 변경하는 전략은 총 오차 E_total이 감소하는 방향으로 ∆W_jk를 조절하는 것이다. 총 오차를 아래와 같이 각 노드의 오차의 절대값 대신 제곱한다. 절대값 취하기 효과를 가질 뿐 만 아니라 오차를 줄여나갈 수 있는 방안이 된다.



최종층의 t_k는 주어졌으며(지도학습!) O_j는 이전 전방향 신경망 계산으로 알고있다. 학습은 W_jk의 변경에 관심을 가지고 있다. 최초 '임의' W_jk = W1일 때 E_k(W1) 만큼의 오차가 발생 했다. W1을 수정하여 오차를 줄여야 한다. W_jk에서 E_k(W_jk)의 접선의 기울기 만큼 가감해 주어 기울기가 0에 가까워 질때까지 반복한다. 이는 수치해석법의 초보적인 최소값 찾기다. 신경망에서는 이를 '경사 하강법(Gradient Descent)'이라고 한다.


경사 하강법으로 최소 오차를 찾아가는 전략을 직관적으로 알아보자. W1에서 접선의 기울기는 양이다. E_k(W_jk)=0로 이동하려면 W1에서 기울기 만큼 빼주어야 한다. W4에서 접선의 기울기가 음이면 W4에 기울기 만큼 더해준다. 기울기에 학습 상수를 곱하여 연결강도 보정치를 결정한다. 과도한 학습 반복, 지역 최소값을 막기 위해 '적당한' 학습률 상수(Learning Rate)를 준다.



W_jk에 대한 접선 기울기를 계산하기 전에 오차 함수 E_k를 풀어보면 다음과 같다. 주어진 값 t_k, W_ij와 O_i의 곱으로 계산된 O_j은 W_jk 와 직접 관련 없으므로 미분에서 제외 할 수 있다.



E_jk에 대한 W_jk의 편미분은 다음과 같다. E_k에 W_jk 가 직접 보이지 않으므로 연쇄법칙을 활용하면 좀더 편리하게 미분을 수행 할 수 있다.



신경세포의 출력에 대한 오차의 미분은 이미 계산되었다.


연결 가중치에 대한 미분은 활성 함수의 미분과 이전층 출력의 곱이다.


정리하면,


계수는 학습률에 포함 시키자. 최종적으로 연결 강도 갱신을 위한 보정 ∆W_jk 은 다음과 같다.


∆W_ij 은 다음과 같다. j-층의 오차 E_j는 E_k를 오차 역전파 방법으로 '추정'하여 얻었다.



7. 끝으로,

대략 신경망은 이해했다. 이제 프로그래밍 언어로 제작해 볼 차례다.

[과제1] 신경망 학습 알고리즘에서 '임의', '적절', '추정'에 해당하는 항목을 찾아보라. 기존의 정밀한 규칙 기반의 알고리즘과 배치된다.

1. 초기 가중치 선정의 '임의'/무작위 가중치 초기화(Random Weight Initialization)
    * Brain-inspired warm-up training with random noise for uncertainty calibration,
2. 오차 역전파를 통한 은익층의 오차 '추정'
3. 연결 강도 갱신량의 '적절'

[과제2] 신경망 알고리즘의 연산기의 특성이 하드웨어 구조 변화에 미친 영향에 대하여 논의해보라.

1. 낮은 정밀도 -> 아날로그 컴퓨팅/The Analog Thing
2. 대규모 병렬성 -> 벡터 전용 계산 구조(비 폰-노이만)

[과제3] 인공지능과 인공신경망을 단 몇분 만에 설명하겠다는 동영상들이 수도 없이 많다. 아래에 소개한 두 동영상을 시청해보라. 신경망을 조금 배우기 전과 후의 감상을 논의해 보자. 동영상에 나오는 내용 중 윗글에서 언급되지 않은 부분이 있는지 찾아보라.

Neural Networks Explained in 5 minutes

----------------------------------------------------------------------
[다음] [내 신경망 만들기/2부] 1. 최소한의 파이썬(찬조출연: C++)


2026년 4월 22일 수요일

[내 신경망 만들기/2부] 3. 손글씨 인식 신경망(MNIST 데이터 셋)

[내 신경망 만들기/2부] 3. 손글씨 인식 신경망(MNIST 데이터 셋)

1. 개요
2. MNIST 데이터 시현
    2-1. 파이썬
    2-2. C++
3. MNIST 손글씨 인식 신경망

----------------------------------------------------------------------------------------------
[참고서] Make Your Own Neural Networks, Tariq Rashid [book][검색링크]
----------------------------------------------------------------------------------------------

1. 개요

MNIST(Modified National Institute of Standards and Technology) 데이터 셋은 손글씨 이미지를 모아놓은 자료로서 다양하게 구현한 신경망의 성능을 평가할 때 널리 활용된다. 뿐만 아니라 신경망을 처음 공부하면서 만든 "내 신경망"의 시험 대상으로 활용되어 딥러닝 분야의 'Hello World'라고 할 수 있다[링크]. 앞서 만들어 놓은 단순 신경망[링크]을 그대로 사용하여 손글씨 인식 "내 신경망"을 구성해 보기로 한다. 다만 신경망의 능력을 높이기 위해 각 층을 구성하는 노드(또는 신경 세포)의 갯수만 늘렸다. 개별 신경세포는 아주 단순하지만 수없이 많은 세포들이 모여 구성한 망은 놀라운 결과를 보여준다. 복잡계를 단순화시켜 그로부터 규칙을 찾아내오던 전통적인 지적 활동에 비하면 신경망은 '단순'하게 보인다. 그래서 일까? 프로그램 코딩을 몰라도, 알고리즘을 몰라도 컴퓨터를 내 구미에 맞추는데 전문가 초보자 가릴것 없이 인공지능에 메달린다. 게으름 이라니!

2. 이미지 시현

MNIST 데이터 셋의 규모는 학습용으로 6만개의 손글씨 이미지, 시험용으로 1만개의 손글씨 이미지로 구성되었다. 각 이미지는 256단계의 단색 화소가 가로 28 세로 28개로 구성된다. MNIST 데이터 셋은 LeCunn의 웹사이트[링크]에서 배포되었으나 워낙 다양한 형식으로 널리 퍼져 있어서 지금은 해당 자료가 보이지 않는다. CSV 형식 MNIST 데이터 셋은 Joseph Redmon의 깃허브 저장소[링크]에서 구할 수 있다.


28x28 화소 손글씨 이미지
출처: https://youtu.be/gh8UR3nw2uk?si=-V-Jf28tN_1cEjQe

내 신경망에서 사용할 MNIST 데이터 셋의 형식은 CSV(Comma Separated Values)로서 다음과 같다.

    2,0,0,0,0,,......,0,116,125,171,255,255,150,93,0,0,.....,0,0,0,0

첫번째 값은 해당 이미지의 숫자이며 이어 784(=28x28)개의 화소값이다. 숫자들의 나열로부터 손글씨 화상(이미지)를 떠올리려면 상당한 상상력이 필요하다.  처리할 대상의 가시화는 개발 생산성을 높이는 중요 요소다. 손글씨 이미지를 컴퓨터 그래픽장치에 시현해보자.

2-1. 파이썬

손글씨 이미지를 시현하는 파이썬 코드 plot_data.py  의 내용을 따라가 보기로 한다.

#
# Plot hand written image with CSV(Comma Separated Value)
#
data_file = open("mnist_dataset/mnist_train_100.csv", 'r')
data_list = data_file.readlines()
data_file.close()
print(F"There's {len(data_list)} Data lists.")
print(F"0-th Data List: \n{data_list[0]}")

읽을 CSV 파일을 열어 한줄을 읽어 .readlines() 변수 data_list에 저장했다.

import numpy
import matplotlib.pyplot

배열형 자료를 쉽게 다루기 위한 numpy 모듈과 데이터 가시화 모듈 matplotlib를 들여온다(import).

all_values = data_list[1].split(',')

긴 문자열에서 콤마(,) 기호로 분리된 값들을 1차원 리스트(list)로 변환한다.

image_array = numpy.asfarray(all_values[1:]).reshape((28,28))

리스트에서 첫번째 값(인식 숫자)을 제외한 나머지 all_values[1:] 를 28x28의 행렬로 재구성하여 부동 소수점 2차원 배열로 변환한다.

matplotlib.pyplot.imshow(image_array,cmap='Greys',interpolation='None')
matplotlib.pyplot.show()

파이썬 모듈 matplotlib에 행렬 데이터를 시각화 해주는 방법(크래스 소속함수)들을 갖추고 있다. 이를 이용하면 단숨에 데이터를 시각화 할 수 있다. 소속함수 .imshow()로 그림판을 채우고 실제로 그래픽 화면에 띄우는 .show()가 뒤따른다. 컴퓨터 그래픽 체계는 그리기(렌더링, rendering)와 시현(show)을 분리한다. 컴퓨팅 시스템에 장착된 그래픽 장치는 메모리에 비해 매우 느리다. 따라서 그림판에 화소를 일일이 채울 때마다 그래픽 장치를 동작시키기 보다 그리는 행동(렌더링)은 메모리에서 수행하고 완성된 그림을 한번에 주변장치에 뿌리는 것이 효과적이다.

$ python3 plot_data.py

파이썬에서 객체의 자료형은 선언할 필요 없이 할당되는 값(리터럴)에 의해 결정된다. 문자열로부터 숫자 배열로 변환하는 소속함수를 갖추고 있다. 매우 높은 추상성을 추구하는 파이썬의 자료형은 자료 처리를 위해 많은 사람들이 요구하는 기능들은 갖추고 있어서 코딩의 수고를 덜어준다. 이는 프로그래밍 입문자들이 파이썬에 열광하는 이유이기도 하다.

2-2. C++

C++ 역시 상당히 높은(그리고 넓은!) 추상화 수준의 프로그래밍 언어다. 각종 자료처리와 시각화를 위해 헤아릴 수 없을 만큼 다양한 그래픽 API와 라이브러리들이 제공되고 있지만 C++ 언어 학습을 위해 고도의 라이브러리를 배제하고 MNIST 데이터 시현 프로그램을 작성하해보기로 한다. C++ 코드는 plot_data.cpp 다. 주요 내용을 따라가 보자.

//
// Plot hand written image with CSV(Comma Separated Value)
// Filename: plot_data.cpp
//
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <unistd.h> // sleep()
#include <string.h>

C 언어의 표준 라이브러리는 파일 입출력을 다루고(stdio.h, stdlib.h) 문자열 변수를 처리하는 함수들(string.h)이 기본적으로 제공 한다. 그래픽 프로그래밍은 게임 및 멀티미디어 응용 프로그램 개발용 오픈-소스 라이브러리 SDL을 활용하기로 한다.

#include <SDL2/SDL.h>

#define CSV_FILENAME    "mnist_dataset/mnist_test_10.csv"
#define SIZE_OF_BOX     10

CSV 형식을 다루는 공개 라이브러리가 있지만 그리 복잡하지 않으므로 직접 작성하기로 한다(공부도 할겸...). CSV 형식 파일에서 손글씨 이미지 데이터를 구하는 함수는 다음과 같다.

bool ParseCSV_MNIST(

        char*     FileName,    // CSV 파일명
        int       nLine,       // 번째 줄
        uint8_t*  image)       // 이미지 데이터(1차원 배열, 길이=28x28+1)
{
    FILE    *fp;
    char    *line = NULL;
    size_t  len = 0;
    ssize_t read;

    fp = fopen(FileName, "r");
    if (fp == NULL) {
        printf("Error Opening CSV file\n");
        return false;
    }

    do
 {
        nLine--;
    } while (((read=getline(&line, &len, fp))!=-1) && (nLine>0));

    if (read==EOF)    // -1
    {
        printf("End of File\n");
        return false;
    }

CSV 파일을 열어 nLine 번째 한 줄을 읽어 문자열 변수에 저장했다. getline()은 파일에서 한줄(문자 '\n'으로 끝난다)을 읽어오는 C/C++의 표준 입출력 함수다. 콤마(,)기호로 분리된 문자열을 다루는 표준 함수는 없으므로 응용 목적에 맞게 처리한다.

    char buff[8];
    int k=0;

    for (int j=0, i=0; i<strlen(line); i++)
    {
        if (line[i]>=0x30 && line[i]<= 0x39)
            buff[j++] = line[i];
        else if (line[i]==',' || line[i]=='\n')
        {
            buff[j] = 0x00;
            image[k++] = (uint8_t)atoi(buff);
            j = 0;
            if (k>(28*28+1))    break;
        }
    }

앞서 열었던 파일을 닫고,

    free(line);
    fclose(fp);

읽은 데이터가 응용 목적에 맞는지 최소한 갯수 만이라도 확인한다.

    if (k!=(28*28+1))   return false;
    else                return true;
}

CSV 파일에서 읽은 이미지를 시현 해보자. C++ 프로그래밍에서 그래픽을 다루는 다양한 방법과 라이브러리가 있지만 SDL이 그중 사용이 쉽다. SDL(https://www.libsdl.org/)은 게임 제작용으로 배포되는 오픈-소스 라이브러리다. 그래픽 뿐만 아니라 키보드, 마우스 등 주변장치의 제어와 쓰레드 프로그래밍을 포함하여 다양한 API들을 구비하고 있어서 시스템 모델링에 매우 유용하다. SDL을 사용하여 각종 게임기 에뮬레이터를 제작한 예를 봐도 알만하다. 손글씨 문자 이미지를 시현하는 함수를 작성하면 다음과 같다.

bool Draw_Charater(
        const char*   Filename,  // CSV 파일명
        SDL_Renderer* renderer,  // 그림을 그릴 객체의 속성
        SDL_Window*   window,    // 그림창 객체의 고유번호
        int           nImage)
{

CSV 파일을 열어 해당 nImage 번째 이미지 자료 읽어온다. CSV에서 읽은 이미지를 저장할 변수는 용도에 맞게 미리 선언되어 있어야 한다. 손글씨 이미지의 각 화소는 256단계 값이므로 부호없는 8비트 uint8_t 형으로 선언되었다(stdint.h).

    uint8_t image[28*28+1];

    // CSV to Image
    if(!ParseCSV_MNIST((char*)CSV_FILENAME, nImage, image))
        return false;

SDL 창에 모양을 내봤다. 몇번째 이미지 인지 창의 타이틀에 표시하였다.

    char szTitle[32];

    sprintf(szTitle, "%d-th Data [%d]", nImage, image[0]);
    SDL_SetWindowTitle(window, szTitle);

화소를 확대하여 시현하기 위해 상자를 그린 후 밝기 단계에 맞춰 채운다. SDL_Renderer는 그림을 그릴 공간과 도구를 모아놓은 객체다. 그리기 전 붓의 RGB 색상, 상자가 그려질 그림판 영역등이 모두 포함된다.

    SDL_Rect rect;
    rect.x = 0;  // X position
    rect.y = 0;  // Y position
    rect.w = SIZE_OF_BOX; // Width
    rect.h = SIZE_OF_BOX; // Height

    for (int n=1, row=0; row<28; row++)
    {
        for (int col=0; col<28; col++)
        {
            SDL_SetRenderDrawColor(renderer,
                 image[n], image[n], image[n],SDL_ALPHA_OPAQUE);
            SDL_RenderFillRect(renderer, &rect);
            rect.x += SIZE_OF_BOX;  // X position
            n++;
        }
        rect.x = 0;
        rect.y = SIZE_OF_BOX*(n/28);  // Y position
    }

렌더러(renderer) 객체에 그림을 채운 후 이를 그래픽 장치에 시현되도록 한다. 파이썬의 경우와 마찬가지로 그리기(rendering)와 시현(presenting)이 분리되어있다.

    SDL_RenderPresent(renderer);                

    return true;
}

위에서 작성한 함수들을 활용하여 손글씨 이미지를 시현해보자.

int main()
{
    // SDL2-------------------------------------------------------
    SDL_Window*     window = NULL;
    SDL_Renderer*   renderer = NULL;
    SDL_Event       event;

    fprintf(stderr, "Plottig MNIST dataset. Use Arrow keys.....");

먼저 컴퓨터의 그래픽 시스템 초기화가 필요하다. SDL에서 제공하는 각종 API들과 멀티미디어를 다룰 객체들은 헤더파일 SDL2.h 에 정의되어 있다.

    if (SDL_Init(SDL_INIT_VIDEO) < 0)
    {
        fprintf(stderr,
                "SDL Initialization Fail: %s\n",
                SDL_GetError());
        return -1;
    }

그림을 그릴 창을 띄운다.

    window = SDL_CreateWindow("Plotting Data",
                              SDL_WINDOWPOS_UNDEFINED,
                              SDL_WINDOWPOS_UNDEFINED,
                              28*SIZE_OF_BOX, 28*SIZE_OF_BOX,
                              SDL_WINDOW_SHOWN);
    if (!window)
    {
        fprintf(stderr,
                "SDL Initialization Fail: %s\n",
                SDL_GetError());
        SDL_Quit();
        return -1;
    }

손글씨 이미지는 항상 28x28 이므로 창의 크기는 고정시켰다.

    SDL_SetWindowResizable(window, SDL_FALSE);

그리기 객체(렌더러)를 생성하여 창에 부여 해준다.

    renderer = SDL_CreateRenderer(window,-1,
                                    SDL_RENDERER_ACCELERATED);

게임 제작 라이브러리 SDL의 주 쓰레드는 무한 반복문 내에서 입출력(키보드, 마우스 그외 게임장치 등)의 사건(event)에 반응한다.

    // Event Loop -----------------------------------------------
    uint8_t image[28*28+1];
    int     nImage = 1;
    bool    quit = false;

    while(!quit)
    {

SDL 커널은 입력장치를 조사(event polling)하여 상태변화가 발생하면 이를 사건으로 알려준다.

        if(SDL_PollEvent(&event))
        {

사건을 일으킨 장치를 판별,

            switch (event.type)
            {

프로그램 종료(마우스로 창 닫음 버튼을 누른 경우),

                case SDL_QUIT:
                    quit = true;
                    break;

키보드에서 누름(key-down) 사건,

                case SDL_KEYDOWN:
                    switch( event.key.keysym.sym )
                    {
                        case SDLK_ESCAPE:
                            quit = true;
                            break;
                        default:
                            break;
                    }
                    break;

키 뗌(key-up) 사건인 경우,

                case SDL_KEYUP:
                    switch( event.key.keysym.sym )
                    {

키보드 윗 화살, 왼쪽 화살 인경우,

                        case SDLK_UP:
                        case SDLK_LEFT:

이전 이미지 시현 함수 Draw_Character() 호출,

                            if (Draw_Charater(CSV_FILENAME,renderer, window, nImage))
                                nImage--;
                            else
                                quit = true;
                            break;
                        case SDLK_DOWN:
                        case SDLK_RIGHT:

다음 이미지 시현 함수 Draw_Character() 호출,

                            if (Draw_Charater(CSV_FILENAME,renderer, window, nImage))
                                nImage++;
                            else
                                quit = true;
                            break;
                        default:
                            break;
                    }
                    break;
                default:
                    break;
            }
        }

아무 사건도 없다면 잠시 쉰 후(컴퓨팅 자원의 독점 방지) 무한 반복,

        else
            usleep(100);
    }

SDL 객체 종료,

    EXIT:
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    SDL_Quit();
}

빌드 할 때 SDL2는 표준 라이브러리가 아니므로 링크 시 추가한다.

$ g++ -o plot_data plot_data.cpp -lSDL2

실행하면 다음과 같은 손글씨를 볼 수 있다. 파이썬에 비하면 그림을 띄우는데 상당한 공이 들어간다. 추상성이 넓은 C++를 활용하면 손쉽게 시스템 제어를 할 수 있다.

$ plot_data

3. MNIST 인식 신경망

두뇌의 신경세포에 꼬리표가 없다. 방대하게 구성된 신경망이 훈련을 통해 특별한 능력을 발휘할 뿐이다. 인공 신경망 역시 이와 다르지 않다. MNIST 손글씨를 인식하기 위해 별도의 인공 신경 세포를 작성하지 않고 망의 구성을 확대하고 훈련 할 뿐이다.

손글씨 숫자 인식 신경망은 다양하게 구현되고 교육용으로 제작된 컨텐츠들이 많다. "내 신경망"을 만들어보기 전에 다른 이들은 어떻게 설명하고 있는지 살펴보기 바란다.


2개 은닉층을 가진 다층 신경망


온-라인 필기체 인식 신경망
출처: https://www.3blue1brown.com/lessons/neural-network-analysis


이미지 분류: 다층 퍼셉트론(MLP)으로 손글씨 숫자(MNIST) 인식
출처: https://youtu.be/gh8UR3nw2uk

손글씨 인식 "내 신경망"을 파이썬으로 구현해보자. 은닉층은 1개다. 원시 코드는 part2_neural_network_mnist_data.py 다.

# python notebook for Make Your Own Neural Network
# code for a 3-layer neural network,and learning the MNIST dataset
# (c) Tariq Rashid, 2016
# license is GPLv2
#--------------------------------------------------------------------
# Fiename: part2_neural_network_mnist_data.py
#--------------------------------------------------------------------
import numpy
import scipy.special
import matplotlib.pyplot

신경망 크래스는 앞서 작성해둔 것 part2_neural_network.py 과 동일하다. 초기화 __init()__, 학습 train() 그리고 조회 query() 를 소속함수로 두고 있다.

# neural network class definition
class neuralNetwork:
    def __init__(self, inputnodes, hiddennodes, outputnodes, learningrate):
        # set number of nodes in each input, hidden, output layer
        self.inodes = inputnodes
        self.hnodes = hiddennodes
        self.onodes = outputnodes
        ........
        pass

    # train the neural network
    def train(self, inputs_list, targets_list):
        .........
        pass

    # query the neural network
    def query(self, inputs_list):
        .........
        return final_outputs

좀더 복잡한 '지능(?!)'을 부여하기 위해 신경망의 규모를 늘린다. 이미지를 구성하는 784개의 화소가 모두 일렬로 입력이다. 은닉층의 노드수는 200개로 정했다. 노드수의 갯수가 많을 수록 정교한 신경망이 될 수 있으나 그렇다고 항상 효과가 있지는 않다. 각 층 사이에 모든 노드가 연결되므로 총 연결 수는 156,800(=784x200)나 된다. 이어 은닉층과 출력층의 연결은 2,000(=200x10)개다. 이 연결마다 가중치 곱셈을 고려하면 신경망이 요구하는 계산량은 엄청나다. 오늘날의 고도로 발전한 반도체 계산기가 없었더라면 구현 불가능하다. 전통적인 과학 기술 탐구에서 수학적 사고의 위대함 엿볼 수 있다.

if __name__=="__main__":
    # number of input, hidden and output nodes
    input_nodes = 784
    hidden_nodes = 200
    output_nodes = 10

    # learning rate
    learning_rate = 0.1

입력층, 은닉층 그리고 출력 층을 지정하여 신경망을 구성한다.

    # create instance of neural network
    n = neuralNetwork(input_nodes, hidden_nodes, output_nodes, learning_rate)

CSV형식 MNIST 파일을 읽어 신경방을 훈련한다.

    # load the mnist training data CSV file into a list
    training_data_file = open("mnist_dataset/mnist_train_100.csv", 'r')
    training_data_list = training_data_file.readlines()
    training_data_file.close()

동일한 훈련 데이터 셋을 가지고 여러번에 걸쳐 훈련을 실시한다. 훈련을 반복함에 따라 경사 하강하여 최소 오류에 도달 할 것으로 기대 하지만 수많은 연결이 최적화(오류 최소화) 될 것이라고 기대하기 어렵다. 훈련의 반복 횟수는 적당히 한다. 반복이 많다고 훈련이 잘된다고 할 수 없다.

    # train the neural network
    # epochs is the number of times the training data set is
    # used for training
    epochs = 5
    for e in range(epochs):
        # go through all records in the training data set
        for record in training_data_list:
            # split the record by the ',' commas
            all_values = record.split(',')
            # scale and shift the inputs
            inputs = (numpy.asfarray(all_values[1:]) / 255.0 * 0.99) + 0.01
            # create the target output values (all 0.01, except the desired label which is 0.99)
            targets = numpy.zeros(output_nodes) + 0.01
            # all_values[0] is the target label for this record
            targets[int(all_values[0])] = 0.99
            n.train(inputs, targets)
            pass
        pass

훈련을 마친 신경망에 시험 이미지를 제공하여 인식이 되는지 알아보자.

    # load the mnist test data CSV file into a list
    test_data_file = open("mnist_dataset/mnist_test_10.csv", 'r')
    test_data_list = test_data_file.readlines()
    test_data_file.close()

    # test the neural network
    # scorecard for how well the network performs, initially empty
    scorecard = []

    # go through all the records in the test data set
    for record in test_data_list:
        # split the record by the ',' commas
        all_values = record.split(',')

        # correct answer is first value
        correct_label = int(all_values[0])

        # scale and shift the inputs
        inputs = (numpy.asfarray(all_values[1:])/255.0*0.99)+0.01

        # query the network
        outputs = n.query(inputs)

        # the index of the highest value corresponds to the label
        label = numpy.argmax(outputs)
        print(F'correct label : {correct_label} -> ', end="")
        print(F'network answer: {label}')

        # append correct or incorrect to list
        if (label == correct_label):
            # network's answer matches correct answer,
            # add 1 to scorecard
            scorecard.append(1)
        else:
            # network's answer doesn't match correct answer,
            # add 0 to scorecard
            scorecard.append(0)
            pass
        pass
    # calculate the performance score
    scorecard_array = numpy.asarray(scorecard)
    print ("performance = ", scorecard_array.sum() / scorecard_array.size)

실행,

$ python3 part2_neural_network.py

correct label : 7 -> network answer: 7
correct label : 2 -> network answer: 0
correct label : 1 -> network answer: 1
correct label : 0 -> network answer: 0
correct label : 4 -> network answer: 4
correct label : 1 -> network answer: 1
correct label : 4 -> network answer: 4
correct label : 9 -> network answer: 4
correct label : 5 -> network answer: 4
correct label : 9 -> network answer: 7
performance =  0.6

MNIST 데이터 셋을 사용하지 못하고 100개의 훈련 데이터셋을 골라 10개의 데이터를 시험해봤다. 잘못된 인식을 한 숫자 이미지는 다음과 같다. 단순 구조(한개의 은닉층, 바이어스 값이 없는 단순 발화 함수)의 "내 신경망"은 시험결과 성능이 우수하다고 볼 수 없다. 어쨌든 신경망의 작동과 훈련 개념을 배웠다는 점이다. 여기에 더하여 파이썬과 C++가 고도의 추상적 언어라고 불릴 수 있는 핵심 요인인 '크래스'의 개념도 학습할 수 있었다. C++ 로 MNIST 손글씨 인식 신경망을 작성해보자.

신경망의 성능은 신경 세포 사이의 적절한 연결 강도의 결정에 달렸다. 훈련 과정에서 역전파된 오류로부터 은닉층의 오류 추정의 불명확성, 지역 최소 오류, 불균일한 훈련, 은닉층의 노드 수, 훈련 반복 횟수등의 정량적 평가와 결정이 매우 어렵다. 재훈련의 비용부담이 큰 인공 신경망은 구조의 유연성을 어렵다.

--------------------------------------------------------------------------------------------------

참고:
[1] Python 및 C++로 "내 신경망 만들기" 깃허브 저장소, https://github.com/GoodKook/ETRI-0.5um-CMOS-MPW-Std-Cell-DK/tree/main/Projects/MYONN
[2] 딥러닝의 핵심 활성화 함수(1): Sigmoid의 특징과 한계, https://youtu.be/KxUhMN5oJyc
[3] 딥러닝의 핵심 활성화 함수(2):Tanh, ReLU, Leaky ReLU, https://youtu.be/OtaUrw2ArWQ
[4] 예제로 배우는 역전파(backpropagation), https://youtu.be/Ku1xUFK9I3Y
[5] 합성곱 신경망(CNN) (기초이론), https://youtu.be/h1Io450Igrg
[6] 합성곱 신경망(CNN) (MNIST 실습), https://youtu.be/IHbsSmRbcrw
[7] Canny Edge Detection, https://en.wikipedia.org/wiki/Canny_edge_detector


2026년 4월 18일 토요일

[내 신경망 만들기/2부] 2. 파이썬으로 작성하는 신경망

[내 신경망 만들기/2부] 2. 파이썬으로 작성하는 신경망

1. 개요
2. 파이썬 신경망 기본틀
    2-1. 크래스 초기화 함수, __init__()
    2-2. 초기 가중치
    2-3. 조회 함수 , query()
    2-4. 훈련 함수, train()
3. 소규모 신경망(실습및 과제)
----------------------------------------------------------------------------------------------
[참고서] Make Your Own Neural Networks, Tariq Rashid [book][검색링크]
----------------------------------------------------------------------------------------------
[CC-BY]

1. 개요

최소한의 파이썬으로 간단한 신경망을 작성해보자.

2. 파이썬 신경망 기본틀

신경망의 파이썬 크래스 기본 골격은 다음과 같은 소속함수를 두기로 한다.

- 초기화 함수(크래스 구성자): 신경망을 구성하는 입력층, 은익층 그리고 출력층의 노드 갯수를 정한다.
- 학습 함수: 각층의 노드들 사이의 연결강도(가중치)를 갱신한다. 갱신될 가중치는 학습 자료에 따라 목표 치와 비교하여 갱신될 가중치가 계산된다.
- 조회 함수: 신경망을 구성하는 각 층의 노드에 입력이 주어진 후 계산된 출력을 조회한다.

신경망 크래스 골격은 다음과 같다. 함수의 내용은 아직 비었다.

# Neural network class definition
class neuralNetwork:
    # Initialise the neural network
    def __init__():
        pass

    # Train the neural networks
    def train():
        pass

    # query the neural network
    def query():
        pass


2-1. 크래스 초기화 함수, __init__()

신경망을 구성하는 각 층의 노드 수와 학습률 상수를 설정하기 위해 크래스 neuralNetwork의 초기화 함수를 아래와 같이 변경 추가 한다.

    # Initialise the neural network
    def __init__(self,
                 inputnodes,hiddennodes,outputnodes,learningrate):
        self.inodes = inputnodes # number of input nodes
        self.hnodes = hiddennodes
        self.onodes = outputnodes
        self.lr = learningrate
        pass

각층의 노드 갯수를 주고 크래스를 사례화 하여 신경망을 구성(construct)할 수 있다.

>>> input_nodes = 3
>>> hidden_nodes = 4
>>> output_nodes = 5
>>> learning_rate = 0.3
>>> n = neuralNetwork(input_nodes, hidden_nodes, output_nodes, learning_rate)


2-2. 초기 가중치

각층의 노드들 사이의 연결강도는 신경망의 핵심이다. 학습은 이 연결강도의 조정과정이다. 연결강도는 행렬로 표현한다. 연결의 시작 층을 열(column)로, 종착 층을 행(row)으로 나타낸다.

[주의] 행렬 표기법
가중치 행렬을 2차원 배열로 표현하는 경우 대부분 프로그래밍 언어에서 첫번째 배열 색인을 행으로 두번째 색인을 열로 나타낸다. 가중치를 표현할 때 W_ij는 i 층에서 j층으로 연결되는 노드 가중치를 의미 했었다. 이때 가중치를 행렬로 표현할 경우 i는 열, j 는 행이다.



예를들어,

- wih은 입력층에서 은닉층으로 연결되는 가중치 행렬이다. 입력층 1번째 노드에서 은닉층 3번째 노드의 연결 강도를 W_13 로 표기 한 경우 파이썬 배열 표현은 wih[3,1] 이다.
- who 는 은닉층에서 출력층으로 연결되는 가중치 행렬이다. 파이썬 배열 who[2,3]는 은닉층 3번째 노드에서 출력층 2번째 노드의 연결 강도다.

초기 가중치를 임의로 주는 방법으로 numpy 모듈의 난수 발생 함수를 사용하여 초기화 한다. 난수 발생 함수는 다음과 같다.

>>> import numpy
>>> numpy.random.rand()
0.6175598212712633


난수를 갖는 4행 3렬의 행렬을 단 한문장으로 쉽게 만들 수 있다. 과학 함수 모듈 SciPy의 난수 발생 함수는 [링크]를 참조한다.

>>> wih = numpy.random.rand(4, 3)
>>> print(wih)
[[0.83381733 0.31199744 0.74202711]
 [0.72237501 0.38875002 0.23477635]
 [0.39497783 0.37731733 0.20452554]
 [0.33714142 0.86863996 0.02348075]]

numpy의 난수는 0.0 과 1.0 사이의 값을 갖는다. 범위를 -0.5와 +0.5 사이 값으로 변경해 주어야 한다. 한 문장으로 모든 행렬 값을 쉽게 병경 할 수 있다. 파이썬의 코드 작성에 효율적인 면을 보여준다.

>>> wih = numpy.random.rand(4, 3) - 0.5
>>> print(wih)
[[ 0.37581529  0.35526794 -0.09234779]
 [ 0.16441161  0.38565857 -0.30835495]
 [-0.17046742  0.36361927  0.02191034]
 [ 0.34118212  0.09421848  0.36531536]]

초기 가중치를 무작위 난수 보다 정규 확률 분포를 따르는 난수가 효과적이다. 정규 확률 분포 난수 발생 함수 numpy.random.normal()는 링크를 참조한다. 이 함수를 이용하여 0.0 을 중심으로 대칭인 정규 확율(가우시안) 분포에서 난수를 생성한다. 한개의 난수값 뿐만 아니라 행렬을 쉽게 만들 수 있다. 출력층의 노드 갯수의 역수, pow(onodes, -0.5)를 확률분포의 표준 편차로 취하여 발생한 난수 행렬을 만드는 예는 다음과 같다.

>>> # normal probability distribution rando generator
>>> import numpy

>>> inodes = 3
>>> hnodes = 4

>>> wih = numpy.random.normal(0.0, pow(hnodes, -0.5), (hnodes, inodes))

>>> print(wih)
[[ 0.42839628 -0.06340692 0.56184273]
[ 0.68782127 0.26616802 -0.35102333]
[ 0.94193363 -0.23215167 -0.04476711]
[ 0.07428921 0.2611703 0.62385664]]

>>> onodes = 5

>>> who = numpy.random.normal(0.0, pow(onodes, -0.5), (onodes, hnodes))
>>> print(who)
[[-0.67086208 -0.52284949 -0.42519078 -0.18905072]
[-0.06233622 0.28244396 0.48764996 0.37380161]
[-0.39578609 0.38793941 0.13654488 -0.29288628]
[ 0.0166917 -0.25951234 0.42738579 0.54957168]
[ 0.04814966 -0.47340498 0.42078217 -0.40487517]]


neuralNetwork 크래스의 초기화 함수에 신경망의 연결강도를 임의의 난수 대신 정규 확률 분포를 갖도록 아래와 같이 추가한다.

self.wih = numpy.random.normal(0.0, pow(self.hnodes, -0.5),
                                         (self.hnodes, self.inodes))
self.who = numpy.random.normal(0.0, pow(self.onodes, -0.5),
                                         (self.onodes, self.hnodes))

2-3. 조회 함수, query()

조회 함수 query()는 노드들의 출력 계산을 수행한다. 전방향 신경망 처리다. 신경망을 구성하는 각층의 노드들 사이에 가중치를 곱한 누적 값을 활성(발화)함수 (시그모이드 함수)를 통하여 출력을 계산한다.


연결 강도 곱의 누적은 행렬과 벡터의 내적(inner product)이다. i층의 출력 벡터 O_i 에 대하여 연결 강도 행렬 W_ij의 내적은 다음과 같다. 입력층의 노드 갯수가 3, 출력(은닉)층의 노드 갯수는 4일 경우,


파이썬의 numpy 모듈은 행렬과 벡터의 내적을 처리하는 함수를 가지고 있다.

    I_hidden = numpy.dot(self.wih, I)

높은 추상화 수준의 언어(객체 선언과 할용이 매우 유연하다)인 파이썬은 라이브러리 구축과 활용에 매우 유리하다. 많은 사용자들에 의해 방대한 라이브러리(모듈)들을 공유하고 있다. 비교적 현대적인 언어로서 과학기술 계산, 자료처리(인공지능), 데이터 시각화 등 다양한 라이브러리들이 있다. 출력층 노드의 최종 값은 발화함수의 출력이다. 발화 함수(시그모이드 함수)를 거친 은닉층의 출력은 다음과 같다.

    O_hidden = sigmoid(I_hidden)

파이썬 SciPy 라이브러리에 시그모이드 함수는 expit() 다. 이 함수를 사용하기 위해 라이브러리를 들여 오자.

# scipy.special for the sigmoid function expit()
import scipy.special

노드의 출력을 결정하는 활성 함수는 시그모이드 외에 다양하게 구현된다. 굳이 복잡한 지수함수를 가진 시그모이드 보다 좀더 단순화된 함수를 사용한다. 계산과 구현의 단순화를 위해 연속함수 대신 불연속 함수가 사용되기도 한다. 대규모 계산을 요구하는 신경망을 감안하면 연산기 단순화(값의 양자화)가 실용적인 면에서 중요한 과제다. 응용에 따라 효율적인 발화함수의 구현은 나중으로 미루고 시그모이드 함수의 원형[참고]을 발화 함수로 사용하기로 한다.

# activation function is the sigmoid function
self.activation_function = lambda x: scipy.special.expit(x)

'람다(lambda)'식(expression)은 익명으로 '함수'를 기술하는 기법이다. 한 문장으로 함수를 간결하게 기술할 수 있다.

    lambda 인자: 표현식

람다 식으로 기술한 활성함수를 사용하는 방법은 일반 함수와 같다.

    hidden_output = self.activation_function(O_hidden)

3개층으로 구성된 신경망을 구성하는 입력층과 은닉층 사이의 노드 연결과 출력은 다음과 같이 기술할 수 있다. 입력 벡터와 연결 강도(가중치) 행렬의 내적과 활성함수 적용후 출력이다.

hidden_inputs = numpy.dot(self.wih, inputs)
hidden_outputs = self.activation_function(hidden_inputs)


동일한 방식으로 은닉층과 출력층을 연결하여 신경망 최종 출력을 얻는다.

final_inputs = numpy.dot(self.who, hidden_outputs)
final_outputs = self.activation_function(final_inputs)

초기화와 조회 함수가 포함된 파이썬 코드를 실행해보자. 학습 함수 train() 은 아직 작성 전이다.

$ python3

Python 3.12.3 (main, Mar 3 2026, 12:15:18) [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.

>>>
>>> import numpy
>>> import scipy.special

파이썬 파일을 읽어 실행,

>>> exec(open('Code_init_query.py').read())

신경망 크래스 사례화하여 소규모 신경망 만들기,

>>> input_nodes = 5
>>> hidden_nodes = 4
>>> output_nodes = 3
>>> learning_rate = 0.3

>>> n = neuralNetwork(input_nodes, hidden_nodes, output_nodes, learning_rate)

신경망 크래스 객체가 사례화 하면서 신경망이 구성된다. 초기화 함수(구성자)에 의해 임의 값으로 초가화 한 입력층과 은닉층 사이의 연결 가중치 행렬 확인해보자.

입력층과 은닉층 사이의 연결 가중치 행렬 확인,

>>> print(n.wih)
[[-0.78217197 0.5808482 -0.31876802 1.64631682 -0.28874034]
[ 0.17742955 -0.26017423 -0.55911973 -0.40947595 0.41022274]
[-0.28092837 -0.04689308 -0.25862563 -0.67334743 -0.85635187]
[-0.44649879 0.31916959 0.05223414 -0.06373131 -0.46721884]]

은닉층과 출력층 사이의 연결 가중치 행렬 확인,

>>> print(n.who)
[[ 0.64205114 0.50799958 1.45701532 -1.02826693]
[ 0.56086822 0.46135494 -0.55627495 0.40996956]
[-0.63063029 -0.71400507 0.82037014 1.43052149]]

입력층은 이 1행짜리 리스트 형식이므로 행렬 내적을 수행 하려면 전치(transpose)행렬로 바꿔 주어야 한다.

    inputs = numpy.array(inputs_list, ndmin=2).T

신경망(순방향)을 실행해 보자.

>>> o = n.query([1.0, 0.5, -1.5, 1.5, 2.0])
[[ 1. ]
[ 0.5]
[-1.5]
[ 1.5]
[ 2. ]]
>>> print(o)
[[0.69641302]
[0.7060551 ]
[0.32236108]]


아직 의미있는 훈련을 하지 않았지만 신경망의 순방향 작동을 확인 해봤다. 파이썬을 사용하면 최소한의 코드로 알고리즘을 기술 할 수 있다.

2-4. 훈련 함수, train()

이제 신경망을 훈련시켜보자. 신경망은 3개 층으로 구성되었으며 각 층마다 L, M, N개의 노드를 가지도록 초기화 되었다. 훈련은 먼저 순방향 계산을 수행하고 이를 토대로 목표와 차분을 역전파하여 가중치를 갱신한다.


학습 함수 train()은 두개의 인자(시험입력과 목표)를 갖는다.

# train the neural network
def train(self, inputs_list, targets_list):

시험입력 input_list는 1행 L렬이므로 연결 가중치 행렬과 내적을 수행하기 위해 전치(transpose)하여 L행 1렬로 변환 한다. 학습 폭표 target_list는 N행 1렬 벡터로 변환한다.  numpy.array().T는 배열의 행과 열의 위치를 바꿔준다.

    inputs = numpy.array(inputs_list, ndmin=2).T
    targets = numpy.array(targets_list, ndmin=2).T

함수 numpy.dot()룰 사용하여 가중치 행렬과 입력 벡터를 내적(inner product)한다. 내적을 수행하려면 가중치 행렬 wih의 열의 갯수와 입력 벡터 input의 행의 갯수가 일치해야 한다. 입력층과 은닉층 사이의 연결강도가 저장된 행렬의 크기는 M행 L렬이다.

    hidden_inputs = numpy.dot(self.wih, inputs)
    hidden_outputs = self.activation_function(hidden_inputs)

이어 은닉층과 출력층의 순방향 계산이다. 연결강도 행렬은 who는 N행 M렬이다.

    final_inputs = numpy.dot(self.who, hidden_outputs)
    final_outputs = self.activation_function(final_inputs)

순방향 처리로 신경망의 현재 출력 final_output을 계산했다. 목표와 차로 오차 output_error를 구한 후 연결강도 행렬 who의 전치행렬과 내적으로 은닉층 오차 hidden_errors 를 구한다. 오차의 역전파(back-propagation)이다.

    output_errors = targets - final_outputs
    hidden_errors = numpy.dot(self.who.T, output_errors)


1부에서 다뤘던 가중치 갱신량은 다음과 같다. 활성함수는 시그모이드 함수다.


은닉층과 출력층 사이의 가중치 행렬의 갱신은 다음과 같다.

    self.who += self.lr * numpy.dot(
        (output_errors * final_outputs * (1.0 - final_outputs)), 
        numpy.transpose(hidden_outputs))

오차 역전파로 은닉층의 오차를 구해 놓았다. 이를 바탕으로 입력층과 은닉층 사이의 가중치 행렬을 갱신하면 다음과 같다.


    self.wih += self.lr * numpy.dot(
        (hidden_errors * hidden_outputs * (1.0 - hidden_outputs)), 
        numpy.transpose(inputs))

학습 함수까지 작성된 신경망 파이썬 코드는 아래 링크에서 받을 수 있다.

https://github.com/makeyourownneuralnetwork/makeyourownneuralnetwork/blob/master/part2_neural_network.ipynb

3. 소규모 신경망 실습

간단한 신경망 이지만 여러 실험을 해볼 수 있다. 아래의 테스트벤치 코드를 가지고 실험해보자.

$ python3
Python 3.12.3 (main, Mar 23 2026, 19:04:32) [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 

미리 작성해둔 신경망을 읽어 실행,

>>> import os
>>> exec(open('./part2_neural_network.py').read())

각 층의 노드수와 학습율을 주고 신경망을 구성한다.

>>> input_nodes = 3
>>> hidden_nodes = 20
>>> output_nodes = 8
>>> learning_rate = 0.3
>>> n = neuralNetwork(input_nodes, hidden_nodes, output_nodes, learning_rate)

초기 연결 가중치 행렬을 보자.

>>> print(n.wih)
[[ 0.23480486 -0.62048203  0.48227183]
 [-0.44191224  0.96688793  0.37256845]
 [ 0.73100745  0.9149449   0.55167343]
    . . . . . .
 [-0.381088    0.11340818 -0.23275817]
 [ 0.22929161 -0.65477635  0.41994934]
 [-0.38261365  0.21811293  0.44382208]]
>>> print(n.who)
[[-0.05045051 -0.09326337 . . . . .  -0.03752638  0.01446497]
 [ 0.4941326  -0.20127579 . . . . .  -0.00389773 -0.17173596]
 [ 0.18257659  0.02078628 . . . . .   0.03690485  0.11091558]
    . . . . . .
 [ 0.1637788  -0.10455684 . . . . .  -0.02724405  0.0628117 ]]

입력과 목표를 주고 훈련시킨다. 훈련 목적은 3x8 디코더다. 훈련이 용이하도록 다음과 같은 함수를 작성했다.

# Filename: train_3x8_decoder.py
def train_3x8_decoder(neuralNetwork):
    for n in range(8):
        target_list = numpy.zeros(8)
        target_list[n] = 0.99

        input_list = numpy.zeros(3)
        m = n
        for i in range(3):
            if (m & 1): input_list[i] = 0.99
            else :      input_list[i] = 0.0
            m >>=1
            pass
        pass
        print("Input :", input_list)
        print("Target:", target_list)
        neuralNetwork.train(input_list, target_list)
    pass

3x8 디코더 훈련 함수를 실행 시켜보자.

>>> exec(open('./train_3x8_decoder.py').read())
>>> train_3x8_decoder(n)
Input : [0. 0. 0.]
Target: [0.99 0.   0.   0.   0.   0.   0.   0.  ]
Input : [0.99 0.   0.  ]
Target: [0.   0.99 0.   0.   0.   0.   0.   0.  ]
Input : [0.   0.99 0.  ]
Target: [0.   0.   0.99 0.   0.   0.   0.   0.  ]
Input : [0.99 0.99 0.  ]
Target: [0.   0.   0.   0.99 0.   0.   0.   0.  ]
Input : [0.   0.   0.99]
Target: [0.   0.   0.   0.   0.99 0.   0.   0.  ]
Input : [0.99 0.   0.99]
Target: [0.   0.   0.   0.   0.   0.99 0.   0.  ]
Input : [0.   0.99 0.99]
Target: [0.   0.   0.   0.   0.   0.   0.99 0.  ]
Input : [0.99 0.99 0.99]
Target: [0.   0.   0.   0.   0.   0.   0.   0.99]

입력을 주고 신경망의 결과를 보자.

>>> input_list = [0.0, 0.99, 0.0]
>>> print(input_list)
[0.0, 0.99, 0.0]
>>> n.query(input_list)
array([[0.28647384],
       [0.31572146],
       [0.2357812 ],
       [0.30340183],
       [0.24903345],
       [0.24786613],
       [0.34638421],
       [0.31687699]])

1번의 훈련으로는 신통치 않다. 훈련을 50회 반복하고,

>>> for epoch in range(50):
...     train_3x8_decoder(n)
...

신경망을 시험해 본다.

>>> print(input_list)
[0.0, 0.99, 0.0]
>>> n.query(input_list)
array([[0.15615978],
       [0.05668246],
       [0.46297304],
       [0.27559223],
       [0.07328362],
       [0.02519343],
       [0.27244608],
       [0.15757236]])

모든 경우의 입력에 대하여 신경망을 시험하기 위해 테스트 함수를 다음과 같이 작성했다.

# Filename: query_3x8_decoder.py
def query_3x8_decoder(neuralNetwork):
    for n in range(8):
        input_list = numpy.zeros(3)
        m = n
        for i in range(3):
            if (m & 1): input_list[i] = 0.99
            else :      input_list[i] = 0.0
            m >>=1
            pass
        pass
        print("Input :", input_list)
        final_outputs = neuralNetwork.query(input_list)
        print("Result:\n", final_outputs)
    pass

테스트 함수를 불러 실행,

>>> exec(open('./query_3x8_decoder.py').read())
>>> query_3x8_decoder(n)
Input : [0. 0. 0.]
Result:
[[0.30495533]
 [0.23650692]
 [0.16199468]
 [0.11086559]
 [0.17795601]
 [0.11836501]
 [0.12903878]
 [0.12213821]]
Input : [0.99 0.   0.  ]
Result:
[[0.20313592]
 [0.53596938]
 [0.05762545]
 [0.25535589]
 [0.08108751]
 [0.27341848]
 [0.04069108]
 [0.14628759]]
Input : [0.   0.99 0.  ]
Result:
[[0.15615978]
 [0.05668246]
 [0.46297304]
 [0.27559223]
 [0.07328362]
 [0.02519343]
 [0.27244608]
 [0.15757236]]
Input : [0.99 0.99 0.  ]
Result:
[[0.10370655]
 [0.17066447]
 [0.19996303]
 [0.48574626]
 [0.03205448]
 [0.06564895]
 [0.09148218]
 [0.18006337]]
Input : [0.   0.   0.99]
Result:
[[0.16357412]
 [0.07169463]
 [0.06815046]
 [0.03221097]
 [0.46463536]
 [0.30841241]
 [0.27241288]
 [0.14081779]]
Input : [0.99 0.   0.99]
Result:
[[0.10043521]
 [0.20833083]
 [0.02399164]
 [0.08000641]
 [0.22380893]
 [0.50919171]
 [0.09650261]
 [0.16372008]]
Input : [0.   0.99 0.99]
Result:
[[0.0869016 ]
 [0.02090949]
 [0.23479213]
 [0.08604624]
 [0.19224772]
 [0.0612008 ]
 [0.42393379]
 [0.15282409]]
Input : [0.99 0.99 0.99]
Result:
[[0.05329815]
 [0.06279431]
 [0.08822157]
 [0.18996017]
 [0.08444943]
 [0.15224292]
 [0.17588012]
 [0.18897843]]

[과제1] 마지막 입력에 대한 결과가 신통치 않게 나왔다. 학습 횟수를 늘려 시험해 보라.
[과제2] 학습이 반복되면서 가중치의 변화를 시각적으로 표시해보라.

우리는 인코딩된 2진수를 디코딩하는 알고리즘은 이미 잘 알고있다. 이를 모르는 기계(신경망)에게 2진수 입력을 주고 디코더를 학습 시켜봤다. 결과를 얻기 위해 상당히 많은 회수의 학습이 필요하다. 소규모 신경망으로 할 수 있는 일은 아주 비효율적이다. 파이썬으로 작성한 코드가 작동 한다는 점 만 확인하는데 만족하자. 신경망이 효과를 거두려면 대규모 네트워크를 구성하여 방대한 자료를 기반으로 학습해야 한다. 다음에는 본격적으로 손글씨 이미지를 받아 훈련시키고 인식하는 신경망을 작성해 보기로 한다.

-------------------------------------------------------------------------------------
[이전] [내 신경망 만들기/2부] 1. 최소한의 파이썬(찬조출연: C++)
[다음] [내 신경망 만들기/2부] 3. 손글씨 인식 신경망(MNIST 데이터 셋)