이미지 로딩 중...

인공 신경망 기초 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 22. · 6 Views

인공 신경망 기초 완벽 가이드

퍼셉트론부터 역전파까지, 딥러닝의 핵심 원리를 초급 개발자도 쉽게 이해할 수 있도록 설명합니다. 실제 코드로 직접 구현하며 인공 신경망의 작동 원리를 배워봅니다.


목차

  1. 퍼셉트론 - 신경망의 가장 기본 단위
  2. 활성화 함수 - 비선형성을 만드는 핵심
  3. 순전파 - 입력에서 출력까지의 데이터 흐름
  4. 손실 함수 - 예측의 정확도를 측정하는 기준
  5. 역전파 - 오차를 거슬러 올라가며 학습하기
  6. 경사하강법 - 최적의 가중치를 찾아가는 여정
  7. 미니배치 학습 - 효율적인 데이터 처리 전략
  8. 과적합과 정규화 - 일반화 성능 향상 기법
  9. 전체 학습 루프 - 모든 것을 하나로 합치기
  10. 실전 예제 - XOR 문제 풀기

1. 퍼셉트론 - 신경망의 가장 기본 단위

시작하며

여러분이 스팸 메일을 분류하거나 고양이 사진을 찾는 프로그램을 만들 때 이런 생각을 해본 적 있나요? "컴퓨터가 어떻게 이걸 판단할까?" 이런 문제는 실제 개발 현장에서 매우 자주 발생합니다.

단순한 if-else 문으로는 복잡한 패턴을 인식할 수 없고, 모든 경우의 수를 직접 코딩하는 것은 불가능에 가깝습니다. 바로 이럴 때 필요한 것이 퍼셉트론입니다.

퍼셉트론은 인간의 뉴런(신경세포)을 모방한 가장 기본적인 인공 신경망 단위로, 여러 입력값을 받아서 하나의 결정을 내리는 방법을 학습합니다.

개요

간단히 말해서, 퍼셉트론은 여러 개의 입력을 받아서 가중치를 곱한 후 합산하여 하나의 출력을 만드는 수학적 모델입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 데이터에서 패턴을 찾아내고 분류하는 모든 작업의 기초가 되기 때문입니다.

예를 들어, 사용자의 나이와 구매 이력을 보고 특정 상품을 추천할지 말지 결정하는 경우에 매우 유용합니다. 전통적인 방법과 비교하면, 기존에는 "나이가 30세 이상이고 구매 횟수가 10회 이상이면 추천한다"라는 규칙을 직접 작성했다면, 퍼셉트론은 데이터를 통해 자동으로 이런 규칙을 학습합니다.

퍼셉트론의 핵심 특징은 첫째, 여러 입력에 각각 다른 중요도(가중치)를 부여할 수 있고, 둘째, 임계값을 넘으면 활성화되는 이진 분류가 가능하며, 셋째, 학습을 통해 가중치를 자동으로 조정할 수 있다는 점입니다. 이러한 특징들이 중요한 이유는 복잡한 판단 기준을 사람이 직접 설계하지 않아도 데이터만 있으면 컴퓨터가 스스로 배울 수 있기 때문입니다.

코드 예제

import numpy as np

# 퍼셉트론 클래스 정의
class Perceptron:
    def __init__(self, input_size, learning_rate=0.01):
        # 가중치를 랜덤하게 초기화 (입력 개수만큼)
        self.weights = np.random.randn(input_size)
        # 편향(bias)도 초기화
        self.bias = np.random.randn()
        self.learning_rate = learning_rate

    def predict(self, inputs):
        # 입력과 가중치의 곱을 모두 더하고 편향을 더함
        total = np.dot(inputs, self.weights) + self.bias
        # 0보다 크면 1, 아니면 0 반환 (계단 함수)
        return 1 if total > 0 else 0

설명

이것이 하는 일: 퍼셉트론은 마치 우리 뇌의 뉴런처럼 여러 신호를 받아서 하나의 결정을 내립니다. 예를 들어 "이 이메일은 스팸인가?"라는 질문에 여러 특징(발신자, 제목의 특정 단어 포함 여부 등)을 종합해서 YES/NO로 답합니다.

첫 번째로, __init__ 메서드에서 가중치(weights)와 편향(bias)을 초기화합니다. 가중치는 각 입력이 얼마나 중요한지를 나타내는 숫자인데, 처음에는 랜덤값으로 시작해서 학습을 통해 점점 적절한 값으로 바뀝니다.

이것은 마치 처음 자전거를 배울 때는 균형을 잡는 방법을 모르지만 계속 타다 보면 자연스럽게 익히는 것과 같습니다. 그 다음으로, predict 메서드가 실행되면서 실제 예측을 수행합니다.

np.dot(inputs, self.weights)는 각 입력값과 가중치를 곱한 후 모두 더하는 연산인데, 이것은 "여러 증거를 종합해서 판단한다"는 의미입니다. 예를 들어, 스팸 메일 분류에서 "무료"라는 단어가 있으면 +5점, 발신자가 신뢰할 만하면 -3점 이런 식으로 점수를 매기는 것입니다.

마지막으로, 계산된 총합(total)이 0보다 크면 1을 반환하고, 그렇지 않으면 0을 반환합니다. 이것은 마치 "증거를 종합한 점수가 기준점을 넘으면 스팸으로 분류한다"는 의미입니다.

이 기준점을 조정하는 것이 바로 편향(bias)의 역할입니다. 여러분이 이 코드를 사용하면 데이터의 특징들을 자동으로 학습하여 분류 작업을 수행할 수 있습니다.

실무에서의 이점은 첫째, 복잡한 규칙을 직접 작성할 필요가 없고, 둘째, 데이터가 바뀌어도 재학습만 하면 되며, 셋째, 수학적으로 명확하게 정의되어 있어 결과를 예측하기 쉽다는 점입니다.

실전 팁

💡 가중치 초기화는 매우 중요합니다. 모두 0으로 초기화하면 학습이 안 되므로 반드시 랜덤값으로 초기화하세요. np.random.randn()을 사용하면 평균 0, 표준편차 1인 정규분포에서 값을 뽑아옵니다.

💡 학습률(learning_rate)을 너무 크게 설정하면 학습이 불안정해지고, 너무 작게 하면 학습이 너무 느립니다. 보통 0.001~0.1 사이에서 시작해서 실험적으로 조정하세요.

💡 퍼셉트론은 선형 분류만 가능합니다. XOR 문제처럼 직선으로 나눌 수 없는 데이터는 분류할 수 없으니, 이런 경우 다층 퍼셉트론(MLP)을 사용해야 합니다.

💡 입력 데이터는 정규화(normalization)하는 것이 좋습니다. 예를 들어 나이(0100)와 연봉(200010000만원)을 함께 사용하면 스케일 차이 때문에 학습이 어려우므로, 0~1 사이로 조정하세요.

💡 실전에서는 단일 퍼셉트론보다는 여러 개를 층으로 쌓은 신경망을 사용합니다. 하지만 원리를 이해하려면 퍼셉트론부터 확실히 익혀야 합니다.


2. 활성화 함수 - 비선형성을 만드는 핵심

시작하며

여러분이 신경망으로 복잡한 패턴을 인식하려고 할 때 이런 문제를 겪어본 적 있나요? "아무리 층을 많이 쌓아도 성능이 안 좋아진다." 이런 문제는 활성화 함수 없이 선형 연산만 반복하면 발생합니다.

수학적으로, 선형 함수를 아무리 여러 번 합성해도 결국 하나의 선형 함수가 되기 때문에 복잡한 패턴을 학습할 수 없습니다. 바로 이럴 때 필요한 것이 활성화 함수입니다.

활성화 함수는 신경망에 비선형성을 추가하여 복잡한 패턴을 학습할 수 있게 만들어주는 핵심 요소입니다.

개요

간단히 말해서, 활성화 함수는 뉴런의 출력값을 특정 범위로 변환하거나 특정 조건에서만 활성화되도록 만드는 함수입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 실제 세상의 데이터는 대부분 비선형적이기 때문입니다.

예를 들어, 공부 시간과 시험 성적의 관계는 처음에는 급격히 올라가다가 나중에는 완만해지는 곡선 형태를 보이는데, 이런 패턴은 직선으로 표현할 수 없습니다. 전통적인 퍼셉트론은 계단 함수(step function)만 사용했다면, 현대 신경망은 시그모이드(Sigmoid), ReLU, Tanh 등 다양한 활성화 함수를 사용합니다.

활성화 함수의 핵심 특징은 첫째, 비선형성을 제공하여 복잡한 패턴 학습이 가능하고, 둘째, 출력값의 범위를 제한하여 학습을 안정화시키며, 셋째, 미분 가능해야 역전파 학습이 가능하다는 점입니다. 이러한 특징들이 중요한 이유는 신경망이 단순한 선형 회귀를 넘어서 이미지 인식, 자연어 처리 같은 복잡한 작업을 수행할 수 있게 하기 때문입니다.

코드 예제

import numpy as np

