신경망이란 무엇일까요? 시작하기 위해 퍼셉트론이라는 인공 뉴런 유형에 대해서 설명하겠습니다. 퍼셉트론은 1950년대와 1960년대에 과학자 프랭크 로젠블랫이 워런 맥컬로치와 월터 피츠의 초기 연구에서 영감을 받아 개발했습니다. 오늘날에는 다른 인공 뉴런 모델을 사용하는 것이 더 일반적입니다. 이 책과 신경망에 대한 많은 현대 연구에서 사용되는 주요 뉴런 모델은 시그모이드 뉴런이라고 하는 모델입니다. 곧 시그모이드 뉴런에 대해 살펴보겠습니다. 하지만 시그모이드 뉴런이 왜 그런 식으로 정의되는지 이해하려면 먼저 퍼셉트론을 이해하는 것이 좋습니다.
그렇다면 퍼셉트론은 어떻게 작동할까요? 퍼셉트론은 $x_1, x_2, ...$와 같이 여러 개의 이진 입력을 받아 단일 이진 출력을 생성합니다.
이것이 기본적인 수학적 모델입니다. 퍼셉트론에 대해 생각해 볼 수 있는 한 가지 방법은 증거를 평가하여 결정을 내리는 장치라는 것입니다. 예를 들어 보겠습니다. 그다지 현실적인 예는 아니지만 이해하기 쉬운 예입니다. (더 현실적인 예는 곧 살펴보겠습니다.) 주말이 다가오고 있는데, 당신이 사는 도시에서 치즈 축제가 열린다는 소식을 들었다고 가정해 보겠습니다. 당신은 치즈를 좋아해서 축제에 갈지 말지 고민하고 있습니다. 세 가지 요소를 평가하여 결정을 내릴 수 있습니다.
1. 날씨가 좋나요?
2. 당신의 남자친구나 여자친구가 함께 가고 싶어하나요?
3. 축제 장소가 대중교통과 가까운가요? (자가용이 없음)
우리는 이 세 가지 요소를 $x_1, x_2, x_3$와 같이 이진 변수로 표현할 수 있습니다. 예를 들어 날씨가 좋다면 $x_1 = 1$, 그렇지 않으면 $x_1 = 0$과 같이 표현할 수 있습니다. 마찬가지로, 남자친구나 여자친구가 가고 싶어한다면 $x_2 = 1$, 그렇지 않으면 $x_2 = 0$과 같이 표현할 수 있습니다. 대중교통의 이용 가능여부에 대해서도 x3로 마찬가지로 표현할 수 있습니다.
이제, 당신이 치즈를 정말 좋아해서 남자친구나 여자친구가 치즈에 관심이 없고 축제에 가기 어려워도 기꺼이 축제에 간다고 가정해 봅시다. 하지만 당신은 악천후를 정말 싫어해서 날씨가 좋지 않으면 축제에 갈 생각이 전혀 없을 수도 있습니다. 퍼셉트론을 사용하여 이러한 의사 결정을 모델링할 수 있습니다. 한 가지 방법은 날씨를 위한 가중치인 $w_1 = 6$으로 선택하고, 다른 조건을 위한 가중치인 $w_2 = 2, w_3 = 2$와 같이 가중치를 선택하는 것입니다. 다른 가중치에 비해 $w_1$에 큰 값을 선택했다는 것은 당신에게 날씨가 매우 중요하다는 것을 나타냅니다. 남자친구나 여자친구가 당신과 함께 가는지, 대중교통이 가까운지 보다 훨씬 더 중요합니다. 마지막으로 당신이 퍼셉트론에 대한 임계값으로 5를 선택했다고 가정해봅시다. 이 퍼셉트론은 다음과 같은 의사결정모델을 구현하게 됩니다. 날씨가 좋을 때마다 1, 날씨가 안좋을 때는 0을 출력하게 됩니다. 남자친구나 여자친구가 가고 싶어 하든, 대중교통이 근처에 있든 결과는 크게 다르지 않습니다.
가중치와 임계값을 변경하면 다양한 의사결정모델을 얻을 수 있습니다. 예를 들어 임계값을 3으로 선택했다고 가정해 보겠습니다. 그러면, 퍼셉트론은 날씨가 좋을 때, 또는 축제 장소가 대중교통과 가까우면서 남자친구나 여자친구가 함께 갈 의향이 있을 때 축제에 가야한다고 판단할 것입니다. 다시 말해, 이는 다른 의사결정모델이 될 것입니다. 임계값을 낮추면 축제에 갈 의향이 더 커진다는 것을 의미합니다.
물론 퍼셉트론이 인간 의사결정의 완전한 모델은 아닙니다. 하지만 이 예시는 퍼셉트론이 어떻게 다양한 종류의 증거를 평가하여 결정을 내릴 수 있는지를 보여줍니다. 그리고 복잡한 퍼셉트론 네트워크가 매우 미묘한 결정을 내릴 수 있다는 것은 타당해 보입니다.
이 네트워크에서 첫 번째 퍼셉트론 열(첫 번째 퍼셉트론 계층)은 입력 증거를 평가하여 세 가지 메우 간단한 결정을 내립니다. 두 번째 계층의 퍼셉트론은 어떨까요? 각 퍼셉트론은 첫 번째 계층의 의사결정 결과를 평가하여 결정을 내립니다. 이러한 방식으로 두 번째 계층의 퍼셉트론은 첫 번째 계층의 퍼셉트론보다 더 복잡하고 추상적인 수준에서 결정을 내릴 수 있습니다. 그리고 세 번째 계층의 퍼셉트론은 훨씬 더 복잡한 결정을 내릴 수 있습니다. 이러한 방식으로 다층 퍼셉트론 네트워크는 정교한 의사결정에 참여할 수 있습니다.
그런데, 제가 퍼셉트론을 정의할 때 퍼셉트론은 출력이 하나뿐이라고 말씀드렸습니다. 위의 네트워크에서 퍼셉트론은 여러 개의 출력을 가진 것처럼 보입니다. 하지만 실제로 단일 출력입니다. 여러 개의 출력 화살표는 단시 퍼셉트론의 출력이 여러 다른 퍼셉트론의 입력으로 사용되고 있음을 나타내는 유용한 방법일 뿐입니다. 하나의 출력선을 그린 후에 여러 개의 출력선으로 분리하는 것보다 훨씬 편리합니다.
저는 퍼셉트론을 의사결정을 우해 증거를 평가하는 방법으로 설명했습니다. 퍼셉트론을 사용할 수 있는 또 다른 방법은 우리가 일반적으로 기본 계산이라고 생각하는 기본적인 논리 함수, 즉 $AND, OR, NAND$와 같은 함수를 사용하는 것 입니다. 예를 들어, 각각 -2의 가중치를 갖는 두 개의 입력을 가진 퍼셉트론이 있다고 가정해 보겠습니다. 그리고 전반적인 편향은 3의 값을 갖는다고 하겠습니다. 그 퍼셉트론은 다음과 같습니다.
이 퍼셉트론은 입력값 $00$에 대하여 $(-2)*0 + (-2)*0 + 3 = 3$의 양의 출력값을 산출해냅니다. 여기서 * 기호는 곱셈을 뜻합니다. 유사하게 $01$이나 $10$의 입력값에 대해서도 1의 출력값이 나오는 것을 계산해볼 수 있습니다. 그러나, 11의 입력값에 대해서는 $(-2)*1 + (-2)*1 + 3 = -1$로 출력값이 0이 됩니다. 따라서, 이 퍼셉트론은 $NAND$ 게이트를 구현한 것과 같습니다.
이 $NAND$ 예시는 퍼셉트론을 사용하여 간단한 논리함수를 계산할 수 있음을 보여줍니다. 실제로 퍼셉트론 네트워크를 사용하여 모든 논리 함수를 계산할 수 있습니다. 그 이유는 $NAND$ 게이트가 계산에 보편적으로 사용되기 때문입니다. 즉, $NAND$ 게이트를 사용하여 모든 계산을 구현할 수 있습니다. 예를 들어 $NAND$ 게이트를 사용하여 $x_1, x_2$ 두 비트를 더하는 회로를 만들 수 있습니다. 이것은 비트수준에서 더하기인 $x_1 \oplus x_2$와 올림 비트(carry bit, $x_1$과 $x_2$가 모두 1일 때 1로 설정)를 계산해야 합니다. 올림 비트는 비트수준의 곱샘인 $x_1x_2$로 계산될 수 있습니다.
동등한 퍼셉트론 네트워크를 얻기 위해 우리는 모든 $NAND$ 게이트를 각각 -2의 가중치를 갖는 두 개의 입력과 전체 편향 3을 갖는 퍼셉트론으로 교체합니다. 그 결과 네트워크는 다음과 같습니다.
이 퍼셉트론 네트워크의 주목할 만한 측면 중 하나는 가장 왼쪽 퍼셉트론의 출력이 가장 아래쪽 퍼셉트론의 입력으로 두 번 사용된다는 것입니다. 제가 퍼셉트론 모델을 정의할 때 이러한 종류의 이중 출력이 같은 위치에 허용되는지 여부를 언급하지 않았습니다. 사실, 이는 크게 중요하지 않습니다. 만약 이러한 것을 허용하고 싶지 않다면, 두 개의 연결에 -2의 가중치를 부여하는 대신, 두 개의 연결을 병합하여 -4의 가중치를 가진 단일 연결로 만들 수 있습니다. 이렇게 변경하면 네트워크는 다음과 같아 집니다. 가중치가 표시되지 않은 것의 가중치는 -2이고, 모든 편향은 3이며, 표시된 단일 가중치는 -4입니다.
가산기 예제는 퍼셉트론 네트워크를 사용하여 여러 $NAND$ 게이트를 포함하는 회로를 시뮬레이션하는 방법을 보여줍니다. $NAND$ 게이트는 계산에 보편적으로 사용되므로, 퍼셉트론 또한 계산에 보편적으로 사용됩니다.
퍼셉트론의 계산적 보편성은 위안이 되기도 하지만 실망이 되기도 합니다. 퍼셉트론 네트워크가 다른 컴퓨팅 장치만큼 강력할 수 있다는 것을 알려주기 때문에 위안이 되기도 하지만, 퍼셉트론이 그저 새로운 유형의 $NAND$ 게이트인 것처럼 보이기 때문에 실망스럽기도 합니다. 이는 결코 큰 뉴스가 아닙니다.
인공 뉴런 네트워크의 가중치와 편향을 자동으로 조정할 수 있는 학습 알고리즘을 고안할 수 있다는 것이 밝혀졌습니다. 이러한 조정은 프로그래머의 직접적인 개입 없이 외부 자극에 반응하여 이루어집니다. 이러한 학습 알고리즘을 통해 인공 뉴런을 기존 논리 게이트와 근본적으로 다른 방식으로 사용할 수 있습니다. $NAND$ 게이트로 구성된 회로를 명시적으로 설계하는 대신, 신경망은 단순히 문제를 해결하는 방법을 학습할 수 있으며, 때로는 기존 회로를 직접 설계하기 매우 어려운 문제도 해결할 수 있습니다.
시그모이드 뉴런
학습 알고리즘은 정말 멋진 것처럼 들립니다. 하지만 신경망에 이러한 알고리즘을 어떻게 구현할 수 있을까요? 어떤 문제를 해결하기 위해 학습하는 데 사용하고 싶은 퍼셉트론 네트워크가 있다고 가정해 보겠습니다. 예를 들어 네트워크의 입력은 손으로 쓴 숫자 이미지를 스캔하여 얻은 원시 픽셀 데이터일 수 있습니다. 그리고 네트워크가 가중치와 편향을 학습하여 네트워크의 출력이 숫자를 정확하게 분류하도록 하고 싶습니다. 학습이 어떻게 작동하는지 알아보기 위해 네트워크의 가중치(또는 편향)를 약간 변형한다고 가정해 보겠습니다. 이 작은 가중치 변경이 네트워크의 출력에 상응하는 작은 변화만을 일으키기를 바랍니다. 잠시 후 살펴보겠지만 이러한 특성 덕분에 학습이 가능합니다. 도식적으로 살펴보면 다음과 같습니다.
문제는 우리 네트워크에 퍼셉트론이 포함되어 있을 때는 이런 일이 일어나지 않는다는 사실입니다. 사실, 네트워크 내 어떤 퍼셉트론 하나의 가중치나 편향이 조금만 변해도 그 퍼셉트론의 출력이 0에서 1로 완전히 뒤집힐 수 있습니다. 이러한 반전은 나머지 네트워크의 동작을 매우 복잡한 방식으로 완전히 변화시킬 수 있습니다. 따라서 "9"는 이제 올바르게 분류될 수 있지만, 다른 모든 이미지에 대한 네트워크의 동작은 제어하기 어려운 방식으로 완전히 바뀌었을 가능성이 높습니다. 이로 인해 네트워크가 원하는 동작에 더 가까워지도록 가중치와 편향을 점진적으로 수정하는 방법을 파악하기 어렵습니다. 아마도 이 문제를 해결할 수 있는 영리한 방법이 있을 것입니다. 하지만 퍼셉트론 네트워크가 어떻게 학습할 수 있도록 할 수 있는지는 아직 명확하게 드러나지 않았습니다.
이 문제는 시그모이드 뉴런이라는 새로운 유형의 인공 뉴런을 도입함으로써 해결할 수 있습니다. 시그모이드 뉴런은 퍼셉트론과 유사하지만, 가중치와 편향의 작은 변화에도 출력의 작은 변화만 발생하도록 수정되었습니다. 이것이 시그모이드 뉴런 네트워크 학습을 가능하게 하는 핵심 요소입니다.
이제 시그모이드 뉴런에 대해서 설명하도록 하겠습니다. 퍼셉트론을 묘사했던 것과 같은 방식으로 시그모이드 뉴런을 묘사해 보겠습니다.
퍼셉트론과 마찬가지로 시그모이드 뉴런에는 $x_1, x_2, ..$ 와 같은 입력이 있습니다. 그러나 단지 0과 1값만 갖는 것이 아니라, 이들의 입력값은 0과 1사이의 어떤 값도 될 수 있습니다. 예를 들어 0.638과 같은 값은 시그모이드 뉴런의 유효한 입력입니다. 또한 퍼셉트론과 마찬가지로 시그모이드 뉴런은 각 일벽에 대해 $w_1, w_2, ...$와 같은 가중치와 전체에 대한 편향값 $b$를 갖습니다. 출력값도 0이나 1 대신에 $\sigma (w \cdot x + b)$의 시그모이드($\sigma$) 함수값을 갖습니다. 시그모이드 함수는 아래와 같이 정의됩니다.
$\sigma (z) \equiv \frac {1}{1+e^{-z}}. \tag{3}$
$x_1, x_2, ..$의 입력과 $w_1, w_1, ..$의 가중치, 그리고 편향값 $b$를 갖는 시그모이드 뉴런의 출력은 다음과 같습니다.
$\frac{1}{1+\exp(-\sum_j w_j x_j-b)}.\tag{4}$
언듯 보기에 시그모이드 뉴런은 퍼셉트론과 매우 다르게 보입니다. 시그모이드 함수의 대수적 형태는 익숙하지 않은 사람에게 불분명하고 어렵게 보일 수 있습니다. 하지만 실제로 퍼셉트론과 시그모이드 뉴런 사이에는 많은 유사점이 있으며, 시그모이드 함수의 대수적 형태는 이해를 가로막는 진정한 장벽이라기 보다는 기술적인 세부 사항에 가깝습니다.
퍼셉트론 모델과의 유사성을 이해하려면 $z \equiv w \cdot x + b$가 큰 양수라고 가정합니다. 그러면, $e^{-z} \approx 0$이 되고, 그 결과 $\sigma(z) \approx 1$이 됩니다. 다시 말해서, $z = w \cdot x+b$가 큰 양수라면, 시그모이드 뉴런의 출력은, 퍼셉트론의 경우와 같이, 1에 가깝게 됩니다. 반대로 z가 절대값이 큰 음수라고 가정해봅시다. 이 경우에 $e^{-z} \rightarrow \infty$가 되며, $\sigma(z) \approx 0$이 됩니다. 따라서, $z = w \cdot x+b$가 절대값이 큰 음수일 경우, 시그모이드 뉴런은 퍼셉트론과 매우 유사하게 됩니다. $w \cdot x + b$의 규모가 작아서 퍼셉트론 모델과 크게 다르지 않습니다.
$\sigma$의 대수적 형태는 어떨까요? 우리는 그것을 어떻게 이해할 수 있을까요? 사실 $\sigma$의 대수적 형태는 그렇게 중요하지 않습니다. 정말 중요한 것은 그래프로 그렸을 때 함수의 모양입니다. 그 모양은 다음과 같습니다.
이 모양은 계단 함수의 매끄러운 버전입니다.
다소 혼란스러울 수 있지만, 역사적인 이유로 이러한 다층 네트워크는 퍼셉트론이 아닌 시그모이드 뉴런으로 구성되었음에 도 불구하고 다층 퍼셉트론 또는 MLP라고 불리기도 합니다. 이 책에서는 MLP라는 용어는 사용하지 않겠습니다. 혼란스러울 수 있기 때문입니다. 하지만 MLP라는 용어가 존재한다는 점은 미리 알려드리고 싶었습니다.
네트워크의 입력 및 출력 계층 설계는 종종 간단합니다. 예를 들어 손으로 쓴 이미지가 "9"를 나타내는지 여부를 판별하려고 한다고 가정해 보겠습니다. 네트워크를 설계하는 자연스러운 방법은 이미지 픽셀의 강도를 입력 뉴런에 인코딩하는 것입니다. 이미지가 64 X 64 흑백 이미지라면, 0과 1사이의 강도를 갖는 4,096 (64X64)의 입력 뉴런을 갖도록 설계할 수 있을 것입니다. 출력 계층은 0.5보다 작은 값은 "9"가 아님을 나타내고, 그 이상은 "9"임을 나타내는 하나의 뉴런을 갖도록 할 수 있을 것입니다.
신경망의 입력 및 출력 계층의 설계는 종종 간단하지만, 은닉 계층의 설계에는 상당한 기술이 필요합니다. 특히, 은닉 계층의 설계 과정을 몇몇 간단한 경험 법칙으로 요약할 수 없습니다. 대신, 신경망 연구자들은 사람들이 신경망에 원하는 행동을 얻을 수 있도록 돕는 은닉 계층 설계 휴리스틱을 개발했습니다. 예를 들어, 이러한 휴리스틱은 은닉 계층 수와 신경망 학습 시간을 어떻게 절충할지 결정하는 데 도움이 될 수 있습니다. 이 책의 뒷부분에서 이러한 여러 설계 휴리스틱을 살펴보겠습니다.
지금까지 한 계층의 출력이 다음 계층의 입력으로 사용되는 신경망에 대해 논의해 왔습니다. 이러한 신경망을 순전파(Feedforward) 신경망 이라고 합니다. 이는 네트워크에 루프가 없다는 것을 의미합니다. 즉, 정보는 항상 순전파되고, 역전파(Feedback)되지 않습니다. 만약 루프가 있다면, $\sigma$함수의 입력이 출력에 의해 결정되는 상황이 발생할 수도 있습니다. 이것은 이해하기 어려울 수 있으므로 이러한 루프는 허용하지 않도록 하겠습니다.
하지만 역전파 루프가 가능한 다른 인공 신경망 모델도 있습니다. 이러한 모델을 순환 신경망(Recurrent Neural Network)이라고 합니다. 이 모델의 핵심은 뉴런이 휴면 상태가 되기 전에 제한된 시간 동안 발화하는 것입니다. 이 발화는 다른 뉴런을 자극할 수 있으며, 이 뉴런들은 잠시 후, 역시 제한된 시간동안 발화할 수 있습니다. 이로 인해 더 많은 뉴런이 발화하게 되고, 시간이 지남에 따라 뉴런의 발화가 연쇄적으로 일어납니다. 이러한 모델에서는 루프가 문제를 일으키지 않습니다. 뉴런의 출력이 입력에 즉시 영향을 미치는 것이 아니라 나중에 어느 시점에야 입력에 영향을 미치기 때문입니다.
손으로 쓴 숫자를 분류하는 간단한 네트워크
신경망을 정의했으니, 이제 필기 인식으로 돌아가 보겠습니다. 필기 숫자 인식 문제는 두가지 하위 문제로 나눌 수 있습니다. 첫째, 여러 숫자가 포함된 이미지를 각각 숫자가 하나씩 포함된 여러 개의 이미지 시퀀스로 분할하는 방법을 찾고 싶습니다. 예를 들어 이미지를 다음과 같이 분할하고 싶습니다.
인간은 이 분할 문제를 쉽게 해결하지만, 컴퓨터 프로그램이 이미지를 정확하게 분할하는 것은 어렵습니다. 이미지가 분할되면 프로그램은 각 숫자를 분류해야합니다. 예를 들어, 프로그램이 위의 첫 번째 숫자를 인식하도록 하고 싶습니다.
5입니다.
두 번째 문제, 즉 개별 숫자를 분류하는 프로그램을 작성하는 데 집중하겠습니다. 이렇게 하는 이유는 개별 숫자를 분류하는 좋은 방법을 알게 되면 분할 문제가 그렇게 어렵지 않다는 것이 밝혀졌기 때문입니다. 분할 문제를 해결하는 데는 여러 가지 접근 방식이 있습니다. 한 가지 접근방식은 개별 숫자 분류기를 사용하여 이미지를 여러 가지 분할 방법을 시도하고, 각 분할 시도에 점수를 매기는 것입니다. 개별 숫자 분류기가 모든 분할에서 분류에 자신 있다면 불할 시도는 높은 점수를 받고, 하나 이상의 분할에서 분류기가 큰 어려움을 겪고 있다면 낮은 점수를 받습니다. 분류기가 어딘가에서 어려움을 겪고 있다면, 아마도 분할이 잘못 선택되었기 때문일 것입니다. 이 아이디어와 다른 변형들은 분할 문제를 매우 잘 해결하는 데 사용될 수 있습니다. 따라서 분할에 대해 고민하는 대신, 더 흥미롭고 어려운 문제, 즉 개별 필기 숫자 인식을 해결할 수 있는 신경망 개발에 집중하겠습니다.
개별 숫자를 인식하기 위해 3층 신경망을 사용합니다.
네트워크 입력층에는 입력 픽셀 값을 인코딩하는 뉴런이 포함됩니다. 다음 섹션에서 설명하겠지만, 네트워크의 학습 데이터는 28X28 픽셀의 손으로 쓴 숫자 이미지입니다. 따라서, 입력 계층은 784 (28X28)개의 뉴런으로 구성됩니다. 위 그림에서 단순화를 위해서 784개의 입력 뉴런의 대부분을 생략했습니다. 입력 픽셀은 흑백이며 0.0은 흰색을 1.0은 검은색을 나타내고 그 사이 값은 점차 어두워지는 회색 음영을 나타냅니다.
네트워크의 두 번째 층은 은닉층입니다. 이 은닉층의 뉴런 수를 $n$으로 나타냅니다. 우리는 이 은닉층의 수를 바꿔가며 다양한 실험을 해볼 예정입니다. 위 그림에서는 15개의 뉴런을 가진 작은 은닉층을 보여주고 있습니다.
네트워크의 출력 계층에는 10개의 뉴런이 있습니다. 첫 번째 뉴런이 활성화 되면, 즉 첫 번째 뉴런의 출력이 발생하면, output $\approx 1$, 네트워크는 숫자를 0으로 판단한다는 뜻입니다. 두 번째 뉴런이 활성화 되면, 네트워크는 숫자를 1로 판단한다는 뜻입니다. 그 다음들도 마찬가지 입니다. 조금 더 명확하게 이야기 하면 우리는 출력 뉴런에 0 부터 9까지의 숫자를 붙이고, 어떤 출력 뉴런에 가장 높은 활성화 값을 가질지를 파악할 것입니다. 만약 가장 높은 활성화 값을 갖는 뉴런이 6번 뉴런이라면, 그것은 (손으로 쓴) 입력 숫자를 6으로 추측한다는 뜻입니다. 그리고, 다른 출력 뉴런에 대해서도 마찬가지 입니다.
왜 10개의 출력 뉴런을 사용하는지 궁금할 수 있습니다. 결국 네트워크의 목표는 입력 이미지가 0, 1, 2, ..., 9 중 어떤 숫자에 해당하는지를 알려주는 것입니다. 이것을 하는 가장 자연스러운 방법은 그 뉴런의 값이 0과 1에 가까운지에 따라 2진 값을 갖는 4개의 출력 뉴런을 사용하는 것입니다. 4개의 뉴론은 10개의 입력 숫자를 나타내기 충분한 16($2^4$)가지를 나타낼 수 있으니 충분합니다. 그런데도 왜 우리의 네트워크에 10개의 뉴런을 사용해야할까요? 이렇게 하는 것은 비효율적이지 않나요? 궁극적인 대답은 경험적으로 할 수 밖에 없습니다. 두 가지 네트워크 설계를 모두 시도해볼 수 있으며, 이 특정 문제에 대해서는 10개의 출력 뉴런 네트워크는 4개의 출력 뉴런 네트워크 보다 숫자를 더 잘 인식하는 법을 배웁니다. 그러나 여전히 의문은 남습니다. 왜 10개의 출력을 이요하는 뉴런이 더 잘 동작할까요? 10개의 출력 인코딩이 4개의 출력 인코딩 보다 낫다는 것을 미리 말해줄 휴리스틱이 있을까요?
왜 이렇게 하는지 이해하려면 신경망이 어떤 역할을 하는지 기본 원리부터 생각해 보는 것이 좋습니다. 먼저 10개의 출력 뉴런을 갖는 신경망을 사용하는 경우를 생각해 보겠습니다. 숫자가 0인지를 판단하는 첫 번째 출력 뉴런에 집중해 보겠습니다. 첫 번째 출력 뉴런은 은닉 계층의 뉴런들로 부터의 증거를 평가함으로써 이를 수행합니다. 은닉층의 뉴런들은 무엇을 할까요?논의를 위해 은닉층의 첫 번째 뉴런이 다음과 같은 이미지가 있는지 감지한다고 가정해 보겠습니다.
따라서 이 숨겨진 뉴런 4개가 모두 발화되면 숫자가 0 이라고 결론 내릴 수 있습니다. 물론, 그것이 그 이미지가 0이라고 결론내릴 수 있는 유일한 증거라고 할 수는 없습니다. 다른 방법으로 0으로 판단할 수 있는 경우도 많습니다. 하지만 적어도 이 경우에는 입력이 0과 같다고 결론내릴 수 있다고 말하는 것이 안전해 보입니다.
신경망이 이런 방식으로 기능한다고 가정하면 왜 4개의 출력을 가진 네트워크 보다 10개의 출력을 가진 네트워크가 더 나은지에 대한 그럴듯한 설명일 할 수 있습니다. 첫 번째 출력 뉴런은 숫자의 최상위 비트가 무엇인지 판단하려고 할 것입니다. 그리고 그 최상위 비트를 위에 표시된 것과 같은 간단한 모양과 연관시킬 쉬운 방법은 없습니다. 숫자의 구성 요소 모양이 출력의 최상위 비트와 밀접한 관련이 있을 것이라고 타당한 역사적 근거가 있다고 생각하기는 어렵습니다.
자, 이 모든 것을 말했듯이, 이 모든 것은 단지 휴리스틱일 뿐입니다. 3층 신경망이 설명한 방식, 즉 은닉 뉴런이 단순한 구성 요소 모양을 감지하는 방식으로 작동해야 한다는 말은 없습니다. 어쩌면 영리한 학습 알고리즘이 우리가 사용할 수 있는 4개의 출력을 가진 네트워크의 가중치 할당을 찾아낼지도 모릅니다. 하지만 휴리스틱으로서 지금까지 설명한 사고방식은 꽤 잘 작동하며, 좋은 신경망 아키텍처를 설계하는 데 많은 시간을 절약해 줄 수 있습니다.
경사 하강법을 이용한 학습
이제 신경망 설계가 완료되었으니, 어떻게 숫자를 인식하도록 학습할 수 있을까요? 가장 먼저 필요한 것은 학습할 데이터 세트, 즉 훈련 데이터 세트입니다. MNIST 데이터 세트를 사용할 텐데, 이 데이터 세트에는 수만 개의 손으로 쓴 숫자 스캔 이미지와 그에 대한 정확한 분류 정보가 포함되어 있습니다. MNIST라는 이름은 미국 국립표준기술원(NIST)에서 수집한 두 데이터 세트의 수정된 부분 집합이라는 사실에서 유래했습니다. 다음은 MNIST에서 가져온 몇 가지 데이터 입니다.
MNIST 데이터는 두 부분으로 구성됩니다. 첫 번째 부분에는 학습 데이터로 사용할 60,000개의 이미지가 포함됩니다. 이 이미지는 250명의 스캔된 필체 샘플로, 절반은 미국 인구조사국 직원이고 절반은 고등학생입니다. 이미지는 흑백이며, 크기는 28X28 픽셀입니다. MNIST 데이터 세트의 두 번째 부분은 테스트 데이터로 사용할 10,000개의 이미지 입니다. 이것 역시 28X28의 흑백 이미지입니다. 테스트 데이터를 사용하여 신경망이 숫자를 인식하는 법을 얼마나 잘 배웠는지 평가합니다. 이를 성능 테스트의 좋은 예로 만들기 위해 테스트 데이터는 원래 학습 데이터와는 다른 250명의 사람들로 부터 가져왔습니다. (그래도 인구조사국 직원과 고등학생으로 나뉜 그룹입니다.) 이를 통해 학습 중 글씨를 보지 못한 사람들의 글씨도 시스템이 인식할 수 있다는 확신을 가질 수 있습니다.
우리는 훈련을 위한 입력을 $x$로 나타내도록 하겠습니다. 훈련을 위한 입력 $x$를 편의상 784 (28X28) 차원의 벡터로 간주하겠습니다. 벡터의 각 항목은 이미지의 하나의 픽셀의 흑백 값을 나타냅니다. 그리고, 각 입력 x에 해당 하는 바람직한 출력을 10차원 벡터인 $y = y(x)$로 나타내도록 하겠습니다. 예를 들어 6을 나타내는 특정 입력 이미지 $x$에 대하여 $y(x) = (0,0,0,0,0,1,0,0,0)^T$가 네트워크로 부터 우리가 기대하는 출력입니다. 여기서 T는 행을 열로 전환하는 연산입니다.
이제 우리에게 필요한 것은 가중치와 편향을 찾는 알고리즘입니다. 이 알고리즘으로 모든 학습을 위한 입력값 $x$에 대하여 대락의 $y(x)$를 도출할 수 있도록 할 수 있을 것입니다. 이러한 목표를 얼마나 잘 달성하는지를 계량적으로 평가하기 위하여 우리는 다음과 같이 비용 함수(Cost Function)를 정의하여 사용할 것입니다.
$C(w,b) \equiv \frac{1}{2n} \sum_x \| y(x) - a\|^2. \tag{6}$
여기, $w$는 네트워크의 모든 가중치의 집합을 나타내며, $b$는 편항을, $n$은 훈련을 위한 입력의 총 개수를, $a$는 입력값 $x$에 대한 네트워크의 출력 벡터를 나타냅니다. 그리고, 그 합은 전체 입력값 $x$에 대하여 수행합니다. 물론 출력값 a는 $x, w와 \, b$에 따라 달라집니다. 그러나, 표기의 단순화를 위하여 이 종속성을 명시적으로 표시하지는 않았습니다. $||v||$는 어떤 벡터 $v$에 대한 길이를 나타냅니다. 우리는 C를 이차 비용 함수라고 부를 것입니다. 이것은 때론 평균 제곱 오차(Mean Squared Error, MSE)라고 부르기도 합니다. 이차 비용 함수의 속성은 간략히 이러합니다. $C(w,b)$는 총 합의 각 항목이 모두 음수가 아니므로, 역시 음수가 아닙니다. 더군다나, 모든 학습을 위한 입력값 $x$에 대하여 출력 $a$가 $y(x)$와 유사하게 같을 때, $C(w,b) \approx 0$이 되어 최소가 됩니다. 다시 말해, 학습 알고리즘이 $C(w,b) \approx 0$되는 가중치와 편향을 찾아낸다면 목적을 훌륭히 달성했다고 평가할 수 있습니다. 반대로, 많은 수의 입력에 대하여 $y(x)$가 출력 $a$에 가깝지 않다면, $C(w,b)$의 값은 커지게 되어, 목적을 잘 달성하지 못했다고 평가할 수 있습니다. 따라서, 학습 알고리즘의 목표는 가중치와 편향에 대한 함수인 비용 함수 $C(w,b)$를 최소화히키는 것이라고 볼 수 있습니다. 다시 말해 우리는 비용을 최대한 작게 만드는 가중치와 편향의 집합을 찾고자 하며, 이를 위해 경사 하강법이라는 알고리즘을 사용할 것입니다.
왜 이차 비용 함수를 이용할까요? 결국 우리의 주된 관심사는 네트워크가 올바르게 분류한 이미지의 개수가 아닐까요? 이차 비용 함수와 같은 대리 측정값을 최소화하는 대신, 왜 그 개수를 직접 최대화하려고 하지 않을까요? 문제는 올바르게 분류된 이미지의 개수가 네트워크의 가중치와 편향에 대한 매끄러운 함수가 아니라는 것입니다. 대부분의 경우 가중치와 편향을 약간만 변형해도 올바르게 분류된 학습 이미지의 개수에는 아무런 변화가 없습니다. 따라서 성능 향상을 위해 가중치와 편향을 어떻게 변경해야 할지 파악하기는 어렵습니다. 이차 비용 함수와 같은 매끄러운 비용 함수를 사용하면 비용을 개선하기 위해 가중치와 편향을 어떻게 약간만 변형해야 할지 쉽게 파악할 수 있습니다. 따라서 먼저 이차 비용 함수를 최소화하는데 집중하고, 그 후에야 분류 정확도를 검토합니다.
매끄러운 비용 함수를 사용한다 하더라도, 왜 식(6)과 같은 이차 함수를 사용하는지 궁금할 수 있습니다. 다소 임시방편적인 선택은 아닐까요? 다른 비용함수를 선택하면 가중치와 편향을 최소화하는 방식이 완전히 달라지지 않을까요? 이는 타당한 고민이며, 나중에 비용 함수를 다시 살펴보고 몇 가지 수정을 가할 것입니다. 그러나 식 (6)의 이차 비용 함수는 신경망 학습의 기본을 이해하는데 매우 적합하므로, 일단 이 방법을 사용하겠습니다.
요약하자면, 신경망을 훈련하는 우리의 목표는 이차 비용함수, $C(w,b)$를 최소화하는 가중치와 편향을 찾는 것입니다. 이것은 잘 정의된 문제이지만, 가중치와 편향인 $w, b$에 대한 해석, 배경에 숨겨진 $\sigma$ 함수, 네트워크 구조의 선택, NMIST 등 몇가지 구조적 문제가 있습니다. 이러한 구조적 문제들을 무시하고 최소화 측면에만 집중하면 많은 것을 이해할 수 있습니다. 따라서 지금은 비용 함수의 구체적인 형태, 신경망과의 연결 등에 대해서는 잠시 잊도록 하겠습니다. 대신, 다변수 함수가 주어졌고 그 함수를 최소화하고 싶다고 가정해보겠습니다. 이러한 최소화 문제를 해결하는데 사용할 수 있는 경사 하강법이라는 기법을 개발할 것입니다. 그런 다음 신경망에서 최소화하려는 특정 함수로 돌아가도록 하겠습니다.
어떤 함수 $C(v)$를 최소화하려고 한다고 가정해 보겠습니다. 이 함수는 $v = v_1, v_2, ...$와 같이 여러 변수를 가진 실수 함수일 수 있습니다. 여기서 우리는 일반적인 경우를 생각하기 위하여 $w, b$를 $v$로 바꾸었습니다. $C(v)$를 최소화하기 위하여 함수 C가 $v_1, v_2$의 단 두개의 변수만을 가진 함수로 간주하도록 하겠습니다.
문제를 해결하는 한 가지 방법은 미적분을 사용하여 해석적으로 최소값을 찾는 것입니다. 미분 계산을 한 다음, 이를 사용하여 함수 C의 극값을 찾는 방법을 생각해볼 수 있습니다. 변수가 많지 않은 함수 C에 대해서는 이러한 방법이 통할 수도 있습니다. 하지만, 변수가 많아지면 이 방법을 사용하기 쉽지 않습니다. 가장 큰 신경망은 수십억 개의 가중치와 편향에 매우 복잡한 방식으로 의존하는 비용 함수를 가지고 있습니다. 이러한 경우 미적분학을 사용하여 이를 최소화하는 것은 불가능합니다.
다행히 꽤 잘 작동하는 알고리즘을 시사하는 좋은 비유가 있습니다. 먼저 함수를 일종의 계곡이라고 생각해 봅시다. 위 그림을 조금만 자세히 살펴보면 어렵지 않을 것입니다. 그리고 계곡 경사면을 따라 굴러가는 공을 상상해보세요. 우리의 일상적인 경험에 따르면 공은 결국 계곡 바닥으로 굴러갈 것입니다. 어쩌면 이 아이디어를 함수의 최소값을 찾는 방법으로 사용할 수 있을까요? (가상의) 공을 시작점을 무작위로 선택한 다음, 공이 계곡 바닥으로 굴러가는 과정을 시뮬레이션해 보겠습니다. 이 시뮬레이션은 C에 대한 변화율(도함수)(그리고 아마도 이차 미분)을 계산하는 것만으로 가능합니다. 이 변화율은 계곡의 지역적 "모양"에 대해 알아야 할 모든 것을 알려줄 것이고, 따라서 공이 어떻게 굴러가는지에 대해서도 알려줄 것입니다.
지금까지 설명으로 인해 중력과 마찰의 효과를 고려해 공이 굴러가는 뉴턴의 방정식을 도출하려는게 아닌가 하는 생각을 하는 독자도 있었을 것이라 생각합니다. 사실 공이 굴러가는 비유를 그렇게 심각하게 받아들이지 않아도 괜찮습니다. 우리는 C를 최소화하기 위한 알고리즘을 찾고자 하는 것이지 물리학 법칙을 정확하게 시뮬레이션하고자 하는 것이 아닙니다. 공의 관점에서 보는 것은 우리의 상상력을 자극하기 위한 것이지 사고를 제한하기 위한 것이 아닙니다. 그러니 물리학의 복잡한 세부 사항들을 하나하나 파고들기 보다는 간단히 스스로에게 질문해 봅시다. 만약, 우리가 하루 동안 신이 되어 우리만의 물리 법칙을 만들어 공이 어떻게 굴러가는지 지시할 수 있다면, 공이 항상 계곡 바닥으로 굴러가도록 어떤 운동법칙을 선택할 수 있을까요?
이 질문을 더 정확하게 만들기 위해서 우리가 공을 $v_1$ 방향으로 $\Delta v_1$만큼 약간 움직이고 $v_2$ 방향으로 $\Delta v_2$만큼 약간 움직일 때 무슨 일이 일어나는지 생각해 보겠습니다. 미적분학에 따르면 C의 변화는 다음과 같습니다.
$\Delta C \approx \frac{\partial C}{\partial v_1} \Delta v_1 + \frac{\partial C}{\partial v_2} \Delta v_2.\tag{7}$
우리는 $\Delta C$를 음수로 만들기 위하여 $\Delta v_1$과 $\Delta v_2$를 선택할 방법을 찾을 것입니다. 다시 말해 공이 계곡 아래쪽으로 굴러내려 가게 하는 $\Delta v_1$과 $\Delta v_2$를 선택할 것이라는 뜻입니다. 이러한 방법을 찾기 위해서, 우리는 $v$에 대한 변화를 $\Delta v$로 나타내고, $\Delta v \equiv (\Delta v_1, \Delta v_2)^T$와 같이 정의할 것입니다. 여기에서도 T는 행을 열로 치환하는 연산을 나타냅니다. 역시 우리는 C의 기울기를 C에 대한 다음과 같은 편미분 벡터로 정의할 것입니다.
$\nabla C \equiv \left( \frac{\partial C}{\partial v_1}, \frac{\partial C}{\partial v_2} \right)^T.\tag{8}$
잠시동안 우리는 C의 $\Delta v$에 대한 변화값 $\Delta C$를 기울기 $\nabla C$로 쓰도록 하겠습니다. 하지만 그 전에 사람들이 기울기에 대하여 가끔 궁금해하는 부분을 명확하게 짚고 넘어가고자 합니다. $\nabla C$ 표기법을 처음 접하는 사람들은 때때로 어떻게 생각하는지 궁금합니다. $\nabla$는 정확하게 무엇을 의미할까요? $\nabla C$를 두 기호를 사용하여 위에 쓴 벡터처럼, 하나의 수학적 개체로 생각하는 것도 좋습니다. 이러한 관점에서, $\nabla$는 "이봐, $\nabla C$는 기울기 벡터야"라고 이야기하는 기호로 간주할 수 있습니다. 조금 심층적인 관점에서 $\nabla$ 그 자체로 독립적인 수학적 실체로 볼 수도 있지만, 그러한 관점이 지금 필요하지는 않습니다.
이러한 정의에 따르면, 식(7)은 $\Delta C$로 다시 쓸 수 있습니다.
$\Delta C \approx \nabla C \cdot \Delta v.\tag{9}$
이 방정식은 왜 $\nabla C$를 기울기 벡터라고 부르는지 이해하는데 도움이 됩니다. $\nabla C$는 $v$의 변화에 대한 $C$의 변화와 연관이 있습니다. 하지만 이 방정식에서 정말 흥미로운 점은 $\Delta C$를 음수로 만들기 위해서 $\Delta v$를 어떻게 선택해야 하는지 볼 수 있다는 점입니다. 구체적으로 작은 양수인 파라미터로 $\eta$ 갖는 다음의 식을 선택할 수 있습니다. ($\eta$는 학습률이라고 알려져 있습니다.)
$\Delta v = -\eta \nabla C,\tag{10}$
그렇다면, 식 (9)는 $\Delta C \approx -\eta \nabla C \cdot \nabla C = -\eta \|\nabla C\|^2$와 같이 쓸 수 있습니다. $\|\nabla C\|^2 \geq 0$이므로, 항상 $\Delta C \leq 0$이 성립하게 됩니다. 즉, $v$를 식 (10)에 따라 변경되게 한다면, C는 항상 감소하고, 절대 증가하지 않습니다. 이것이 우리가 원했던 속성입니다. 따라서 경사 하강 알고리즘에서 공의 "운동 법칙"을 정의하기 위해 식(10)을 사용할 것입니다. 즉, 식 (10)을 사용하여 $\Delta v$의 값을 계산하고, 공의 위치 $v$를 다음만큼 움직입니다.
$v \rightarrow v' = v -\eta \nabla C.\tag{11}$
그런 다음 이 변화 규칙을 다시 사용하여 다른 움직임을 만들겠습니다. 이 작업을 반복하면 $C$는 계속 감소하여 전체적인 최소 수준에 도달할 수 있습니다.
요약하자면, 경사 하강 알고리즘이 작동하는 방식은 기울기 $\nabla C$를 반복적으로 계산하는 것입니다. 그리고, 계곡 경사의 "떨어지는 방향"인 반대 방향으로 움직이는 것입니다. 이것을 우리는 다음과 같이 시각화해볼 수 있습니다.
경사 하강 규칙을 사용한다고 해서, 실제 물리적 움직임을 재현할 수 있는 것은 아니라는 점을 유의해주시기 바랍니다. 실제로 공은 운동량을 가지고 있으며, 이 운동량 덕분에 공은 경사면을 가로질러 굴러가거나 (잠시) 오르막길을 굴러갈 수도 있습니다. 마찰력이 작용한 후에야 공은 계곡으로 굴러가게 됩니다. 이와 대조적으로, 우리의 $\Delta v$ 선택 규칙은 그냥 "지금 당장 내려가세요"라고만 적혀 있습니다. 최소값을 찾는데는 꽤 괜찮은 규칙입니다.
경사 하강이 제대로 작동하려면 학습률을 적절하게 선택해야 합니다. $\eta$ 식 (9)가 좋은 근사치가 될 만큼 충분히 작아야 합니다. 만약 그렇지 않다면, $\Delta C > 0$이 되고, 이는 좋지 않은 결과를 가져옵니다. 동시에, $\eta$가 너무 작아서도 안됩니다. 그러면, $\Delta v$도 너무 작아지고, 이는 경사 하강 알고리즘이 매우 느리게 작동한다는 뜻입니다. 실제 구현할 때 $\eta$는 변화하는 값으로 선택하여, 식 (9)가 좋은 근사값이 유지되도록 하는 동시에 경사 하강 알고르즘이 너무 느려지지 않도록 합니다. 우리는 이후에 이에 대해서 더 살펴보도록 하겠습니다.
우리는 여기서 두개 변수를 갖는 함수 $C$의 경사 하강을 설명했습니다. 하지만 사실 함수 $C$가 여러 개의 변수를 갖는다고 해도 잘 작동합니다. 함수 $C$가 $v_1, ..., v_m$의 $m$개의 변수를 갖는다고 했을 때, 각 변수들의 변화 $\Delta v \approx (\Delta v_1, ..., \Delta v_m)^T$로 인한 C의 변화 $\Delta C$는 다음과 같습니다.
$\Delta C \approx \nabla C \cdot \Delta v,\tag{12}$
그리고, $\nabla C$는 다음과 같은 벡터 입니다.
$\nabla C \equiv \left(\frac{\partial C}{\partial v_1}, \ldots, \frac{\partial C}{\partial v_m}\right)^T.\tag{13}$
그리고, 변수가 두 개인 경우에, 우리는 변수를 다음과 같이 선택함으로써 식 (12)에서 보았듯이 $\Delta C$가 항상 음수가 되도록 할 수 있습니다.
$\Delta v = -\eta \nabla C,\tag{14}$
이렇게 함으로써 우리는 기울기가 최소가 되도록 할수 있습니다. 함수 $C$가 많은 변수를 갖는 경우에 다음의 변화 규칙을 반복하여 적용함으로써 최소값을 찾아갈 수 있습니다.
$v \rightarrow v' = v-\eta \nabla C.\tag{15}$
이 변화 규칙은 경사 하강 알고르즘을 정의하는 것으로 간주할 수 있습니다. 이는 위치를 반복적으로 변경하는 방법을 제공합니다. 이는 함수 $C$의 최소값을 찾기 위하여 위치 $v$를 반복적으로 움직이는 방법을 알려줍니다. 그러나, 이 방법이 항상 작동하는 것은 아닙니다. 여러 가지 문제가 발생할 수 있으며, 경사 하강이 $C$의 전역적 최소값을 찾지 못할 수도 있습니다. 이 점에 대해서는 이후 장에서 다시 살펴보도록 하겠습니다. 하지만, 실제로 경사 하강법은 매우 잘 작동하는 경우가 많으며, 신경망에서 비용 함수를 최소화하여 신경망의 학습을 돕는 강력한 방법임을 알게될 것입니다.
어떤 의미에서는 경사 하강법이 최소값을 찾는 최적의 전략일 수도 있습니다. 예를 들어 위치를 가장 많이 움직여 $\Delta v$, $C$를 최대한 감소시켜야한다고 가정해 봅시다. 이것은 $\Delta C \approx \nabla C \cdot \Delta v$를 최소화하는 것과 같습니다. 우리는 움직임의 크기인 $\| |Delta v \| = \epsilon$를 제한할 것입니다. ($\epsilon > 0$) 즉, 우리는 고정된 크기로 한 단계씩 조금 씩 움직이도록 해야하며, 움직일 때마다 비용함수 $C$가 최대한 많이 감소하는 방향을 찾아야 합니다. 이것은 $\nabla C \cdot \Delta v$가 최소화되는 $\Delta v$는 $\Delta v = -\eta \nabla C$일 때라는 것은 증명할 수 있습니다. ($\| \Delta v \| = \epsilon$이라는 제약조건을 만족할 때, $\eta = \epsilon \| \nabla C \|$ 경우) 따라서, 경사 하강법은 $C$가 즉시 감소하는 방향으로 작은 단계를 취하는 방법으로 볼 수 있습니다.
사람들은 경사 하강법의 다양한 변경을 연구해 왔는데, 그 중에는 실제 공을 더욱 정확하게 모방하는 변형도 포함됩니다. 이러한 공 모방 변형에는 몇 가지 장점이 있지만, 큰 단점도 있습니다. 바로 $C$의 2차 편미분을 계산해야 한다는 것입니다. 그리고 이는 상당히 비용이 많이 드는 방법일 수 있습니다. 비용이 많이 드는 이유를 알아보기 위하여, $\partial^2 C/ \partial v_j \partial v_k$의 모든 2차 편미분을 계산한다고 가정해보겠습니다. 변수 $v_j$가 백만개 있다고 가정하면, 우리는 약 1조(즉, 백만 제곱)개의 2차 편미분을 계산해야합니다. 이는 계산 비용이 상당히 많이 드는 작업입니다. 하지만, 이러한 문제를 피하는 몇가지 방법이 있으며, 경사 하강법의 대안을 찾는 것은 활발하게 연구되고 있는 분야입니다. 하지만, 이 책에서는 신경망 학습의 주요 접근 방식으로 경사 하강법(및 다양한 변형)을 사용할 것입니다.
신경망 학습에 경사 하강법은 어떻게 적용할 수 있을까요? 경사 하강법을 사용하여 식 (6)의 비용을 최소화하는 가중치 $w_k$와 편항 $b_l$을 찾는 것이 핵심입니다. 이것이 어떻게 작동하는지를 보기 위해, 가중치와 편향을 변수 $v_j$로 대체하여 경사 하강법 변경 규칙을 다시 살펴보도록 하겠습니다. 다시 말해서, 위치는 $w_k$와 $b_l$을 구성요소로 가지고, 기울기 벡터 $\Delta C$는 $\partial C / \partial w_k$와 $\partial C / \partial v_l$을 구성요소로 갖습니다. 경사 하강법 변경 규칙을 이들 구성요소로 다시 쓰면 다음과 같습니다.
$w_k \rightarrow w_k' = w_k-\eta \frac{\partial C}{\partial w_k} \tag{16}$
$b_l \rightarrow b_l' = b_l-\eta \frac{\partial C}{\partial b_l}.\tag{17}$
이 변경 규칙을 반복적으로 적용하면 경사 아래로 굴러가는 비용 함수의 최소값을 찾을 수 있습니다. 다시 말해 이 규칙은 신경망 학습에 사용될 수 있습니다.
경사 하강법 규칙을 적용하는 데에는 여러 가지 어려움이 있습니다. 이후 장에서 이러한 문제에 대해서 자세히 살펴보겠습니다. 하지만 지금은 한 가지 문제만 언급하고 싶습니다. 문제가 무엇인지 이해하기 위해 식 (6)의 이차 비용 함수를 다시 살펴보겠습니다. 비용 함수는 $C = \frac{1}{n} \sum_x C_x$와 같은 형태를 가집니다. 그리고, 각 학습 예제당 평균적인 비용은 $C_x \equiv \frac{\|y(x)-a\|^2}{2}$와 같습니다. 실제로 기울기 $\nabla C$를 계산하기 위해서는 각각 훈련 입력 $x$에 대하여 기울기 $\nabla C_x$를 계산해야 합니다. 그리고, 그 후에 이들에 대한 평균을 $\nabla C = \frac{1}{n}\sum_x \nabla C_x$ 계산해야 합니다. 불행히도 학습을 위한 입력의 수가 매우 많으면 시간이 오래 걸리고 따라서 학습이 느리게 진행됩니다.
확률적 경사 하강법이라는 아이디어를 이용하면 학습 속돌르 높일 수 있습니다. 이 아이디어는 학습용 입력 중 일정 표본을 무작위로 추출해 $\nabla C_x$를 계산함으로써 기울기$\nabla C$를 추정하는 것입니다. 이 표본에 대한 평균을 구하면 실제 $\nabla C$기울기를 빠르고 정확하게 추정할 수 있습니다. 이는 경사 하강의 속도를 높이는데 도움이 되며, 결과적으로 학습 속도도 향상됩니다.
확율적 경사 하강법은 실제로 학습용 입력 중에 작은 수 $m$만큼을 무작위로 선택하여 사용합니다. 우리는 이 무작위로 선택한 학습용 입력을 $X_1, X_2, ..., X_m$과 같이 표시하고, 이들을 미니배치라고 부르도록 하겠습니다. 만약 표본의 크기 $m$이 충분히 크다면 $\nabla C_{X_j}$의 평균은 전체 $\nabla C_x$의 평균과 유사해집니다. 즉,
$\frac{\sum_{j=1}^m \nabla C_{X_{j}}}{m} \approx \frac{\sum_x \nabla C_x}{n} = \nabla C,\tag{18}$
여기서 두 번째 합은 전체 훈련 데이터 세트에 대한 것입니다. 측면을 바꾸면 다음과 같습니다.
$\nabla C \approx \frac{1}{m} \sum_{j=1}^m \nabla C_{X_{j}},\tag{19}$
무작위로 선택된 미치배치에 대한 경사값만 계산하여 전체 경사값을 추정할 수 있습니다.
우리는 신경망의 가중치와 편향을 $w_k와 b_l$로 표시했습니다. 그리고, 확률적 경사 하강법은 학습용 입력 중에 미니배치를 무작위로 선택하여 이들을 가지고 학습하는 방식으로 작동합니다. 즉,
$w_k \rightarrow w_k' = w_k-\frac{\eta}{m} \sum_j \frac{\partial C_{X_j}}{\partial w_k} \tag{20}$
$b_l \rightarrow b_l' = b_l-\frac{\eta}{m} \sum_j \frac{\partial C_{X_j}}{\partial b_l},\tag{21}$
여기서 합계는 현재 미니배치의 모든 훈련 예제, $X_j$를 포함합니다. 그런 다음 다른 미니배치를 무작위로 선택하고, 이들로 학습을 시작합니다. 이렇게 학습용 입력이 모두 소진될 때까지 반복하는데, 이를 한 에포크(epoch)의 학습이 완료되었다고 합니다. 한 에포크의 학습이 완료되면 새로운 에포크(epoch)로 다시 시작합니다.
덧붙여 비용 함수와 가중치 및 편항에 대한 미니배치 변경의 스케일링에 대한 관례가 다르다는 점에 유의해야 합니다. 식 (6)에서 전체 비용 함수를 $1/N$과 같이 스케일링 했습니다. 사람들은 가끔 이 $1/N$을 간과하고, 평균을 계산하는 대신 각 학습 예제의 비용을 더하곤 합니다. 이는 특히 총 학습 예제의 수를 미리 알 수 없는 경우에 유용합니다. 예를 들어, 실시간으로 더 많은 학습 데이터가 생성되는 경우가 있을 수 있습니다. 마찬가지로, 미니배치 변경 규칙인 식 (20)과 (21)에서 합산의 앞에 있는 $1/m$이 가끔 생략되기도 합니다. 이는 개념적으로 $\eta$ 학습률을 재조정하는 것과 같으므로 큰 차이가 없습니다. 하지만 다양한 작업을 자세하게 비교할 때는 주의해야할 점이 있습니다.
확률적 경사 하강법은 정치 여론 조사와 비슷하다고 생각할 수 있습니다. 전체 배치에 경사 하강법을 적용하는 것보다 작은 미니배치를 샘플링하는 것이 훨씬 쉽습니다. 마치 여론조사를 실시하는 것이 전체 선거를 실시하는 것보다 쉬운 것처럼 말입니다. 예를 들어 크기가 $n = 60,000$ MNIST와 마찬가지로 미니배치 크기를 $m = 10$과 같이 선택합니다. 이것은 우리가 6,000개의 입력으로 빠르게 기울기를 추정하는 것과 같습니다. 물론, 추정값이 완벽하지 않을 것입니다. 통계적 변동이 있을 테니까요. 하지만, 완벽할 필요는 없습니다. 우리가 진정으로 중요하게 생각하는 것은 비용 $C$ 감소에 도움이 되는 일반적인 방향으로 움직이는 것입니다. 기울기를 정확하게 계산할 필요가 없습니다. 실제로 확율적 경사 하강법은 신경망 학습에 널리 사용되고 강력한 기법이며, 이 책의 대부분 학습 기법의 기반이 됩니다.
경사 하강법을 처음 접하는 사람들을 가끔 짜증나게 하는 점에 대해 논의하면서 이번 장을 마무리하겠습니다. 신경망에서 비용 $C$는 여러 변수, 즉 모든 가중치와 편향의 함수이므로 어떤 의미에서는 매우 고차원 공간에서 표면을 정의합니다. 어떤 사람들은 "이 모든 추가적인 차원들을 시각화할 수 있어야 해"라고 생각하며 쩔쩔매기도 합니다. 그리고, "4차원은 생각도 못하는데, 5차원(혹은 500만)은 더더욱 생각 못해"라고 걱정하기 시작할 수 있습니다. 그들에게 "진짜" 초수학자들이 가진 특별한 능력이 없는 걸까요? 물론 답은 "아니오" 입니다. 대부분의 전문 수학자들조차도 4차원을 시각화할 수 없거나, 아예 시각화하지 못합니다. 대신 그들이 사용하는 비결은 현상을 표현하는 다른 방법을 개발하는 것입니다. 바로 우리가 위에서 한 일이 그것입니다. 우리는 $C$를 감소시키기 위한 움직임을 알아내기 위해 시각적 표현이 아닌 $C$의 대수적 표현을 사용했습니다. 고차원 사고에 익숙한 사람들은 이와 같은 다양한 기법들을 머릿속에 그려보고 있습니다. 우리의 대수적 기법은 그 중의 하나일 뿐입니다. 이러한 기법들은 우리가 3차원을 시각화할 때처럼 단순하지 않을 수 있지만, 일단 이러한 기법들을 모아 놓으면 고차원 사고에 꽤 능숙해질 수 있습니다. 여기서는 더 자세히 설명하지 않겠지만, 관심이 있다면, 전문 수학자들이 고차원 사고에 사용하는 기법들에 대한 글을 읽어보는 것도 좋을 것 같습니다. 논의되는 기법들 중 일부는 상당히 복잡하지만, 좋은 설명은 대부분 직관적이고 접근하기 쉬워 누구나 쉽게 익힐 수 있습니다.
숫자를 분류하기 위한 네트워크 구현
확률적 경사 하강법과 MNIST 학습 데이터를 사용하여 손글씨 숫자를 인식하는 방법을 학습하는 프로그램을 작성해보겠습니다. 74줄짜리 짧은 Python(2.7) 프로그램으로 이 작업을 진행해 보겠습니다. 먼저 MNIST 데이터를 가져와야 합니다. git 사용자라면 이 책의 코드 저장소를 복제하여 데이터를 얻을 수 있습니다.
git clone https://github.com/mnielsen/neural-networks-and-deep-learning.git
git을 사용하지 않는 경우 여기서 데이터와 코드를 다운로드 할 수 있습니다.
그런데, 앞서 MNIST 데이터를 설명할 때 60,000개의 학습 이미지와 10,000개의 테스트 이미지로 나뉜다고 이야기했습니다. 그게 공식적인 MNIST의 설명입니다. 사실, 우리는 데이터를 조금 다르게 나눌 것입니다. 테스트 이미지는 그대로 두고 60,000개의 이미지로 구성된 MNIST 학습 세트를 두 부분으로 나눕니다. 신경망을 학습하는데 사용할 50,000개의 이미지 세트와 별도의 10,000개 이미지 검증 세트입니다. 이 장에서는 검증 데이터를 사용하지 않겠지만, 책 뒷부분에서 학습 알고리즘에서 직접 선택하지 않는 학습률과 같은 신경망의 특정 하이퍼 매개변수를 설정하는 방법을 파악하는 데 유용하다는 것을 알게될 것입니다. 검증 데이터는 원래 MNIST 사양에 포함되지 않았지만 많은 사람들이 이런 방식으로 MNIST를 사용하고 있으며, 검증 데이터를 사용하는 것은 신경망에서 일반적입니다. 앞으로 "MNIST 학습 데이터"라고 언급할 때는 원래 60,000개의 이미지 데이터 세트가 아닌 50,000개의 이미지 데이터 세트를 지칭합니다.
MNIST 데이터 외에도 빠른 선형 대수 연산을 위해 Numpy 라는 Python 라이브러리가 필요합니다. Numpy가 설치되어 있지 않다면 여기에서 다운로드할 수 있습니다.
전체 목록을 제시하기 전에 신경망 코드의 핵심 기능을 설명하겠습니다. 핵심은 신경망을 표현하는 데 사용되는 Network 클래스입니다. 네트워크 객체를 초기화하는데 사용되는 코드는 다음과 같습니다.
class Network(object): def __init__(self, sizes): self.num_layers = len(sizes) self.sizes = sizes self.biases = [np.random.randn(y, 1) for y in sizes[1:]] self.weights = [np.random.randn(y, x) for x, y in zip(sizes[:-1], sizes[1:])]
이 코드에서 sizes 목록에는 각 계층의 뉴런 개수가 포함됩니다. 예를 들어 첫 번째 계층에서 뉴런 2개, 두 번째 계층에 뉴런 3개, 마지막 계층에 뉴런 1개를 포함하는 Network 객체를 만들려면 다음 코드를 사용합니다.
net = Network([2, 3, 1])
Network 객체의 편향과 가중치는 모두 평균값 0과 표준편차 1을 갖는 가우시안 분포를 생성하기 위해 Numpy np.random.randn 함수를 사용하여 무작위로 초기화됩니다. 이 무작위 초기화는 확률적 경사 하강법 알고리즘을 시작할 수 있는 기반을 제공합니다. 이후 장에서 가중치와 편향을 초기화하는 더 나은 방법을 살펴보겠지만, 지금은 이 정도로 충분합니다. Network 초기화 코드는 첫 번재 뉴런 층을 입력층으로 가정하고 해당 뉴런에 대한 편향은 설정하지 않습니다. 편향은 이후 층의 출력을 계산할 때만 사용되기 때문입니다.
편향과 가중치는 Numpy 행렬의 리스트로 저장된다는 점에 유의하세요. 예를 들어 net.weights[1]은 두 번째와 세 번째 뉴런 층을 연결하는 가중치를 저장하는 Numpy 행렬입닏. (Python의 리스트 인덱싱은 0부터 시작하므로 첫 번째와 두 번째 층이 아닙니다.) net.weights[1]은 다소 장황하므로, 여기서는 행렬 $w$로 표기하도록 하겠습니다. $w_{jk}$는 두 번째 뉴런 층의 $k^th$뉴런과 세 번째 뉴런 층의 $j^th$뉴런간의 연결에 대한 가중치를 뜻합니다. j와 k 순서가 이상하게 보일 수 있습니다. j와 k의 순서를 바꾸는 것이 더 타당해 보일 수 있습니다. 이 순서를 사용하는 것의 가장 큰 장점은 세 번째 뉴런 층의 활성화 벡터가 다음과 같다는 것입니다.
$a' = \sigma(w a + b).\tag{22}$
이 방정식에는 꽤 많은 내용이 담겨 있으므로 조금 세부적으로 살펴 보겠습니다. $a$는 신경망의 두 번째 계층의 활성화 벡터입니다. $a'$를 얻기 위해서, $a$에 가중치 행렬 $w$를 곱하고 편향에 대한 벡터 $b$를 더해야 합니다. 그리고 벡터 $wa + b$의 모든 요소에 대해 개별적(elementwise)으로 $\sigma$ 함수를 적용합니다. (이것을 함수 $\sigma$의 벡터화라고 부릅니다.) 시그모이드 뉴런의 결과를 계산하기 위하여, 식 (22)가 우리가 일찍이 살펴보았던 식 (4)와 같은 결과를 갖는다는 것은 쉽게 확인할 수 있습니다.
이러한 것들을 염두해두면, Network 인스턴스의 출력을 계산하는 코드를 쉽게 작성할 수 있습니다. 먼저 시그모이드 함수를 정의합니다.
def sigmoid(z): return 1.0/(1.0+np.exp(-z))
입력 z가 벡터 또는 Numpy 배열인 경우 Numpy는 자동으로 sigmoid 함수를 요소별로, 즉 벡터화된 형태로 적용합니다.
그런다음 네트워크에 대한 입력 a가 주어지면 해당 출력을 반환하는 feedfoward 매서드를 Network 클래스에 추가합니다. 이 매서드가 하는 것은 각 계층에 식 (22)를 적용하는 것입니다.
def feedforward(self, a): """Return the output of the network if "a" is input.""" for b, w in zip(self.biases, self.weights): a = sigmoid(np.dot(w, a)+b) return a
물론 Network 객체가 학습하도록 하는 것이 중요합니다. 이를 위해 확률적 경사 하강법을 규현하는 SGD 메서드를 만들도록 하겠습니다. 코드는 아래와 같습니다. 몇몇 부분이 다소 이해하기 어렵지만, 차차로 자세하게 설명하겠습니다.
def SGD(self, training_data, epochs, mini_batch_size, eta, test_data=None): """Train the neural network using mini-batch stochastic gradient descent. The "training_data" is a list of tuples "(x, y)" representing the training inputs and the desired outputs. The other non-optional parameters are self-explanatory. If "test_data" is provided then the network will be evaluated against the test data after each epoch, and partial progress printed out. This is useful for tracking progress, but slows things down substantially.""" if test_data: n_test = len(test_data) n = len(training_data) for j in xrange(epochs): random.shuffle(training_data) mini_batches = [ training_data[k:k+mini_batch_size] for k in xrange(0, n, mini_batch_size)] for mini_batch in mini_batches: self.update_mini_batch(mini_batch, eta) if test_data: print "Epoch {0}: {1} / {2}".format( j, self.evaluate(test_data), n_test) else: print "Epoch {0} complete".format(j)
training_data는 학습 입력과 그에 상응하는 출력을 나타내는 Tuple (x, y)에 대한 리스트입니다. epochs와 mini_batch_size 변수는 예상대로 학습할 epoch 횟수와 샘플링에 사용할 미니배치의 크기를 나타냅니다. eta는 학습률 $\eta$입니다. 선택적 인수 test_data를 제공하면 프로그램은 각 학습 epoch마다 네트워크를 평가하고 부분적인 진행상황을 출력합니다. 이는 진행 상황을 추적하는데 유용하지만, 작업 속도가 상당히 느려집니다.
코드는 다음과 같이 작동합니다. 각 epoch마다 학습 데이터를 무작위로 섞은 후, 적절한 크기의 미니배치로 분할합니다. 이는 학습 데이터에서 무작위로 샘플링하는 간단한 방법입니다. 그런 다음 각 배치에 대해 경사 하강법을 한 단계씩 적용합니다. 이 작업은 self.update_mini_batch(mini_batch, eta) 코드로 수행되며, mini_batch에 있는 학습 데이터만 사용하여 단일 경사 하강법 반복에 따라 네트워크의 가중치와 편향을 업데이트 합니다. update_mini_batch 메서드의 코드는 다음과 같습니다.
def update_mini_batch(self, mini_batch, eta): """Update the network's weights and biases by applying gradient descent using backpropagation to a single mini batch. The "mini_batch" is a list of tuples "(x, y)", and "eta" is the learning rate.""" nabla_b = [np.zeros(b.shape) for b in self.biases] nabla_w = [np.zeros(w.shape) for w in self.weights] for x, y in mini_batch: delta_nabla_b, delta_nabla_w = self.backprop(x, y) nabla_b = [nb+dnb for nb, dnb in zip(nabla_b, delta_nabla_b)] nabla_w = [nw+dnw for nw, dnw in zip(nabla_w, delta_nabla_w)] self.weights = [w-(eta/len(mini_batch))*nw for w, nw in zip(self.weights, nabla_w)] self.biases = [b-(eta/len(mini_batch))*nb for b, nb in zip(self.biases, nabla_b)]
대부분의 작업은 다음 라인에서 수행됩니다.
delta_nabla_b, delta_nabla_w = self.backprop(x, y)
이는 역전파 알고리즘을 위한 boackprop 메서드를 호출하는데, 이는 비용 함수의 기울기를 빠르게 계산하는 메서드입니다. 지금은 self.backprop 메서드의 코드는 살펴보지 않겠습니다. 다음 장에서 이를 포함하여 역전파 알고리즘의 작동 방식을 살펴보겠습니다. 지금은 이 알고리즘의 주장대로 동작하여 학습 예제 x와 관련된 비용에 대한 적절한 기울기를 반환한다고 가정하겠습니다.
위에서 생략한 문서 문자열을 포함한 전체 프로그램을 살펴보겠습니다. self.backprop을 제외하면 프로그램 설명이 필요하진 않습니다. 모든 복잡한 작업은 self.SGD와 이미 설명한 self.update_mini_batch에서 수행됩니다. self.backprop 메서드는 기울기 계산을 돕기 위해 몇 가지 추가 함수를 사용하는데, 그 중 하나가 $\sigma$의 미분을 계산하기 위한 sigmoid_prime이며, 다른 하나는 self.cost_derivative입니다. self.cost_derivative는 여기서 자세히 설명하지 않겠습니다. 다음 코드의 세부적인 것은 다음 장에서 더 살펴보도록 하겠습니다. 실제로 아래 프로그램은 공백이나 주석을 제외하면 74줄입니다. 전체 코드는 GitHub의 여기에서 확인할 수 있습니다.
""" network.py ~~~~~~~~~~ A module to implement the stochastic gradient descent learning algorithm for a feedforward neural network. Gradients are calculated using backpropagation. Note that I have focused on making the code simple, easily readable, and easily modifiable. It is not optimized, and omits many desirable features. """ #### Libraries # Standard library import random # Third-party libraries import numpy as np class Network(object): def __init__(self, sizes): """The list ``sizes`` contains the number of neurons in the respective layers of the network. For example, if the list was [2, 3, 1] then it would be a three-layer network, with the first layer containing 2 neurons, the second layer 3 neurons, and the third layer 1 neuron. The biases and weights for the network are initialized randomly, using a Gaussian distribution with mean 0, and variance 1. Note that the first layer is assumed to be an input layer, and by convention we won't set any biases for those neurons, since biases are only ever used in computing the outputs from later layers.""" self.num_layers = len(sizes) self.sizes = sizes self.biases = [np.random.randn(y, 1) for y in sizes[1:]] self.weights = [np.random.randn(y, x) for x, y in zip(sizes[:-1], sizes[1:])] def feedforward(self, a): """Return the output of the network if ``a`` is input.""" for b, w in zip(self.biases, self.weights): a = sigmoid(np.dot(w, a)+b) return a def SGD(self, training_data, epochs, mini_batch_size, eta, test_data=None): """Train the neural network using mini-batch stochastic gradient descent. The ``training_data`` is a list of tuples ``(x, y)`` representing the training inputs and the desired outputs. The other non-optional parameters are self-explanatory. If ``test_data`` is provided then the network will be evaluated against the test data after each epoch, and partial progress printed out. This is useful for tracking progress, but slows things down substantially.""" if test_data: n_test = len(test_data) n = len(training_data) for j in xrange(epochs): random.shuffle(training_data) mini_batches = [ training_data[k:k+mini_batch_size] for k in xrange(0, n, mini_batch_size)] for mini_batch in mini_batches: self.update_mini_batch(mini_batch, eta) if test_data: print "Epoch {0}: {1} / {2}".format( j, self.evaluate(test_data), n_test) else: print "Epoch {0} complete".format(j) def update_mini_batch(self, mini_batch, eta): """Update the network's weights and biases by applying gradient descent using backpropagation to a single mini batch. The ``mini_batch`` is a list of tuples ``(x, y)``, and ``eta`` is the learning rate.""" nabla_b = [np.zeros(b.shape) for b in self.biases] nabla_w = [np.zeros(w.shape) for w in self.weights] for x, y in mini_batch: delta_nabla_b, delta_nabla_w = self.backprop(x, y) nabla_b = [nb+dnb for nb, dnb in zip(nabla_b, delta_nabla_b)] nabla_w = [nw+dnw for nw, dnw in zip(nabla_w, delta_nabla_w)] self.weights = [w-(eta/len(mini_batch))*nw for w, nw in zip(self.weights, nabla_w)] self.biases = [b-(eta/len(mini_batch))*nb for b, nb in zip(self.biases, nabla_b)] def backprop(self, x, y): """Return a tuple ``(nabla_b, nabla_w)`` representing the gradient for the cost function C_x. ``nabla_b`` and ``nabla_w`` are layer-by-layer lists of numpy arrays, similar to ``self.biases`` and ``self.weights``.""" nabla_b = [np.zeros(b.shape) for b in self.biases] nabla_w = [np.zeros(w.shape) for w in self.weights] # feedforward activation = x activations = [x] # list to store all the activations, layer by layer zs = [] # list to store all the z vectors, layer by layer for b, w in zip(self.biases, self.weights): z = np.dot(w, activation)+b zs.append(z) activation = sigmoid(z) activations.append(activation) # backward pass delta = self.cost_derivative(activations[-1], y) * \ sigmoid_prime(zs[-1]) nabla_b[-1] = delta nabla_w[-1] = np.dot(delta, activations[-2].transpose()) # Note that the variable l in the loop below is used a little # differently to the notation in Chapter 2 of the book. Here, # l = 1 means the last layer of neurons, l = 2 is the # second-last layer, and so on. It's a renumbering of the # scheme in the book, used here to take advantage of the fact # that Python can use negative indices in lists. for l in xrange(2, self.num_layers): z = zs[-l] sp = sigmoid_prime(z) delta = np.dot(self.weights[-l+1].transpose(), delta) * sp nabla_b[-l] = delta nabla_w[-l] = np.dot(delta, activations[-l-1].transpose()) return (nabla_b, nabla_w) def evaluate(self, test_data): """Return the number of test inputs for which the neural network outputs the correct result. Note that the neural network's output is assumed to be the index of whichever neuron in the final layer has the highest activation.""" test_results = [(np.argmax(self.feedforward(x)), y) for (x, y) in test_data] return sum(int(x == y) for (x, y) in test_results) def cost_derivative(self, output_activations, y): """Return the vector of partial derivatives \partial C_x / \partial a for the output activations.""" return (output_activations-y) #### Miscellaneous functions def sigmoid(z): """The sigmoid function.""" return 1.0/(1.0+np.exp(-z)) def sigmoid_prime(z): """Derivative of the sigmoid function.""" return sigmoid(z)*(1-sigmoid(z))
이 프로그램은 손으로 쓴 숫자를 얼마나 잘 인식할까요? MNIST 데이터를 불러오는 것부터 시작해 보겠습니다. 아래에서 설명할 간단한 도우미 프로그램 mnist_loader.py를 사용해서 이 작업을 수행하겠습니다. Python shell에서 다음 명령을 실행합니다.
>>> import mnist_loader >>> training_data, validation_data, test_data = \ ... mnist_loader.load_data_wrapper()
물론, 별도의 Python 프로그램에서도 이 작업을 수행할 수 있지만, Python shell에서 수행하는 것이 가장 쉬울 것입니다.
MNIST 데이터를 로드한 후에 30개의 Hidden Neuron을 가진 네트워크를 설정합니다. 위의 network.py 프로그램을 가져온 후 이 작업을 수행합니다.
>>> import network >>> net = network.Network([784, 30, 10])
마지막으로 우리는 10의 미니배치 크기와 학습율 $\eta = 3.0$을 사용하여 epochs 30에 걸쳐 NMIST 학습 데이터로부터 학습하기 위해 활률적 경사 하강법을 사용할 것입니다.
>>> net.SGD(training_data, 30, 10, 3.0, test_data=test_data)
코드를 읽으면서 실행하면 시간이 다소 걸릴 수 있습니다. 일반적인 컴퓨터에서 실행하는 데 몇 분정도 걸릴 수 있습니다. 코드를 실행 상태로 설정하고 계속 읽으면서 코드 출력을 주기적으로 확인하는 것도 좋습니다. 시간이 모자라다면 epoch 수나 Hidden Neuron의 수를 줄이거나 훈련 데이터의 일부만 사용하여 속도를 높일 수 있습니다. 이 Python 프로그램은 성능을 고려한 것이 아닙니다. 신경망의 작동방식을 이해하는데 도움을 주기 위한 것입니다. 물론, 네트워크를 훈련한 후에는 거의 모든 컴퓨팅 플랫폼에서 매우 빠르게 실행할 수 있습니다. 예를 들어 네트워크의 적절한 가중치와 편향 집합을 학습하고 나면 웹 브라우저의 Javascript 또는 모바일 기기의 네이티브 앱으로 쉽게 이식하여 실행할 수 있습니다. 어쨋든, 신경망 훈련 실행 결과의 일부를 아래에 첨부합니다. 이 script는 각 훈련 epoch마다 신경망이 올바르게 인식한 텍스트 이미지의 수를 보여줍니다. 보시다시피 단 한번의 epoch만에 9,129개에 도달했고, 그 수는 계속 증가하고 있습니다.
Epoch 0: 9129 / 10000 Epoch 1: 9295 / 10000 Epoch 2: 9348 / 10000 ... Epoch 27: 9528 / 10000 Epoch 28: 9542 / 10000 Epoch 29: 9534 / 10000
훈련된 네트워크는 약 95%의 정확도로 분류할 수 있습니다. (Epoch 28에서는 최고로 95.42%를 보여줍니다.) 첫 시도치고는 꽤 고무적입니다. 하지만 코드를 실행해 보면 결과가 꼭 같지 않을 수 있다는 점을 미리 알려드립니다. 무작위 가중치와 편향을 사용하여 네트워크를 초기화할 것이기 때문입니다. 이 장에서 결과를 얻기 위해 3판 2선승제를 적용했습니다.
위의 실험을 다시 실행하여 Hidden Neuron의 수를 100으로 변경해 보겠습니다.
>>> net = network.Network([784, 100, 10]) >>> net.SGD(training_data, 30, 10, 3.0, test_data=test_data)
이렇게 하면 결과가 96.59%로 결과가 좋아집니다. 적어도 이 경우에는 더 많은 숨겨진 뉴런을 사용하면 더 나은 결과를 얻는데 도움이 됩니다.
물론 이러한 정확도를 얻으려면 학습 epoch 수, 미니배치 크기 및 학습률 $\eta$에 대한 특정 선택을 해야합니다. 위에서 언급 했듯이, 이러한 매개변수는 신경망의 하이퍼 파라미터(hyper-parameter)로 알려져 있으며, 학습 알고리즘을 통해 학습된 매개변수(가중치와 편향)와 구분하기 위해 사용됩니다. 하이퍼 파리미터를 잘못 선택하면 좋지 않은 결과를 얻을 수 있습니다. 예를 들어 학습률 $\eta = 0.001$로 선택했다고 가정해보겠습니다.
>>> net = network.Network([784, 100, 10]) >>> net.SGD(training_data, 30, 10, 0.001, test_data=test_data)
그러면 그 결과는 훨씬 덜 고무적입니다.
Epoch 0: 1139 / 10000 Epoch 1: 1136 / 10000 Epoch 2: 1135 / 10000 ... Epoch 27: 2101 / 10000 Epoch 28: 2123 / 10000 Epoch 29: 2142 / 10000
하지만 시간이 지남에 따라 네트워크의 성능이 점점 향상되는 것을 볼 수 있습니다. 이는 학습률 $\eta = 0.01$을 높이는 것을 의미합니다. 이렇게 하면 더 나은 결과를 얻을 수 있는데, 이는 학습률 $\eta = 1.0$을 다시 높여야 한다는 것을 의미합니다. (변경을 통해 결과가 개선되면 더 많은 작업을 시도해 보세요) 이는 이전 실험과 유사합니다. 따라서 초기에 하이퍼 파라미터를 잘못 선택했음에도 불구하고 적어도 하이퍼 파라미터 선택을 개선하는데 도움이 될 만큼 충분한 정보를 얻었습니다.
일반적으로 신경망 디버깅은 어려울 수 있습니다. 특히 초기 하이퍼 파라미터 선택이 무작위 노이즈 보다 나을 것이 없는 결과를 낼 때 더욱 그렇습니다. 앞서 살펴본 성공적인 30개의 Hidden Neuron 가진 신경망의 학습률을 $\eta = 100.0$으로 변경하여 다시 시도해 보겠습니다.
>>> net = network.Network([784, 30, 10]) >>> net.SGD(training_data, 30, 10, 100.0, test_data=test_data)
우리는 실제로 너무 멀리 나갔고, 학습률이 너무 높다는 것을 알 수 있습니다.
Epoch 0: 1009 / 10000 Epoch 1: 1009 / 10000 Epoch 2: 1009 / 10000 Epoch 3: 1009 / 10000 ... Epoch 27: 982 / 10000 Epoch 28: 982 / 10000 Epoch 29: 982 / 10000
이제 처음으로 이 문제에 직면했다고 상상해 보세요. 물론 이전 실험을 통해 학습률을 낮추는 것이 옳다는 것을 알고 있습니다. 하지만 이 문제에 처음 직면한다면 출력 결과를 바탕으로 어떻게 해야할지에 대한 충분한 정보를 얻기 어렵습니다. 학습률 뿐만 아니라 신경망의 다른 모든 측면에 대해서도 걱정할 수 있습니다. 네트워크가 학습하기 어렵게 가중치와 편향을 초기화했는지 궁금해할 수 있습니다. 아니면 의미 있는 학습을 위한 충분한 학습 데이터가 없는 것일까요? 충분한 epoch 동안 학습을 하지 않은 것일까요? 아니면 이 아키텍처를 사용한 신경망이 손으로 쓴 숫자를 인식하는 법을 배우는 것이 불가능한 것일까요? 학습률이 너무 낮은 것일까요? 아니면 너무 높은 것일까요? 처음으로 문제에 직면하면 항상 확신할 수 있는 것은 아닙니다.
이 글에서 얻을 수 있는 교훈은 신경망 디버깅이 결코 쉬운 일이 아니며, 일반적인 프로그래밍과 마찬가지로 디버깅에도 기술적인 요소가 있다는 것입니다. 신경망에서 좋은 결과를 얻으려면 디버깅이라는 기술을 익혀야 합니다. 더 일반적으로 좋은 하이퍼 파라미터와 아키텍처를 선택하기 위한 휴리스틱을 개발해야 합니다. 이 책에서 이러한 모든 내용과 위에서 제가 하이퍼 파리미터를 어떻게 선택했는지를 포함하여 자세히 설명하겠습니다.
앞에서 MNIST 데이터 로드 방법에 대한 자세한 내용은 건너 뛰었습니다. 매우 간단합ㄴ디ㅏ. 완전한 설명을 위해 코드를 첨부합니다. MNIST 데이터를 저장하는 데 사용되는 데이터 구조는 설명서에 설명되어 있습니다. Numpu ndarray 객체의 튜플과 리스트로 구성된 간단한 구조입니다.
""" mnist_loader ~~~~~~~~~~~~ A library to load the MNIST image data. For details of the data structures that are returned, see the doc strings for ``load_data`` and ``load_data_wrapper``. In practice, ``load_data_wrapper`` is the function usually called by our neural network code. """ #### Libraries # Standard library import cPickle import gzip # Third-party libraries import numpy as np def load_data(): """Return the MNIST data as a tuple containing the training data, the validation data, and the test data. The ``training_data`` is returned as a tuple with two entries. The first entry contains the actual training images. This is a numpy ndarray with 50,000 entries. Each entry is, in turn, a numpy ndarray with 784 values, representing the 28 * 28 = 784 pixels in a single MNIST image. The second entry in the ``training_data`` tuple is a numpy ndarray containing 50,000 entries. Those entries are just the digit values (0...9) for the corresponding images contained in the first entry of the tuple. The ``validation_data`` and ``test_data`` are similar, except each contains only 10,000 images. This is a nice data format, but for use in neural networks it's helpful to modify the format of the ``training_data`` a little. That's done in the wrapper function ``load_data_wrapper()``, see below. """ f = gzip.open('../data/mnist.pkl.gz', 'rb') training_data, validation_data, test_data = cPickle.load(f) f.close() return (training_data, validation_data, test_data) def load_data_wrapper(): """Return a tuple containing ``(training_data, validation_data, test_data)``. Based on ``load_data``, but the format is more convenient for use in our implementation of neural networks. In particular, ``training_data`` is a list containing 50,000 2-tuples ``(x, y)``. ``x`` is a 784-dimensional numpy.ndarray containing the input image. ``y`` is a 10-dimensional numpy.ndarray representing the unit vector corresponding to the correct digit for ``x``. ``validation_data`` and ``test_data`` are lists containing 10,000 2-tuples ``(x, y)``. In each case, ``x`` is a 784-dimensional numpy.ndarry containing the input image, and ``y`` is the corresponding classification, i.e., the digit values (integers) corresponding to ``x``. Obviously, this means we're using slightly different formats for the training data and the validation / test data. These formats turn out to be the most convenient for use in our neural network code.""" tr_d, va_d, te_d = load_data() training_inputs = [np.reshape(x, (784, 1)) for x in tr_d[0]] training_results = [vectorized_result(y) for y in tr_d[1]] training_data = zip(training_inputs, training_results) validation_inputs = [np.reshape(x, (784, 1)) for x in va_d[0]] validation_data = zip(validation_inputs, va_d[1]) test_inputs = [np.reshape(x, (784, 1)) for x in te_d[0]] test_data = zip(test_inputs, te_d[1]) return (training_data, validation_data, test_data) def vectorized_result(j): """Return a 10-dimensional unit vector with a 1.0 in the jth position and zeroes elsewhere. This is used to convert a digit (0...9) into a corresponding desired output from the neural network.""" e = np.zeros((10, 1)) e[j] = 1.0 return e
위에서 이야기했듯이 우리의 프로그램은 꽤 좋은 결과를 얻었습니다. 이것이 무슨 뜻일까요? 무어과 비교했을 때 좋은 걸까요? 좋은 성능을 낸다는 것이 무엇을 의미하는지 이해하기 위해 비교 대상이 되는 간단한 (신경망이 아닌) 기준선 테스트가 있다는 것은 유익합니다. 물론 가장 간단한 기준선은 숫자를 무작위로 추측하는 것입니다. 그러면 10%정도 맞을 겁니다. 하지만 우리 프로그램은 그보다 훨씬 더 잘합니다.
좀 더 간단한 기준선은 어떨까요? 아주 간단한 아이디어를 시도해 봅시다. 이미지가 얼마나 어두운지 살펴봅시다. 예를 들어 2의 이미지는 1보다 훨씬 어둡습니다. 다음 예에서 알 수 있듯이 더 많은 픽셀이 검은색으로 칠해지기 때문입니다.
이는 0, 1, 2, ..., 0의 각 숫자의 평균적인 어두운 정도를 계산하기 위해 훈련 데이터를 사용하는 방법을 암시하기도 합니다. 새로운 이미지가 제시되면 이미지의 어두운 정도를 계산한 후 평균적으로 가장 어두운 정도에 가까운 숫자를 추축합니다. 이는 간단한 절차이며 코딩하기도 쉽기 때문에 코드를 명시적으로 작성하지 않겠습니다. 관심이 있으시면, GitHub 저장소에 그 코드가 있습니다. 하지만 이것은 무작위로 추측하는 것보다 훨씬 나은 방법입니다. 10,000개의 테스트 이미지 중에 2,225개를 정확하게 (약 22.25%) 분류할 수 있습니다.
20~50%의 정확성을 달성하는 다른 아이디어를 찾는 것은 어렵지 않습니다. 조금 더 열심히 노력하면 50%를 넘은 정확성을 얻을 수 있습니다. 하지만 훨씬 더 높은 정확도를 얻으려면 기존 머신 러닝 알고리즘을 사용하는 것이 좋습니다. 가장 잘 알려진 알고리즘 중 하나인 서포트 벡터 머신(Support Vector Machine, SVM)을 사용해 보겠습니다. SVM에 익숙하지 않더라도 걱정하지 마세요. SVM의 작동 방식을 자세히 이해할 필요는 없습니다. 대신 scikit-learn이라는 Python 라이브러리를 사용하겠습니다. 이 라이브러리는 LIBSVM이라는 SVM용 C 기반 라이브러리에 대한 간단한 Python 인터페이스를 제공합니다.
scikit-learn의 SVM 분류기를 기본 설정으로 실행하면 10,000개의 데스트 이미지 중 9,435개를 정확하게 분류합니다. (코드는 여기서 확인할 수 있습니다.) 이는 이미지의 어두움을 기준으로 분류하는 우리의 단순한 접근 방식보다 크게 개선된 것입니다. 실제로 SVM은 기존 신경망과 거의 비슷한 성능을 보이지만 약간 더 떨어집니다. 이후 장에서는 신경망 성능을 개선하여 SVM보다 훨씬 더 나은 성능을 낼 수 있는 새로운 기법들을 소개하겠습니다.
하지만 여기서 끝이 아닙니다. 10,000개 중 9,435개의 결과는 scikit-learn의 SVM 기본 설정에 대한 결과입니다. SVM에는 조정 가능한 매개변수가 여러 개 있으며, 이러한 기본 성능을 향상시키는 매개변수를 검색할 수 있습니다. 더 자세히 알고 싶다면, Andreas Mueller의 불로그를 참고하세요. Mueller는 SVM 매개변수를 최적화하는 작업을 통해 성능을 98.5%이상의 정확도로 높일 수 있음을 보여줍니다. 다시 말해, 잘 조정된 SVM은 70개 중 약 한자리 수에서만 오류를 범합니다. 이는 꽤 좋은 결과입니다. 신경망은 더 나은 성능을 낼 수 있을까요?
사실 가능합니다. 현재 잘 설계된 신경망은 SVM을 포함한 MNIST를 해결하는 다른 모든 기법보다 성능이 더 뛰어납니다. 2013년의 기록은 10,000개의 이미지 중 9,979개를 정확하게 분류하는 것입니다. 이는 Li Wan, Matthew Zeiler, Sixin Zhang, Yann LeCun, Rob Fergus가 수행했습니다. 이 책의 뒷부분에서 이들이 사용한 대부분의 기법을 살펴보겠습니다. 이 수준에서는 인간의 성능과 거의 동일하며 상당후의 MNIST 이미지는 인간조차 확실하게 인식하기 어렵기 때문에 더 우수하다고 할 수 있습니다.
예를 들어,
여러분도 저것들이 분류하기 어렵다는데 동의하시리라 믿습니다. MNIST 데이터 세트에 이러한 이미지가 있는데, 신경망이 10,000개의 테시트 이미지 중 21개를 제외한 모든 이미지를 정확하게 분류할 수 있다는 것은 놀라운 일입니다. 일반적으로 프로그래밍할 때 MNIST 숫자 인식과 같은 복잡한 문제를 해결하려면 정교한 알고리즘이 필요하다고 생각합니다. 하지만 방금 언급한 Wan 외 논문의 신경망 조차도 이 장에서 살펴본 알고리즘의 변형인 매우 간단한 알고리즘을 사용합니다. 모든 복잡성은 훈련 데이터에서 자동으로 학습됩니다. 어떤 의미에서 저희 결과와 더 정교한 논문의 결과 모두에서 얻을 수 있는 교훈은 일부 문제에 대해 다음과 같다는 것입니다.
$정교한 알고리즘 \leq$ 간단한 학습 알고리즘 + 좋은 학습 데이터
주: 이 책에 나오는 코드들은 파이선 2.7 환경에 맞추어져 있습니다. 파이선 3.13 환경에 맞추어진 작동하는 코드는 다음 링크에서 받을 수 있습니다. run.py가 network.py와 mnist_loader.py를 이용하여 이 책의 deep learning 프로그램을 실행시킵니다. 이 프로그램을 실행하기 위해서는 mnist의 손으로쓴 숫자 학습 데이터를 다운받아서 적절한 위치에 저장하고, 그 위치를 mnist_loader.py에 지정해주어야 합니다.
딥러닝을 향하여
우리의 신경망은 안정적인 성능을 보이지만, 그 성능은 다소 모호합니다. 신경망의 가중치와 편향은 자동으로 발견되었습니다. 즉, 신경망이 어떻게 동작하는지 바로 알 수 없다는 뜻입니다. 신경망이 손글씨 숫자를 분류하는 원리를 이해할 수 있는 방법을 찾을 수 있을까요? 그리고 이러한 원리를 바탕으로 더 나은 결과를 얻을 수 있을까요?
이러한 질문을 더욱 명확하게 표현하면, 수십 년 후 신경망이 인공지능(AI)로 이어진다고 가정해 보겠습니다. 우리는 그러한 지능형 네트워크의 작동방식을 이해할 수 있을까요? 어쩌면 네트워크는 자동으로 학습되었기 때문에 우리가 이해하지 못하는 가중치와 편향을 가진 불투명한 상태로 남을지도 모릅니다. AI 연구 초창기에는 AI를 개발하려는 노력이 지능의 원리와 어쩌면 인간 뇌의 기능을 이해하는 데 도움이 될 것이라고 기대했습니다. 하지만 결국 우리의 뇌도, 인공지능의 작동 방식도 이해하지 못하는 결과를 낳을지도 모릅니다.
이렇한 질문들에 답하기 위해 이 장의 서두에서 제가 제시했던 인공뉴런에 대한 해석을 다시 생각해보겠습니다. 이는 증거를 평가하는 수단입니다. 이미지에 사람 얼굴이 있는지 없는지 판단하고 싶다고 가정해보겠습니다.
이 문제는 손글씨 인식을 공격했던 것과 같은 방식으로 공격할 수 있습니다. 즉, 이미지 픽셀을 신경망에 입력으로 사용하고 네트워크의 출력은 "예 얼굴입니다" 또는 "아니오 얼굴이 아닙니다"를 나타내는 단일 뉴런이 됩니다.
학습 알고리즘을 사용하지 않고 이렇게 한다고 가정해봅시다. 대신 적절한 가중치와 편향을 선택하여 네트워크를 직접 설계해 보겠습니다. 어ㄸ허게 하면 될까요? 일단 신경망은 완전히 잊어버리고 우리가 사용할 수 있는 휴리스틱은 문제를 여러 하위 문제로 분해하는 것입니다. 이미지 왼쪽 상단에 눈이 있나요? 오른쪽 상단에 눈이 있나요? 가운데에 코가 있나요? 가운데 하단에 입이 있나요? 위쪽 머리카락이 있나요? 등등.
이 문제들 중 여러 개에 대한 답이 "예"이거나 심지어 "아마도 그럴 것"이라면 해당 이미지는 얼굴일 가능성이 높다고 결론지을 수 있습니다. 반대로 대부분의 질문에 대한 답이 "아니오"라면 해당 이미지는 얼굴이 아닐 가능성이 높습니다.
물론 이는 단순한 추론일 뿐이며 여러가지 단점이 있습니다. 예를 들어 대머리라서 머리카락이 없는 사람일 수도 있고, 얼굴의 일부만 보이거나 얼굴이 비스듬히 있어서 얼굴 특징 중 일부가 가려지는 경우도 있습니다. 그럼에도 불구하고 이 추론은 신경망을 결합하여 얼굴 검출을 위해 신경망을 구축할 수 있음을 시사합니다. 다음은 가능한 아키텍처이며, 사각형은 하위 네트워크를 나타냅니다. 이는 얼굴 검출 문제를 해결하는 현실적인 접근법이 아니라, 네트워크 작동 방식에 대한 직관력을 기르도록 돕기 위한 것입니다.
하위 네트워크도 분해될 수 있습니다. "왼쪽 상단에 눈이 있나요?"라는 질문을 생각해 봅시다. 이 질문은 "눈썹이 있나요?", "속눈썹이 있나요?", "홍체가 있나요?" 등과 같은 질문으로 분해될 수 있습니다. 물론 이러한 질문에는 "눈썹이 왼쪽 상단에 있고 홍체 위에 있나요?"와 같은 위치정보도 포함되어야 하지만 간단하게 생각해 보겠습니다. "왼쪽 상단에 눈이 있나요?"라는 질문에 답하는 네트워크는 이제 다음과 같이 분해될 수 있습니다.
이러한 질문들 역시 여러 개층을 통해 더욱 세분화될 수 있습니다. 궁극적으로 우리는 단일 픽셀 수준에서 쉽게 답할 수 있을 만큼 간단한 질문들에 답하는 하위 네트워크를 구축할 것입니다. 예를 들어, 이러한 질문들은 이미지의 특정 지점에 매우 단순한 모양이 있는지 없는지에 대한 것일 수 있습니다. 이러한 질문들은 이미지의 원시 픽셀에 연결된 단일 뉴런을 통해 답할 수 있습니다.
최종 결과는 매우 복잡한 질문(이 이미지에 얼굴이 보이는가?)을 단일 픽셀 수준에서 답할 수 있는 매우 단순한 질문들로 분해하는 네트워크입니다. 이 네트워크들은 여러 층으로 구성되어 있으며, 초기 층들은 입력 이미지에 대한 매우 간단하고 구체적인 질문에 답하고 이후 층들은 점점 더 복잡하고 추상적인 개념들의 계층 구조를 구축합니다. 이렇한 다층 구조를 가진 네트워크를 심층 신경망(Deep Neural Network)이라고 합니다.
물론, 이러한 재귀적 분해를 하위 네트워크로 어떻게 수행하는지 아직 언급하지 않았습니다. 네트워크의 가중치와 편향을 직접 서례하는 것은 분명 실용적이지 않습니다. 대신, 학습 알고리즘을 사용하여 네트워크가 학습 데이터로 부터 가중치와 편향, 그리고 개념의 계층 구조를 자동으로 학습할 수 있도로 록 해야합니다. 1980년대와 1990년대의 연구자들은 확률적 경사 하강법과 역전파법을 사용하여 심층 네트워크를 학습하려고 시도했습니다. 안타깝게도 몇 가지 특수 아키텍쳐를 제외하고는 큰 성과를 거두지 못했습니다. 네트워크 학습을 했지만 매우 느렸고, 실제로는 너무 느려서 유용하지 않은 경우가 많았습니다.
2006년 이후, 심층 신경망 학습을 가능하게 하는 여러 기법들이 개발되었습니다. 이러한 심층 학습 기법들은 확률적 경사 하강법과 역전파법을 기반으로 하지만, 새로운 아이디어들도 도입되었습니다. 이러한 기법들을 통해 훨씬 더 깊고 큰 네트워크의 학습이 가능해졌습니다. 사람들은 이제 5~10개의 은닉층으로 구성된 네트워크를 일상적으로 학습시키고 있습니다. 그리고 이러한 네트워크들은 여러 문제에서 얕은 신경망, 즉 은닉층이 하나뿐인 네트워크보다 훨씬 더 나은 성능을 보이는 것으로 나타났습니다. 그 이유는 물론 심층 신경망이 복잡한 개념 개층 구조를 구축할 수 있기 때문입니다. 이는 기존 프로그래밍 언어가 모듈식 설계와 추상화에 대한 아이디어를 활용하여 복잡한 컴퓨터 프로그램을 만드는 방식과 유사합니다. 심층 네트워크와 얕은 네트워크를 비교하는 것은 함수 호출이 가능한 프로그래밍 언어와 그러한 호출이 불가능한 단순 언어의 비교와 같습니다. 신경망에서 추상화는 기존 프로그래밍과 다른 형태를 띠지만, 그 중요성은 매우 큽니다.
댓글 없음:
댓글 쓰기