# 다양한 활성화 함수들
class ActivationFunctions:
    @staticmethod
    def sigmoid(x):
        # 시그모이드: 출력을 0~1 사이로 압축
        return 1 / (1 + np.exp(-x))

    @staticmethod
    def relu(x):
        # ReLU: 0보다 크면 그대로, 작으면 0
        return np.maximum(0, x)

    @staticmethod
    def tanh(x):
        # Tanh: 출력을 -1~1 사이로 압축
        return np.tanh(x)

    @staticmethod
    def softmax(x):
        # Softmax: 여러 클래스의 확률로 변환 (합이 1)
        exp_x = np.exp(x - np.max(x))  # 수치 안정성을 위해 최댓값 빼기
        return exp_x / np.sum(exp_x)

설명

이것이 하는 일: 활성화 함수는 뉴런의 출력값을 변환하여 신경망이 직선이 아닌 곡선으로 데이터를 표현할 수 있게 합니다. 마치 레고 블록을 직선으로만 쌓는 것이 아니라 곡선과 복잡한 모양을 만들 수 있게 해주는 것과 같습니다.

첫 번째로, Sigmoid 함수는 입력값을 0~1 사이로 압축합니다. 예를 들어 -100을 넣으면 거의 0, +100을 넣으면 거의 1이 나옵니다.

이것은 확률을 나타낼 때 유용하지만, 입력값이 매우 크거나 작으면 기울기가 0에 가까워져서 학습이 느려지는 "기울기 소실" 문제가 있습니다. 그 다음으로, ReLU(Rectified Linear Unit) 함수는 가장 널리 사용되는 활성화 함수입니다.

0보다 크면 입력값을 그대로 통과시키고, 0보다 작으면 0으로 만듭니다. 이것은 계산이 매우 빠르고 기울기 소실 문제가 적어서 깊은 신경망에서 잘 작동합니다.

다만 입력이 음수인 뉴런은 완전히 죽어버릴 수 있다는 단점이 있습니다. Tanh 함수는 Sigmoid와 비슷하지만 출력 범위가 -1~1입니다.

이것은 데이터의 평균이 0 근처에 있을 때 더 좋은 성능을 보이며, 은닉층(hidden layer)에서 자주 사용됩니다. 마지막으로, Softmax 함수는 여러 개의 출력을 확률 분포로 변환합니다.

예를 들어 [2.0, 1.0, 0.1]을 입력하면 [0.7, 0.2, 0.1] 같은 형태로 변환되며, 모든 값을 더하면 정확히 1이 됩니다. 이것은 분류 문제의 마지막 층에서 "이 이미지가 고양이일 확률 70%, 강아지일 확률 20%, 새일 확률 10%" 같은 형태로 결과를 표현할 때 사용됩니다.

여러분이 이 코드를 사용하면 문제의 특성에 맞는 활성화 함수를 선택하여 학습 성능을 크게 향상시킬 수 있습니다. 실무에서의 이점은 첫째, ReLU는 대부분의 상황에서 좋은 기본 선택이고, 둘째, Sigmoid는 이진 분류 출력층에, 셋째, Softmax는 다중 클래스 분류 출력층에 사용하면 됩니다.

실전 팁

💡 은닉층에는 보통 ReLU를 사용하세요. 계산이 빠르고 대부분의 경우 좋은 성능을 보입니다. Sigmoid나 Tanh는 깊은 신경망에서 기울기 소실 문제를 일으킬 수 있습니다.

💡 이진 분류 문제의 출력층에는 Sigmoid를, 다중 클래스 분류에는 Softmax를 사용하세요. 회귀 문제라면 활성화 함수 없이 선형 출력을 사용합니다.

💡 ReLU의 변형으로 Leaky ReLU나 PReLU도 있습니다. 이들은 음수 입력에 대해 작은 기울기를 허용하여 "죽은 뉴런" 문제를 완화합니다.

💡 Softmax 구현 시 수치 안정성을 위해 입력에서 최댓값을 빼주세요. 그렇지 않으면 exp() 함수가 오버플로우를 일으킬 수 있습니다.

💡 최근에는 GELU, Swish 같은 새로운 활성화 함수도 나오고 있습니다. 특히 Transformer 모델에서는 GELU가 자주 사용되니 관심 있다면 찾아보세요.


3. 순전파 - 입력에서 출력까지의 데이터 흐름

시작하며

여러분이 신경망을 처음 공부할 때 이런 궁금증을 가져본 적 있나요? "입력 데이터가 들어가면 어떻게 예측값이 나오는 거지?" 이런 질문은 신경망의 가장 기본적인 동작 원리에 관한 것입니다.

많은 초보자들이 신경망을 블랙박스로만 이해하고 내부에서 무슨 일이 일어나는지 모른 채 사용합니다. 바로 이럴 때 필요한 것이 순전파(Forward Propagation)의 이해입니다.

순전파는 입력 데이터가 신경망의 각 층을 거쳐 최종 출력으로 변환되는 과정을 말하며, 이것을 이해해야 신경망이 어떻게 예측을 만들어내는지 알 수 있습니다.

개요

간단히 말해서, 순전파는 입력층에서 시작하여 은닉층들을 거쳐 출력층까지 데이터가 앞으로(forward) 흘러가는 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 순전파를 이해해야 신경망의 예측 과정을 디버깅하고 성능을 개선할 수 있기 때문입니다.

예를 들어, 중간층의 출력값을 확인하여 어느 부분에서 정보가 손실되는지 파악할 수 있습니다. 전통적인 프로그래밍에서는 "입력 → 처리 → 출력"이 명확했다면, 신경망에서는 "입력 → 여러 층의 변환 → 출력"이며 각 층마다 가중치 곱셈과 활성화 함수 적용이 반복됩니다.

순전파의 핵심 특징은 첫째, 각 층에서 가중치 곱셈 후 활성화 함수를 적용하고, 둘째, 데이터가 한 방향으로만 흐르며(순환 없음), 셋째, 중간 결과들을 저장해두어야 나중에 역전파에서 사용할 수 있다는 점입니다. 이러한 특징들이 중요한 이유는 신경망의 학습이 순전파로 예측하고 역전파로 개선하는 반복 과정이기 때문입니다.

코드 예제

import numpy as np

class NeuralNetwork:
    def __init__(self, input_size, hidden_size, output_size):
        # 층 간 가중치 초기화
        self.W1 = np.random.randn(input_size, hidden_size) * 0.01
        self.b1 = np.zeros((1, hidden_size))
        self.W2 = np.random.randn(hidden_size, output_size) * 0.01
        self.b2 = np.zeros((1, output_size))

    def forward(self, X):
        # 첫 번째 층: 입력 → 은닉층
        self.z1 = np.dot(X, self.W1) + self.b1  # 선형 변환
        self.a1 = np.maximum(0, self.z1)  # ReLU 활성화

        # 두 번째 층: 은닉층 → 출력층
        self.z2 = np.dot(self.a1, self.W2) + self.b2  # 선형 변환
        self.a2 = 1 / (1 + np.exp(-self.z2))  # Sigmoid 활성화

        return self.a2  # 최종 예측값 반환

설명

이것이 하는 일: 순전파는 마치 공장의 생산 라인처럼 데이터가 여러 단계를 거쳐 변환되는 과정입니다. 원자재(입력)가 여러 기계(층)를 거쳐 완제품(출력)이 되는 것과 같습니다.

첫 번째로, __init__ 메서드에서 각 층 사이의 가중치(W1, W2)와 편향(b1, b2)을 초기화합니다. 가중치는 작은 랜덤값으로 초기화하는데, 0.01을 곱하는 이유는 너무 큰 초기값이 학습을 불안정하게 만들기 때문입니다.

편향은 0으로 초기화해도 괜찮습니다. 그 다음으로, forward 메서드의 첫 번째 층에서 입력 X와 가중치 W1을 곱하고(np.dot) 편향 b1을 더합니다.

이것은 선형 변환으로, 입력 특징들을 조합하여 새로운 표현을 만듭니다. 그 결과(z1)에 ReLU 활성화 함수를 적용하여 비선형성을 추가합니다.

이때 중간 결과인 z1과 a1을 self에 저장하는 이유는 나중에 역전파에서 사용하기 위함입니다. 두 번째 층에서도 같은 과정을 반복합니다.

은닉층의 출력(a1)과 두 번째 가중치(W2)를 곱하고 편향을 더한 후, 이번에는 Sigmoid 활성화 함수를 적용합니다. Sigmoid를 사용하는 이유는 최종 출력을 0~1 사이의 확률값으로 만들기 위함입니다.

마지막으로, 모든 변환이 끝나면 최종 예측값(a2)을 반환합니다. 예를 들어 이진 분류 문제라면 0.5보다 크면 클래스 1, 작으면 클래스 0으로 판단할 수 있습니다.

여러분이 이 코드를 사용하면 신경망이 어떻게 입력을 변환하여 예측을 만드는지 직접 눈으로 확인할 수 있습니다. 실무에서의 이점은 첫째, 각 층의 출력을 확인하여 디버깅할 수 있고, 둘째, 중간 결과를 시각화하여 신경망이 무엇을 학습했는지 이해할 수 있으며, 셋째, 이 구조를 확장하여 더 깊은 신경망을 만들 수 있다는 점입니다.

실전 팁

💡 가중치는 절대 모두 0으로 초기화하지 마세요. 그러면 모든 뉴런이 같은 값으로 업데이트되어 학습이 안 됩니다. He 초기화나 Xavier 초기화 같은 방법을 사용하세요.

💡 중간 결과(z1, a1 등)를 반드시 저장하세요. 역전파 계산에 필요하며, 디버깅할 때도 매우 유용합니다. 각 층의 출력 범위를 확인하여 이상한 값이 없는지 체크하세요.

💡 배치 처리를 위해 입력 X는 보통 (배치크기, 특징개수) 형태의 2D 배열입니다. 예를 들어 100개 샘플을 한번에 처리하려면 (100, input_size) 형태가 됩니다.

💡 층을 추가할 때는 앞 층의 출력 크기가 다음 층의 입력 크기와 일치해야 합니다. 차원이 안 맞으면 행렬 곱셈이 불가능하므로 에러가 발생합니다.

💡 실전에서는 Dropout이나 Batch Normalization 같은 정규화 기법도 순전파에 포함됩니다. 학습 시와 예측 시 동작이 다르므로 주의하세요.


4. 손실 함수 - 예측의 정확도를 측정하는 기준

시작하며

여러분이 신경망을 학습시킬 때 이런 고민을 해본 적 있나요? "예측이 얼마나 틀렸는지 어떻게 수치로 표현하지?" 이런 문제는 모든 머신러닝 모델에서 핵심적입니다.

컴퓨터는 "예측이 좋다/나쁘다"를 직관적으로 이해할 수 없고, 정확한 숫자로 표현되어야 개선할 방향을 알 수 있습니다. 바로 이럴 때 필요한 것이 손실 함수(Loss Function)입니다.

손실 함수는 모델의 예측값과 실제 정답 사이의 차이를 하나의 숫자로 계산하여, 모델이 얼마나 잘못 예측했는지를 알려줍니다.

개요

간단히 말해서, 손실 함수는 "모델의 성적표"입니다. 예측이 정답에 가까우면 작은 값을, 멀리 떨어져 있으면 큰 값을 반환합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, 신경망 학습의 목표가 바로 이 손실값을 최소화하는 것이기 때문입니다. 예를 들어, 주식 가격 예측 모델을 만든다면 예측가와 실제가의 차이(손실)를 줄이는 방향으로 학습합니다.

전통적인 프로그래밍에서는 정답이 맞으면 1점, 틀리면 0점 같은 단순한 평가였다면, 손실 함수는 "얼마나 틀렸는지"를 연속적인 값으로 표현하여 더 섬세한 학습이 가능합니다. 손실 함수의 핵심 특징은 첫째, 문제 유형(분류/회귀)에 따라 다른 함수를 사용하고, 둘째, 미분 가능해야 경사하강법으로 최적화할 수 있으며, 셋째, 항상 양수 값을 가지고 예측이 정확할수록 0에 가까워진다는 점입니다.

이러한 특징들이 중요한 이유는 손실값의 기울기를 따라 가중치를 업데이트하는 것이 신경망 학습의 핵심이기 때문입니다.

코드 예제

import numpy as np

class LossFunctions:
    @staticmethod
    def binary_cross_entropy(y_true, y_pred, epsilon=1e-15):
        # 이진 분류용: 예측 확률과 실제 레이블 간의 차이
        # epsilon: log(0) 방지를 위한 작은 값
        y_pred = np.clip(y_pred, epsilon, 1 - epsilon)
        return -np.mean(y_true * np.log(y_pred) +
                       (1 - y_true) * np.log(1 - y_pred))

    @staticmethod
    def categorical_cross_entropy(y_true, y_pred, epsilon=1e-15):
        # 다중 클래스 분류용
        y_pred = np.clip(y_pred, epsilon, 1 - epsilon)
        return -np.mean(np.sum(y_true * np.log(y_pred), axis=1))

    @staticmethod
    def mean_squared_error(y_true, y_pred):
        # 회귀 문제용: 예측값과 실제값의 차이 제곱의 평균
        return np.mean((y_true - y_pred) ** 2)

설명

이것이 하는 일: 손실 함수는 마치 과녁에서 화살이 중심에서 얼마나 떨어져 있는지 측정하는 것과 같습니다. 중심에 가까울수록 점수가 높고(손실이 낮고), 멀수록 점수가 낮습니다(손실이 높습니다).

첫 번째로, Binary Cross-Entropy는 이진 분류(예/아니오) 문제에 사용됩니다. 예를 들어 스팸 메일 분류에서 실제로는 스팸(y_true=1)인데 모델이 0.9의 확률로 스팸이라고 예측했다면 손실이 작고, 0.1의 확률로 예측했다면 손실이 큽니다.

np.clip을 사용하는 이유는 확률이 정확히 0이나 1이 되면 log(0)에서 무한대가 되기 때문에 아주 작은 값(epsilon)으로 보정합니다. 그 다음으로, Categorical Cross-Entropy는 여러 클래스 중 하나를 선택하는 문제에 사용됩니다.

예를 들어 고양이/강아지/새 분류에서 실제 정답은 1, 0, 0인데 모델이 [0.7, 0.2, 0.1]로 예측했다면, 고양이를 가장 높은 확률로 예측했으므로 손실이 비교적 작습니다. y_true는 원-핫 인코딩 형태여야 합니다.

Mean Squared Error(MSE)는 회귀 문제에 사용됩니다. 예를 들어 집값 예측에서 실제가가 3억인데 2.8억으로 예측했다면 차이(0.2억)의 제곱이 손실이 됩니다.

제곱을 하는 이유는 큰 오차에 더 큰 패널티를 주기 위함이며, 평균을 내는 이유는 데이터 개수에 관계없이 일관된 척도를 얻기 위함입니다. 마지막으로, 손실값이 계산되면 이것을 기반으로 역전파를 수행하여 가중치를 업데이트합니다.

손실이 크면 가중치를 크게 조정하고, 손실이 작으면 작게 조정합니다. 여러분이 이 코드를 사용하면 문제 유형에 맞는 손실 함수를 선택하여 모델을 효과적으로 학습시킬 수 있습니다.

실무에서의 이점은 첫째, 분류 문제에는 Cross-Entropy가 MSE보다 훨씬 효과적이고, 둘째, 손실값을 모니터링하여 학습이 잘 진행되는지 확인할 수 있으며, 셋째, 손실이 감소하지 않으면 학습률이나 모델 구조를 조정해야 한다는 신호로 활용할 수 있습니다.

실전 팁

💡 분류 문제에는 절대 MSE를 사용하지 마세요. Cross-Entropy가 확률 예측에 훨씬 적합하며 학습 속도도 빠릅니다. MSE는 회귀 문제에만 사용하세요.

💡 손실값이 NaN이나 무한대가 나온다면 학습률이 너무 크거나 가중치 초기화에 문제가 있을 수 있습니다. epsilon 값을 조정하거나 gradient clipping을 사용하세요.

💡 학습 과정에서 손실값을 그래프로 그려보세요. 손실이 지그재그로 튀면 학습률이 너무 크고, 너무 천천히 감소하면 학습률이 너무 작습니다.

💡 검증 세트의 손실도 함께 모니터링하세요. 학습 손실은 계속 감소하는데 검증 손실이 증가하기 시작하면 과적합(overfitting)의 신호입니다.

💡 클래스 불균형 문제(예: 스팸 1% vs 정상 99%)에서는 가중치를 부여한 손실 함수나 Focal Loss 같은 변형을 사용하세요.


5. 역전파 - 오차를 거슬러 올라가며 학습하기

시작하며

여러분이 신경망 학습에 대해 궁금할 때 이런 질문을 해본 적 있나요? "손실이 크다는 건 알겠는데, 어느 가중치를 얼마나 바꿔야 하지?" 이런 문제는 신경망 학습의 가장 핵심적인 도전 과제입니다.

수백만 개의 가중치 중 어떤 것을 어느 방향으로 조정해야 손실이 줄어들지 찾아내는 것은 직관적으로 불가능합니다. 바로 이럴 때 필요한 것이 역전파(Backpropagation)입니다.

역전파는 출력층의 오차를 거꾸로 전달하면서 각 가중치가 손실에 기여한 정도(기울기)를 계산하여, 가중치를 어떻게 조정해야 할지 알려주는 알고리즘입니다.

개요

간단히 말해서, 역전파는 미적분의 연쇄 법칙(Chain Rule)을 사용하여 출력층에서 입력층까지 거슬러 올라가며 각 가중치의 기울기를 계산하는 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 이것이 없으면 신경망 학습이 사실상 불가능하기 때문입니다.

역전파 알고리즘이 발명되기 전에는 신경망이 이론적으로만 존재했고, 실용적인 학습이 어려웠습니다. 예를 들어, 이미지 분류 모델에서 특정 픽셀의 가중치가 최종 예측에 어떤 영향을 미치는지 정확히 계산할 수 있습니다.

전통적으로는 각 가중치를 조금씩 바꿔보면서 손실이 줄어드는지 확인하는 방법(수치적 기울기)을 사용했다면, 역전파는 수학적으로 한 번에 모든 기울기를 정확하게 계산합니다. 역전파의 핵심 특징은 첫째, 연쇄 법칙으로 복잡한 합성 함수의 미분을 효율적으로 계산하고, 둘째, 순전파에서 저장한 중간 결과를 재사용하며, 셋째, 출력층에서 입력층으로 역방향으로 진행한다는 점입니다.

이러한 특징들이 중요한 이유는 깊은 신경망도 실용적인 시간 안에 학습할 수 있게 해주기 때문입니다.

코드 예제

import numpy as np

class NeuralNetworkWithBackprop:
    def __init__(self, input_size, hidden_size, output_size, learning_rate=0.01):
        self.W1 = np.random.randn(input_size, hidden_size) * 0.01
        self.b1 = np.zeros((1, hidden_size))
        self.W2 = np.random.randn(hidden_size, output_size) * 0.01
        self.b2 = np.zeros((1, output_size))
        self.lr = learning_rate

    def backward(self, X, y):
        m = X.shape[0]  # 배치 크기

        # 출력층의 기울기 (손실에 대한 출력의 미분)
        dz2 = self.a2 - y  # Sigmoid + BCE의 간단한 형태
        dW2 = np.dot(self.a1.T, dz2) / m  # 가중치의 기울기
        db2 = np.sum(dz2, axis=0, keepdims=True) / m  # 편향의 기울기

        # 은닉층의 기울기 (연쇄 법칙 적용)
        dz1 = np.dot(dz2, self.W2.T) * (self.z1 > 0)  # ReLU 미분
        dW1 = np.dot(X.T, dz1) / m
        db1 = np.sum(dz1, axis=0, keepdims=True) / m

        # 가중치 업데이트 (경사하강법)
        self.W2 -= self.lr * dW2
        self.b2 -= self.lr * db2
        self.W1 -= self.lr * dW1
        self.b1 -= self.lr * db1

설명

이것이 하는 일: 역전파는 마치 산을 내려갈 때 가장 가파른 방향을 찾는 것과 같습니다. 손실이라는 산의 정상에서 출발하여, 각 가중치 방향으로 얼마나 가파른지(기울기)를 측정하고, 그 반대 방향으로 내려갑니다.

첫 번째로, 출력층의 기울기를 계산합니다. dz2 = self.a2 - y는 예측값(a2)과 실제값(y)의 차이인데, 이것이 바로 Sigmoid 활성화 함수와 Binary Cross-Entropy 손실을 조합했을 때의 기울기입니다.

수학적으로는 복잡한 미분이지만, 결과는 놀랍도록 간단합니다. 예를 들어 예측이 0.9인데 정답이 1이면 기울기는 -0.1이고, 예측이 0.3인데 정답이 1이면 기울기는 -0.7로 더 큽니다.

그 다음으로, 이 기울기를 사용하여 가중치 W2의 기울기를 계산합니다. dW2 = np.dot(self.a1.T, dz2) / m는 은닉층의 활성화(a1)와 출력층의 기울기(dz2)를 행렬 곱으로 결합합니다.

이것은 "은닉층의 각 뉴런이 출력 오차에 얼마나 책임이 있는가"를 계산하는 것입니다. 배치 크기 m으로 나누는 이유는 평균 기울기를 구하기 위함입니다.

은닉층의 기울기는 연쇄 법칙으로 계산됩니다. 먼저 np.dot(dz2, self.W2.T)로 출력층의 기울기를 역으로 전파하고, 여기에 (self.z1 > 0)를 곱하는데 이것은 ReLU의 미분입니다.

ReLU는 입력이 양수면 미분이 1, 음수면 0이므로 마스크 형태로 표현됩니다. 마지막으로, 계산된 기울기를 사용하여 가중치를 업데이트합니다.

self.W2 -= self.lr * dW2는 현재 가중치에서 (학습률 × 기울기)를 빼는 것인데, 기울기의 반대 방향으로 이동하여 손실을 줄입니다. 이것을 경사하강법(Gradient Descent)이라고 합니다.

여러분이 이 코드를 사용하면 신경망이 데이터로부터 자동으로 학습하는 과정을 구현할 수 있습니다. 실무에서의 이점은 첫째, 수백만 개의 파라미터도 효율적으로 학습 가능하고, 둘째, 층을 추가해도 같은 원리로 기울기를 계산할 수 있으며, 셋째, 이 원리를 이해하면 PyTorch나 TensorFlow 같은 프레임워크의 동작도 이해할 수 있습니다.

실전 팁

💡 역전파 구현 후 수치적 기울기(numerical gradient)와 비교하여 검증하세요. 차이가 1e-7 이상이면 버그가 있을 가능성이 높습니다.

💡 기울기 폭발을 방지하기 위해 gradient clipping을 사용하세요. 기울기의 노름이 일정 값(예: 5.0)을 넘으면 잘라냅니다.

💡 배치 정규화나 가중치 초기화를 잘못하면 기울기가 소실되어 깊은 층까지 전달되지 않습니다. He 초기화를 사용하고 층마다 기울기 크기를 모니터링하세요.

💡 실전에서는 PyTorch의 autograd나 TensorFlow의 GradientTape을 사용하여 자동으로 역전파를 계산합니다. 하지만 원리를 이해해야 디버깅이 가능합니다.

💡 Adam, RMSprop 같은 최적화 알고리즘은 기본 경사하강법을 개선한 것입니다. 기울기의 이동 평균을 사용하여 더 안정적으로 학습합니다.


6. 경사하강법 - 최적의 가중치를 찾아가는 여정

시작하며

여러분이 신경망을 학습시킬 때 이런 의문을 가져본 적 있나요? "기울기를 계산한 후에는 어떻게 가중치를 업데이트하지?" 이런 질문은 최적화 알고리즘의 핵심입니다.

기울기만 알고 있어도 어느 방향으로 가야 할지는 알지만, 얼마나 큰 걸음으로 갈지, 효율적으로 최적점에 도달하려면 어떤 전략을 써야 할지는 별개의 문제입니다. 바로 이럴 때 필요한 것이 경사하강법(Gradient Descent)과 그 변형들입니다.

경사하강법은 손실 함수의 기울기를 따라 반복적으로 가중치를 조정하여 최적의 값을 찾아가는 최적화 알고리즘입니다.

개요

간단히 말해서, 경사하강법은 "안개 낀 산에서 정상으로 올라가려면 주변의 경사만 보고 가장 가파른 방향으로 계속 올라간다"는 원리입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 신경망의 가중치 공간은 수백만 차원이어서 직접 최적값을 찾는 것이 불가능하기 때문입니다.

경사하강법은 국소 정보(기울기)만으로 전역 최적해에 가까운 값을 찾을 수 있습니다. 예를 들어, 1억 개의 파라미터를 가진 모델도 반복적인 업데이트로 학습할 수 있습니다.

전통적인 배치 경사하강법은 전체 데이터를 한 번에 사용했다면, 현대의 확률적 경사하강법(SGD)과 미니배치 방식은 일부 데이터만 사용하여 훨씬 빠르게 학습합니다. 경사하강법의 핵심 특징은 첫째, 학습률(learning rate)이 수렴 속도와 안정성을 결정하고, 둘째, 배치 크기에 따라 다양한 변형이 있으며, 셋째, Momentum, Adam 같은 개선 기법이 존재한다는 점입니다.

이러한 특징들이 중요한 이유는 실제 딥러닝 학습에서 기본 경사하강법만으로는 효율적인 학습이 어렵고, 문제에 맞는 최적화 기법 선택이 성능을 크게 좌우하기 때문입니다.

코드 예제

import numpy as np

class Optimizers:
    # 기본 경사하강법
    class SGD:
        def __init__(self, learning_rate=0.01):
            self.lr = learning_rate

        def update(self, params, grads):
            # 가중치 = 가중치 - (학습률 × 기울기)
            for key in params.keys():
                params[key] -= self.lr * grads[key]

    # Momentum: 이전 방향을 기억하여 가속
    class Momentum:
        def __init__(self, learning_rate=0.01, momentum=0.9):
            self.lr = learning_rate
            self.momentum = momentum
            self.v = {}  # 속도 저장

        def update(self, params, grads):
            if not self.v:  # 첫 호출 시 초기화
                for key in params.keys():
                    self.v[key] = np.zeros_like(params[key])

            for key in params.keys():
                # 속도 업데이트: 이전 속도 × momentum + 현재 기울기
                self.v[key] = self.momentum * self.v[key] - self.lr * grads[key]
                params[key] += self.v[key]

    # Adam: 적응적 학습률 + Momentum
    class Adam:
        def __init__(self, learning_rate=0.001, beta1=0.9, beta2=0.999):
            self.lr = learning_rate
            self.beta1 = beta1  # 1차 모멘트 감쇠율
            self.beta2 = beta2  # 2차 모멘트 감쇠율
            self.m = {}  # 1차 모멘트 (평균)
            self.v = {}  # 2차 모멘트 (분산)
            self.t = 0   # 타임스텝

        def update(self, params, grads):
            if not self.m:  # 첫 호출 시 초기화
                for key in params.keys():
                    self.m[key] = np.zeros_like(params[key])
                    self.v[key] = np.zeros_like(params[key])

            self.t += 1
            for key in params.keys():
                # 기울기의 1차/2차 모멘트 계산
                self.m[key] = self.beta1 * self.m[key] + (1 - self.beta1) * grads[key]
                self.v[key] = self.beta2 * self.v[key] + (1 - self.beta2) * (grads[key] ** 2)

                # 편향 보정
                m_hat = self.m[key] / (1 - self.beta1 ** self.t)
                v_hat = self.v[key] / (1 - self.beta2 ** self.t)

                # 파라미터 업데이트
                params[key] -= self.lr * m_hat / (np.sqrt(v_hat) + 1e-8)

설명

이것이 하는 일: 경사하강법은 마치 눈을 감고 계곡에서 가장 낮은 지점을 찾아가는 것과 같습니다. 발밑의 경사만 느끼면서 가장 가파르게 내려가는 방향으로 계속 걸어가면 결국 골짜기 바닥에 도착합니다.

첫 번째로, 기본 SGD는 가장 단순한 형태로 params -= lr * grads 공식을 사용합니다. 학습률(lr)이 크면 빠르게 이동하지만 최적점을 지나칠 수 있고, 작으면 안정적이지만 느립니다.

이것은 마치 큰 걸음으로 걸으면 빠르지만 목표 지점을 지나칠 수 있고, 작은 걸음으로 걸으면 정확하지만 시간이 오래 걸리는 것과 같습니다. 그 다음으로, Momentum은 "관성"의 개념을 추가합니다.

이전에 이동하던 방향을 기억하여(self.v) 같은 방향으로 계속 가면 가속하고, 방향이 바뀌면 감속합니다. 이것은 공이 언덕을 굴러 내려갈 때 작은 웅덩이는 관성으로 넘어가서 더 깊은 곳까지 도달하는 것과 같습니다.

momentum=0.9는 이전 속도의 90%를 유지한다는 의미입니다. Adam 옵티마이저는 현재 가장 널리 사용되는 알고리즘입니다.

첫째, 기울기의 1차 모멘트(평균)를 계산하여 Momentum 효과를 얻고, 둘째, 2차 모멘트(분산)를 계산하여 각 파라미터마다 적응적으로 학습률을 조정합니다. 예를 들어 기울기가 항상 큰 파라미터는 학습률을 줄이고, 기울기가 작은 파라미터는 학습률을 늘립니다.

편향 보정(m_hat, v_hat)은 초기 몇 스텝에서 모멘트 추정값이 0에 편향되는 것을 방지합니다. 분모에 1e-8을 더하는 이유는 0으로 나누는 것을 방지하기 위함입니다.

마지막으로, 실전에서는 대부분 Adam을 기본값으로 사용하고, 필요에 따라 학습률만 조정합니다. Adam은 학습률에 덜 민감하고 대부분의 문제에서 잘 작동하기 때문입니다.

여러분이 이 코드를 사용하면 신경망 학습을 크게 가속화하고 안정화할 수 있습니다. 실무에서의 이점은 첫째, Adam은 별다른 튜닝 없이 좋은 성능을 보이고, 둘째, Momentum은 SGD보다 빠르게 수렴하며, 셋째, 문제에 따라 최적화 알고리즘을 쉽게 교체할 수 있다는 점입니다.

실전 팁

💡 처음에는 Adam을 사용하세요. 대부분의 경우 좋은 결과를 보이며, learning_rate=0.001이 좋은 시작점입니다. 학습이 안정적이지 않다면 0.0001로 줄여보세요.

💡 학습 곡선을 그려서 최적화가 잘 되고 있는지 확인하세요. 손실이 진동하면 학습률이 너무 크고, 너무 천천히 줄어들면 학습률이 너무 작습니다.

💡 학습률 스케줄링을 사용하세요. 처음에는 큰 학습률로 빠르게 탐색하고, 나중에는 작은 학습률로 세밀하게 조정하면 더 좋은 결과를 얻을 수 있습니다.

💡 SGD with Momentum은 Adam보다 일반화 성능이 좋을 수 있습니다. 특히 컴퓨터 비전 분야에서는 잘 튜닝된 SGD가 Adam보다 나은 경우도 있습니다.

💡 배치 크기도 중요합니다. 작은 배치(32128)는 노이즈가 많아 일반화에 좋고, 큰 배치(2561024)는 안정적이지만 일반화가 떨어질 수 있습니다.


7. 미니배치 학습 - 효율적인 데이터 처리 전략

시작하며

여러분이 대용량 데이터셋으로 신경망을 학습시킬 때 이런 문제를 겪어본 적 있나요? "전체 데이터를 한 번에 메모리에 올릴 수 없어서 학습이 안 돼요." 이런 문제는 실무에서 매우 흔합니다.

예를 들어 100만 장의 이미지로 학습한다면 모든 데이터를 동시에 메모리에 올리는 것은 불가능하고, 설령 가능하다 해도 계산 시간이 너무 오래 걸립니다. 바로 이럴 때 필요한 것이 미니배치 학습입니다.

미니배치 학습은 전체 데이터를 작은 묶음(배치)으로 나누어 순차적으로 학습하여, 메모리 효율성과 학습 속도를 모두 개선하는 방법입니다.

개요

간단히 말해서, 미니배치 학습은 전체 데이터를 32개, 64개, 128개 같은 작은 단위로 나누어 한 번에 하나의 배치만 처리하는 방식입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 첫째 메모리 효율성(전체 데이터가 아닌 일부만 로드), 둘째 학습 속도(전체 데이터 계산보다 훨씬 빠름), 셋째 일반화 성능(배치마다 약간의 노이즈가 과적합 방지) 때문입니다.

예를 들어, 이미지넷 데이터셋(120만 장)을 배치 크기 256으로 학습하면 한 에폭에 약 4700번의 업데이트가 이루어집니다. 전통적인 배치 경사하강법(전체 데이터 사용)과 확률적 경사하강법(샘플 1개씩 사용)의 중간 지점이 미니배치 방식입니다.

배치 방식은 너무 느리고, 확률적 방식은 너무 불안정한 반면, 미니배치는 둘의 장점을 결합했습니다. 미니배치 학습의 핵심 특징은 첫째, 배치 크기가 성능과 속도의 트레이드오프를 결정하고, 둘째, GPU의 병렬 처리를 효과적으로 활용하며, 셋째, 한 에폭(전체 데이터 1회 순회)이 여러 번의 가중치 업데이트로 구성된다는 점입니다.

이러한 특징들이 중요한 이유는 현대 딥러닝의 거의 모든 학습이 미니배치 방식을 사용하며, 배치 크기 선택이 학습 성능에 큰 영향을 미치기 때문입니다.

코드 예제

import numpy as np

def create_mini_batches(X, y, batch_size):
    """
    데이터를 미니배치로 나누는 함수
    X: 입력 데이터 (샘플 수, 특징 수)
    y: 레이블 (샘플 수, 출력 차원)
    batch_size: 배치 크기
    """
    m = X.shape[0]  # 전체 샘플 수
    mini_batches = []

    # 데이터 셔플 (에폭마다 다른 순서로 학습)
    indices = np.random.permutation(m)
    X_shuffled = X[indices]
    y_shuffled = y[indices]

    # 완전한 배치들 생성
    num_complete_batches = m // batch_size
    for i in range(num_complete_batches):
        X_batch = X_shuffled[i * batch_size:(i + 1) * batch_size]
        y_batch = y_shuffled[i * batch_size:(i + 1) * batch_size]
        mini_batches.append((X_batch, y_batch))

    # 마지막 남은 데이터 처리 (배치 크기보다 작을 수 있음)
    if m % batch_size != 0:
        X_batch = X_shuffled[num_complete_batches * batch_size:]
        y_batch = y_shuffled[num_complete_batches * batch_size:]
        mini_batches.append((X_batch, y_batch))

    return mini_batches

# 사용 예제
def train_with_mini_batches(model, X_train, y_train, epochs=10, batch_size=32):
    for epoch in range(epochs):
        # 각 에폭마다 새로운 셔플로 배치 생성
        mini_batches = create_mini_batches(X_train, y_train, batch_size)
        epoch_loss = 0

        # 각 배치로 학습
        for X_batch, y_batch in mini_batches:
            # 순전파
            predictions = model.forward(X_batch)
            # 손실 계산
            loss = np.mean((predictions - y_batch) ** 2)
            epoch_loss += loss
            # 역전파 및 가중치 업데이트
            model.backward(X_batch, y_batch)

        # 에폭 평균 손실 출력
        print(f"Epoch {epoch + 1}/{epochs}, Loss: {epoch_loss / len(mini_batches):.4f}")

설명

이것이 하는 일: 미니배치 학습은 마치 큰 책을 읽을 때 한 번에 다 읽지 않고 한 장씩 읽어나가는 것과 같습니다. 전체를 한 번에 처리하려면 너무 부담이 크지만, 작은 단위로 나누면 관리 가능합니다.

첫 번째로, create_mini_batches 함수는 전체 데이터를 지정된 크기의 배치들로 나눕니다. np.random.permutation으로 인덱스를 섞는 이유는 매우 중요한데, 데이터가 항상 같은 순서로 들어오면 모델이 순서를 외워버려 일반화 성능이 떨어지기 때문입니다.

예를 들어 고양이 사진이 앞쪽에, 강아지 사진이 뒤쪽에 몰려있다면 셔플 없이는 제대로 학습되지 않습니다. 그 다음으로, 완전한 배치들을 먼저 생성합니다.

예를 들어 1000개 샘플을 배치 크기 32로 나누면 31개의 완전한 배치(992개 샘플)가 만들어집니다. 슬라이싱(X_shuffled[i*batch_size:(i+1)*batch_size])을 사용하여 각 배치를 추출합니다.

마지막 남은 샘플들(위 예시에서 8개)은 별도로 처리합니다. 이것들을 버리면 데이터 낭비이므로, 작은 크기의 마지막 배치로 만듭니다.

일부 구현에서는 이 마지막 배치를 제외하기도 하지만, 보통은 포함하는 것이 더 좋습니다. 학습 루프에서는 각 에폭마다 새롭게 셔플된 배치를 생성합니다.

이렇게 하면 매 에폭마다 다른 순서와 조합으로 학습하여 모델이 데이터의 순서나 특정 조합에 과적합되는 것을 방지합니다. 각 배치에 대해 순전파 → 손실 계산 → 역전파 → 가중치 업데이트를 수행하므로, 한 에폭에 여러 번 가중치가 업데이트됩니다.

여러분이 이 코드를 사용하면 큰 데이터셋도 제한된 메모리에서 효율적으로 학습할 수 있습니다. 실무에서의 이점은 첫째, GPU 메모리가 부족해도 배치 크기를 줄여서 학습 가능하고, 둘째, 배치 크기 조정으로 학습 속도와 성능을 튜닝할 수 있으며, 셋째, 모든 딥러닝 프레임워크가 이 패턴을 따르므로 개념을 이해하면 어떤 도구든 쉽게 사용할 수 있습니다.

실전 팁

💡 배치 크기는 2의 거듭제곱(32, 64, 128, 256)을 사용하세요. GPU 메모리가 이런 크기에 최적화되어 있어 계산이 빠릅니다.

💡 배치 크기가 클수록 안정적이지만 일반화 성능이 떨어질 수 있습니다. 작은 배치는 노이즈가 많아 불안정하지만 일반화에 유리합니다. 보통 32~128이 좋은 시작점입니다.

💡 학습 시작 전에 한 번 배치를 출력해서 모양을 확인하세요. 차원이 예상과 다르면 나중에 디버깅하기 어려우므로 미리 체크하는 것이 중요합니다.

💡 데이터 로더를 사용할 때는 num_workers를 설정하여 병렬로 배치를 준비하세요. PyTorch의 DataLoader는 이 기능을 제공하여 GPU가 대기하는 시간을 줄입니다.

💡 배치 정규화(Batch Normalization)를 사용한다면 배치 크기가 너무 작으면(예: 4 이하) 통계가 불안정해집니다. 최소 16 이상을 유지하세요.


8. 과적합과 정규화 - 일반화 성능 향상 기법

시작하며

여러분이 신경망을 학습시킬 때 이런 당황스러운 상황을 겪어본 적 있나요? "학습 데이터에서는 정확도가 99%인데 새로운 데이터에서는 60%밖에 안 나와요." 이런 문제는 과적합(Overfitting)이라고 하며, 실무에서 가장 흔하게 마주치는 도전 과제입니다.

모델이 학습 데이터를 너무 잘 외워버려서 실제 세상의 새로운 데이터에는 제대로 작동하지 않는 것입니다. 바로 이럴 때 필요한 것이 정규화(Regularization) 기법입니다.

정규화는 모델이 학습 데이터의 노이즈까지 외우지 않도록 제약을 가하여, 일반화 성능을 향상시키는 다양한 기법들을 말합니다.

개요

간단히 말해서, 과적합은 모델이 학습 데이터에만 특화되어 새로운 데이터를 잘 처리하지 못하는 현상이고, 정규화는 이를 방지하는 기법들입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 모델의 최종 목표는 학습 데이터가 아닌 실제 세상의 새로운 데이터에서 잘 작동하는 것이기 때문입니다.

예를 들어, 의료 진단 모델이 학습 데이터의 환자는 100% 맞추지만 새로운 환자는 제대로 진단하지 못한다면 쓸모가 없습니다. 전통적인 머신러닝에서는 L1/L2 정규화를 주로 사용했다면, 딥러닝에서는 Dropout, Batch Normalization, Data Augmentation, Early Stopping 등 훨씬 다양한 기법이 사용됩니다.

정규화의 핵심 특징은 첫째, 모델의 복잡도를 제한하여 일반화를 돕고, 둘째, 학습과 테스트 시 동작이 다를 수 있으며(예: Dropout), 셋째, 여러 정규화 기법을 함께 사용할 수 있다는 점입니다. 이러한 특징들이 중요한 이유는 적절한 정규화 없이는 깊은 신경망이 쉽게 과적합되어 실용성이 떨어지기 때문입니다.

코드 예제

import numpy as np

class RegularizationTechniques:
    # L2 정규화 (Weight Decay)
    @staticmethod
    def l2_regularization(weights, lambda_reg=0.01):
        """
        L2 정규화: 가중치의 제곱합에 페널티
        큰 가중치를 억제하여 모델을 단순하게 유지
        """
        return lambda_reg * np.sum(weights ** 2)

    # Dropout
    @staticmethod
    def dropout(X, dropout_rate=0.5, training=True):
        """
        Dropout: 학습 시 일부 뉴런을 랜덤하게 제거
        X: 입력 활성화
        dropout_rate: 제거할 뉴런 비율 (0.5 = 50%)
        training: 학습 모드인지 여부
        """
        if not training:
            return X  # 테스트 시에는 모든 뉴런 사용

        # 유지할 뉴런 마스크 생성 (1=유지, 0=제거)
        keep_prob = 1 - dropout_rate
        mask = np.random.binomial(1, keep_prob, size=X.shape)

        # 마스크 적용 및 스케일 조정
        return (X * mask) / keep_prob

    # Early Stopping 체커
    class EarlyStopping:
        def __init__(self, patience=5, min_delta=0.001):
            """
            patience: 개선이 없어도 기다릴 에폭 수
            min_delta: 개선으로 인정할 최소 변화량
            """
            self.patience = patience
            self.min_delta = min_delta
            self.best_loss = float('inf')
            self.counter = 0
            self.best_weights = None

        def check(self, val_loss, model_weights):
            """
            검증 손실을 확인하고 조기 종료 여부 반환
            """
            if val_loss < self.best_loss - self.min_delta:
                # 개선됨: 카운터 리셋
                self.best_loss = val_loss
                self.best_weights = model_weights.copy()
                self.counter = 0
                return False
            else:
                # 개선 없음: 카운터 증가
                self.counter += 1
                if self.counter >= self.patience:
                    print(f"Early stopping: {self.patience} epochs without improvement")
                    return True
            return False

설명

이것이 하는 일: 정규화는 마치 학생이 시험 문제를 통째로 외우지 말고 원리를 이해하도록 유도하는 것과 같습니다. 과거 시험 문제를 100% 맞추는 것보다 새로운 문제도 풀 수 있는 능력이 더 중요합니다.

첫 번째로, L2 정규화는 가중치가 너무 커지는 것을 방지합니다. 손실 함수에 가중치 제곱합의 페널티를 추가하여, 큰 가중치를 가진 모델은 손실이 증가하도록 만듭니다.

이것은 모델이 특정 몇 개의 특징에만 과도하게 의존하지 않고 많은 특징을 골고루 사용하도록 유도합니다. lambda_reg는 정규화 강도를 조절하는데, 너무 크면 모델이 과소적합되고 너무 작으면 정규화 효과가 없습니다.

그 다음으로, Dropout은 매우 강력한 정규화 기법입니다. 학습 시 각 뉴런을 일정 확률(보통 50%)로 랜덤하게 제거하여, 모델이 특정 뉴런에 과도하게 의존하지 못하게 합니다.

이것은 마치 팀 프로젝트에서 매번 다른 멤버가 빠지는 상황을 연습하는 것과 같아서, 모든 멤버가 중요한 역할을 분담하게 됩니다. /keep_prob로 스케일을 조정하는 이유는 테스트 시 모든 뉴런을 사용할 때와 출력 크기를 맞추기 위함입니다.

Early Stopping은 검증 데이터의 손실을 모니터링하여 개선이 멈추면 학습을 조기 종료합니다. 예를 들어 검증 손실이 5 에폭 동안 개선되지 않으면 학습을 멈추고 가장 좋았던 가중치를 복원합니다.

이것은 과적합이 시작되기 직전에 학습을 멈추는 효과가 있습니다. patience 파라미터는 "몇 번의 기회를 줄 것인가"를 의미합니다.

때로는 일시적으로 검증 손실이 증가했다가 다시 개선될 수 있으므로, 너무 조급하게 멈추지 않도록 여유를 줍니다. min_delta는 아주 작은 개선은 무시하도록 하는 임계값입니다.

여러분이 이 코드를 사용하면 모델의 일반화 성능을 크게 향상시킬 수 있습니다. 실무에서의 이점은 첫째, Dropout은 거의 모든 신경망에서 효과적이고, 둘째, Early Stopping은 과적합을 자동으로 방지하며, 셋째, L2 정규화는 가중치 크기를 제한하여 안정성을 높입니다.

실전 팁

💡 Dropout은 주로 완전연결층(Fully Connected)에 사용하며, 비율은 0.5가 일반적입니다. 합성곱층(Conv)에는 더 작은 비율(0.2~0.3)을 사용하세요.

💡 테스트/예측 시에는 반드시 Dropout을 끄세요. PyTorch의 model.eval()이나 TensorFlow의 training=False가 이것을 자동으로 처리합니다.

💡 Early Stopping을 사용할 때는 검증 세트를 별도로 준비하세요. 학습 데이터로 검증하면 의미가 없습니다. 보통 전체 데이터의 10~20%를 검증용으로 씁니다.

💡 여러 정규화를 함께 사용하세요. Dropout + L2 + Batch Normalization + Early Stopping을 조합하면 매우 강력합니다. 단, 너무 많으면 과소적합될 수 있으니 실험이 필요합니다.

💡 Data Augmentation(데이터 증강)도 강력한 정규화입니다. 이미지 분류라면 회전, 크롭, 색상 조정 등으로 데이터를 인위적으로 늘리면 과적합을 크게 줄일 수 있습니다.


9. 전체 학습 루프 - 모든 것을 하나로 합치기

시작하며

여러분이 지금까지 배운 모든 개념들을 보면서 이런 궁금증을 가질 수 있습니다. "순전파, 역전파, 최적화, 정규화...

이걸 어떻게 하나로 합치지?" 이런 의문은 매우 자연스럽습니다. 개별 개념들을 이해하는 것과 실제로 작동하는 학습 시스템을 만드는 것은 다른 문제입니다.

각 부분이 어떤 순서로 실행되고 어떻게 상호작용하는지 알아야 합니다. 바로 이럴 때 필요한 것이 완전한 학습 루프의 이해입니다.

학습 루프는 데이터 로딩부터 모델 평가까지 모든 단계를 체계적으로 조직하는 전체 프로세스입니다.

개요

간단히 말해서, 학습 루프는 "데이터 준비 → 여러 에폭 반복 → 각 에폭에서 미니배치로 학습 → 검증 → 모델 저장"의 전체 흐름을 구조화한 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 실제 프로젝트에서는 수십 시간씩 학습을 돌려야 하므로 견고하고 체계적인 학습 파이프라인이 필수적이기 때문입니다.

예를 들어, 학습 중간에 체크포인트를 저장하지 않았다가 서버가 다운되면 며칠치 작업이 날아갈 수 있습니다. 전통적인 머신러닝에서는 fit() 한 번 호출로 끝났다면, 딥러닝에서는 에폭, 배치, 검증, 체크포인트, 로깅 등을 명시적으로 관리해야 합니다.

학습 루프의 핵심 특징은 첫째, 명확한 단계 구분(학습/검증 분리), 둘째, 진행 상황 모니터링(손실, 정확도 추적), 셋째, 체크포인트와 복원 메커니즘이 있다는 점입니다. 이러한 특징들이 중요한 이유는 딥러닝 학습은 오래 걸리고 많은 자원을 소모하므로 신뢰할 수 있는 관리 시스템이 필요하기 때문입니다.

코드 예제

import numpy as np

class CompleteTrainingPipeline:
    def __init__(self, model, optimizer, loss_fn):
        self.model = model
        self.optimizer = optimizer
        self.loss_fn = loss_fn
        self.train_losses = []
        self.val_losses = []

    def train_epoch(self, X_train, y_train, batch_size=32):
        """한 에폭 학습"""
        # 미니배치 생성
        mini_batches = create_mini_batches(X_train, y_train, batch_size)
        epoch_loss = 0

        for X_batch, y_batch in mini_batches:
            # 1. 순전파
            predictions = self.model.forward(X_batch)

            # 2. 손실 계산
            loss = self.loss_fn(y_batch, predictions)
            epoch_loss += loss

            # 3. 역전파
            self.model.backward(X_batch, y_batch)

            # 4. 가중치 업데이트
            self.optimizer.update(self.model.params, self.model.grads)

        return epoch_loss / len(mini_batches)

    def validate(self, X_val, y_val):
        """검증 세트 평가"""
        predictions = self.model.forward(X_val)
        val_loss = self.loss_fn(y_val, predictions)

        # 정확도 계산 (분류 문제의 경우)
        predicted_classes = (predictions > 0.5).astype(int)
        accuracy = np.mean(predicted_classes == y_val)

        return val_loss, accuracy

    def fit(self, X_train, y_train, X_val, y_val,
            epochs=100, batch_size=32, patience=10):
        """
        완전한 학습 루프
        """
        early_stopping = EarlyStopping(patience=patience)

        for epoch in range(epochs):
            # 학습 단계
            train_loss = self.train_epoch(X_train, y_train, batch_size)
            self.train_losses.append(train_loss)

            # 검증 단계
            val_loss, val_acc = self.validate(X_val, y_val)
            self.val_losses.append(val_loss)

            # 진행 상황 출력
            print(f"Epoch {epoch + 1}/{epochs}")
            print(f"  Train Loss: {train_loss:.4f}")
            print(f"  Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.4f}")

            # Early Stopping 체크
            if early_stopping.check(val_loss, self.model.params):
                print("Training stopped early")
                self.model.params = early_stopping.best_weights
                break

        return self.train_losses, self.val_losses

# 사용 예제
def create_mini_batches(X, y, batch_size):
    # (이전 섹션의 구현 사용)
    pass

class EarlyStopping:
    # (이전 섹션의 구현 사용)
    pass

설명

이것이 하는 일: 학습 루프는 마치 공장의 생산 라인처럼 각 단계가 체계적으로 실행되는 프로세스입니다. 원자재(데이터)가 들어와서 여러 공정(에폭)을 거쳐 완제품(학습된 모델)이 나옵니다.

첫 번째로, train_epoch 메서드는 한 에폭의 학습을 담당합니다. 전체 학습 데이터를 미니배치로 나눈 후, 각 배치에 대해 순전파 → 손실 계산 → 역전파 → 가중치 업데이트를 수행합니다.

모든 배치의 평균 손실을 반환하여 에폭 단위로 학습 진행을 추적할 수 있습니다. 이것은 "하루 동안의 작업"에 해당합니다.

그 다음으로, validate 메서드는 학습 중간에 모델 성능을 확인합니다. 중요한 점은 검증 시에는 역전파나 가중치 업데이트를 하지 않고 순전파만 수행한다는 것입니다.

검증 데이터는 모델이 한 번도 보지 못한 데이터여야 하며, 이를 통해 실제 성능을 추정합니다. 정확도도 함께 계산하여 손실 외에 직관적인 지표도 제공합니다.

fit 메서드는 전체 학습 프로세스를 조율하는 마스터 함수입니다. 각 에폭마다 학습을 수행하고 검증하며, 손실을 기록하고, 진행 상황을 출력합니다.

학습 손실과 검증 손실을 모두 추적하는 이유는 과적합을 감지하기 위함입니다. 학습 손실은 계속 감소하는데 검증 손실이 증가하기 시작하면 과적합의 신호입니다.

Early Stopping을 통합하여 검증 손실이 개선되지 않으면 자동으로 학습을 멈춥니다. 이것은 계산 자원을 절약하고 과적합을 방지하는 효과적인 방법입니다.

가장 좋은 가중치를 복원하여 최종 모델은 검증 손실이 가장 낮았던 시점의 상태가 됩니다. 진행 상황 출력은 매우 중요합니다.

학습이 수 시간 또는 수 일 걸릴 수 있으므로, 중간 결과를 보면서 문제가 있는지(손실이 증가하거나 NaN이 되는 등) 확인해야 합니다. 여러분이 이 코드를 사용하면 견고하고 재현 가능한 학습 파이프라인을 구축할 수 있습니다.

실무에서의 이점은 첫째, 체계적인 구조로 디버깅이 쉽고, 둘째, 학습 과정을 완전히 제어할 수 있으며, 셋째, 이 패턴은 PyTorch나 TensorFlow의 학습 루프와 개념이 같아서 쉽게 전환할 수 있습니다.

실전 팁

💡 학습 손실과 검증 손실을 그래프로 그려보세요. 두 곡선이 모두 감소하면 정상, 검증 손실만 증가하면 과적합, 둘 다 평평하면 학습률을 올려야 합니다.

💡 체크포인트를 주기적으로 저장하세요. 매 에폭마다 또는 검증 손실이 개선될 때마다 모델을 저장하면 학습이 중단되어도 복구할 수 있습니다.

💡 텐서보드(TensorBoard)나 wandb 같은 도구로 학습을 시각화하세요. 손실, 정확도, 학습률 등을 실시간으로 모니터링하면 문제를 빨리 발견할 수 있습니다.

💡 학습률 스케줄러를 추가하세요. 에폭이 진행될수록 학습률을 줄이면(예: 10 에폭마다 0.1배) 더 세밀한 최적화가 가능합니다.

💡 실험 결과를 기록하세요. 하이퍼파라미터(학습률, 배치 크기, 정규화 강도 등)와 결과(최종 손실, 정확도)를 표로 만들어 어떤 설정이 좋은지 비교하세요.


10. 실전 예제 - XOR 문제 풀기

시작하며

여러분이 지금까지 배운 모든 것을 적용해볼 준비가 되셨나요? "이론은 알겠는데 실제로 어떻게 써먹지?"라는 궁금증이 있을 것입니다.

이런 궁금증을 해결하기 위해 가장 좋은 방법은 간단하면서도 의미 있는 문제를 직접 풀어보는 것입니다. XOR 문제는 단순한 퍼셉트론으로는 풀 수 없어서 신경망이 필요한 고전적인 문제입니다.

바로 이럴 때 완전한 예제가 필요합니다. 데이터 준비부터 모델 정의, 학습, 평가까지 전체 과정을 따라하면서 실제로 작동하는 신경망을 만들어봅시다.

개요

간단히 말해서, XOR 문제는 두 입력이 서로 다를 때만 1을 출력하는 논리 연산입니다. (0,0)→0, (0,1)→1, (1,0)→1, (1,1)→0.

왜 이 문제가 중요한지 실무 관점에서 설명하면, XOR은 선형으로 분리할 수 없는 가장 단순한 예제여서 신경망의 필요성을 보여주기 때문입니다. 1950년대 단일 퍼셉트론이 XOR을 풀지 못한다는 사실이 밝혀지면서 AI 겨울이 왔고, 1980년대 다층 퍼셉트론과 역전파로 이 문제를 해결하면서 신경망이 부활했습니다.

전통적인 프로그래밍에서는 if-else로 간단히 구현할 수 있지만, 신경망 관점에서는 "데이터만 보고 이 패턴을 학습할 수 있는가"가 중요합니다. 이 예제의 핵심 특징은 첫째, 코드가 짧아서 전체를 한눈에 볼 수 있고, 둘째, 모든 개념(순전파, 역전파, 활성화 함수, 학습 루프)이 들어있으며, 셋째, 실제로 실행하면 학습이 진행되는 것을 볼 수 있다는 점입니다.

이러한 특징들이 중요한 이유는 완전히 이해한 후 더 복잡한 문제로 확장할 수 있는 기반이 되기 때문입니다.

코드 예제

import numpy as np

# XOR 데이터셋
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y = np.array([[0], [1], [1], [0]])

# 신경망 정의
class XORNetwork:
    def __init__(self):
        # 2개 입력 → 4개 은닉 뉴런 → 1개 출력
        self.W1 = np.random.randn(2, 4) * 0.5
        self.b1 = np.zeros((1, 4))
        self.W2 = np.random.randn(4, 1) * 0.5
        self.b2 = np.zeros((1, 1))
        self.lr = 0.5

    def sigmoid(self, x):
        return 1 / (1 + np.exp(-np.clip(x, -500, 500)))

    def forward(self, X):
        self.z1 = np.dot(X, self.W1) + self.b1
        self.a1 = np.tanh(self.z1)  # Tanh 활성화
        self.z2 = np.dot(self.a1, self.W2) + self.b2
        self.a2 = self.sigmoid(self.z2)  # Sigmoid 출력
        return self.a2

    def backward(self, X, y):
        m = X.shape[0]

        # 출력층 기울기
        dz2 = self.a2 - y
        dW2 = np.dot(self.a1.T, dz2) / m
        db2 = np.sum(dz2, axis=0, keepdims=True) / m

        # 은닉층 기울기
        dz1 = np.dot(dz2, self.W2.T) * (1 - self.a1 ** 2)  # Tanh 미분
        dW1 = np.dot(X.T, dz1) / m
        db1 = np.sum(dz1, axis=0, keepdims=True) / m

        # 가중치 업데이트
        self.W2 -= self.lr * dW2
        self.b2 -= self.lr * db2
        self.W1 -= self.lr * dW1
        self.b1 -= self.lr * db1

    def train(self, X, y, epochs=10000):
        for epoch in range(epochs):
            # 순전파
            output = self.forward(X)

            # 손실 계산 (Binary Cross-Entropy)
            loss = -np.mean(y * np.log(output + 1e-8) +
                           (1 - y) * np.log(1 - output + 1e-8))

            # 역전파
            self.backward(X, y)

            # 1000 에폭마다 출력
            if epoch % 1000 == 0:
                print(f"Epoch {epoch}, Loss: {loss:.4f}")

        print("\n최종 예측:")
        for i in range(len(X)):
            pred = self.forward(X[i:i+1])[0, 0]
            print(f"Input: {X[i]} → Prediction: {pred:.4f} (Target: {y[i, 0]})")

# 학습 실행
model = XORNetwork()
model.train(X, y)

설명

이것이 하는 일: 이 코드는 완전한 신경망을 처음부터 만들어 XOR 문제를 학습합니다. 마치 아기가 예제를 보면서 패턴을 배우듯이, 신경망도 4개의 샘플을 반복해서 보면서 XOR 규칙을 학습합니다.

첫 번째로, 데이터 준비 부분에서 X는 가능한 모든 입력 조합이고 y는 정답입니다. XOR의 특징은 (0,0)과 (1,1)은 같은 클래스(0), (0,1)과 (1,0)은 다른 클래스(1)인데, 이것은 직선으로 나눌 수 없습니다.

종이에 점을 찍어보면 대각선으로 나뉘어져 있어서 단일 직선으로는 분리가 불가능합니다. 네트워크 구조는 2개 입력 → 4개 은닉 뉴런 → 1개 출력으로 매우 작습니다.

은닉층이 4개인 이유는 XOR 같은 단순한 문제는 적은 뉴런으로도 충분하기 때문입니다. 실험적으로 2개만 있어도 되지만 4개를 쓰면 더 안정적으로 학습됩니다.

은닉층에는 Tanh를, 출력층에는 Sigmoid를 사용하는데, Tanh는 -11 범위로 중심이 0이라 학습이 빠르고, Sigmoid는 01 범위로 확률을 나타내기 좋습니다. 순전파는 우리가 배운 대로 선형 변환(np.dot) → 활성화 함수를 두 번 반복합니다.

np.clip으로 값을 제한하는 이유는 매우 큰 음수/양수가 들어가면 exp() 함수가 오버플로우/언더플로우를 일으킬 수 있기 때문입니다. 역전파에서는 연쇄 법칙으로 기울기를 계산합니다.

Tanh의 미분이 1 - tanh^2(x)라서 1 - self.a1**2로 표현됩니다. 각 층의 기울기를 계산한 후 학습률 0.5를 곱해 가중치를 업데이트합니다.

학습률이 비교적 큰 이유는 데이터가 매우 단순하고 적어서 빠르게 수렴할 수 있기 때문입니다. 학습 루프에서는 10,000 에폭 동안 같은 4개 샘플을 반복해서 학습합니다.

이것이 가능한 이유는 XOR 문제가 매우 단순하기 때문입니다. 1000 에폭마다 손실을 출력하면 손실이 점점 감소하는 것을 볼 수 있고, 최종적으로 각 입력에 대한 예측을 출력하면 거의 정확하게 맞추는 것을 확인할 수 있습니다.

여러분이 이 코드를 실행하면 신경망이 실제로 학습하는 과정을 직접 볼 수 있습니다. 실무에서의 이점은 첫째, 전체 파이프라인을 이해했으므로 더 복잡한 문제로 확장 가능하고, 둘째, 각 부분을 수정해가며 실험할 수 있으며, 셋째, 디버깅 방법을 배울 수 있다는 점입니다.

실전 팁

💡 은닉 뉴런 개수를 바꿔보세요. 2개로 줄이면 어떻게 되는지, 10개로 늘리면 어떻게 되는지 실험하면 모델 용량(capacity)의 개념을 이해할 수 있습니다.

💡 학습률을 조정해보세요. 0.1로 줄이면 학습이 느려지고, 2.0으로 늘리면 불안정해집니다. 적절한 학습률 찾기가 얼마나 중요한지 체감할 수 있습니다.

💡 중간 활성화값을 출력해보세요. self.a1을 프린트하면 은닉층이 무엇을 학습했는지 볼 수 있습니다. 각 뉴런이 서로 다른 특징을 포착하는 것을 관찰하세요.

💡 이 코드를 AND, OR, NAND 문제에도 적용해보세요. AND와 OR은 단일 퍼셉트론으로도 풀리지만 신경망으로도 잘 학습됩니다.

💡 가중치 초기화를 다르게 해보세요. 모두 0으로 초기화하면 학습이 안 되고, 너무 큰 값으로 초기화하면 기울기 폭발이 일어납니다. 적절한 초기화의 중요성을 확인하세요.


#Python#NeuralNetwork#Perceptron#Backpropagation#DeepLearning#Data Science

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

데이터 증강과 정규화 완벽 가이드

머신러닝 모델의 성능을 극대화하는 핵심 기법인 데이터 증강과 정규화에 대해 알아봅니다. 실무에서 바로 활용할 수 있는 다양한 기법과 실전 예제를 통해 과적합을 방지하고 모델 성능을 향상시키는 방법을 배웁니다.

ResNet과 Skip Connection 완벽 가이드

딥러닝 모델이 깊어질수록 성능이 떨어지는 문제를 해결한 혁신적인 기법, ResNet과 Skip Connection을 초급자도 이해할 수 있도록 쉽게 설명합니다. 실제 구현 코드와 함께 배워보세요.

CNN 아키텍처 완벽 가이드 LeNet AlexNet VGGNet

컴퓨터 비전의 기초가 되는 세 가지 핵심 CNN 아키텍처를 배웁니다. 손글씨 인식부터 이미지 분류까지, 딥러닝의 발전 과정을 따라가며 각 모델의 구조와 특징을 실습 코드와 함께 이해합니다.

CNN 기초 Convolution과 Pooling 완벽 가이드

CNN의 핵심인 Convolution과 Pooling을 초급자도 쉽게 이해할 수 있도록 설명합니다. 이미지 인식의 원리부터 실제 코드 구현까지, 실무에서 바로 활용 가능한 내용을 담았습니다.

TensorFlow와 Keras 완벽 입문 가이드

머신러닝과 딥러닝의 세계로 들어가는 첫걸음! TensorFlow와 Keras 프레임워크를 처음 접하는 분들을 위한 친절한 가이드입니다. 실무에서 바로 활용할 수 있는 핵심 개념과 예제를 통해 AI 모델 개발의 기초를 탄탄히 다져보세요.