머신러닝 & 딥러닝/딥러닝

[AI 기초 다지기] 스탠포드 대학 딥러닝 기초(3) - Neural Networks

Haru_29 2024. 11. 7. 22:18

뉴럴 네트워크의 기초: 완벽 가이드

1. 뉴럴 네트워크란?

1.1 기본 개념

뉴럴 네트워크를 이해하기 위해 먼저 선형 분류기를 살펴보겠습니다. 기존의 선형 분류에서는 다음과 같은 공식을 사용했습니다:

s = Wx # 여기서 s는 점수, W는 가중치 행렬, x는 입력 벡터

예를 들어 CIFAR-10 데이터셋의 경우:

  • x: [3072x1] 크기의 이미지 픽셀 데이터 벡터
  • W: [10x3072] 크기의 가중치 행렬
  • s: 10개 클래스에 대한 점수 벡터

1.2 뉴럴 네트워크의 기본 수식

기본적인 뉴럴 네트워크는 다음과 같은 형태를 가집니다:

s = W2 * max(0, W1x)

여기서:

  • W1: [100x3072] 크기의 첫 번째 가중치 행렬
  • max(0,-): ReLU 활성화 함수
  • W2: [10x100] 크기의 두 번째 가중치 행렬

3층 신경망의 경우:

s = W3 * max(0, W2 * max(0, W1x))

2. 생물학적 뉴런과 인공 뉴런

2.1 생물학적 뉴런의 구조

인간의 신경계는 놀라운 복잡성을 가지고 있습니다:

  • 약 860억 개의 뉴런
  • 10^14 - 10^15개의 시냅스 연결
  • 구성 요소:
    • 수상돌기: 입력 신호 수신
    • 세포체: 신호 처리
    • 축삭: 출력 신호 전달
    • 시냅스: 뉴런 간 연결 지점

2.2 수학적 모델링

실제 뉴런을 단순화하여 다음과 같이 모델링합니다:

class Neuron(object):
    def __init__(self, weights, bias):
        self.weights = weights
        self.bias = bias
        
    def forward(self, inputs):
        # 1. 입력과 가중치의 곱의 합
        cell_body_sum = np.sum(inputs * self.weights) + self.bias
        # 2. 활성화 함수 적용
        firing_rate = 1.0 / (1.0 + math.exp(-cell_body_sum))
        return firing_rate

이 모델의 특징:

  • 입력 신호와 가중치의 곱
  • 바이어스 항 추가
  • 활성화 함수를 통한 출력 생성

3. 활성화 함수의 종류와 특징

3.1 시그모이드 (Sigmoid)

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

특징:

  • 출력 범위: [0, 1]
  • 장점:
    • 생물학적 뉴런의 발화율과 유사
    • 확률로 해석 가능
  • 단점:
    • 기울기 소실 문제
    • zero-centered가 아님
    • 실제 학습에서 성능 저하

3.2 하이퍼볼릭 탄젠트 (tanh)

def tanh(x):
    return (np.exp(x) - np.exp(-x)) / (np.exp(x) + np.exp(-x))

특징:

  • 출력 범위: [-1, 1]
  • 장점:
    • zero-centered 출력
    • 시그모이드보다 더 나은 학습 성능
  • 단점:
    • 여전히 기울기 소실 문제 존재

3.3 ReLU (Rectified Linear Unit)

def relu(x):
    return max(0, x)

특징:

  • 출력 범위: [0, ∞)
  • 장점:
    • 계산 효율성
    • 기울기 소실 문제 감소
    • 학습 속도 향상 (약 6배)
  • 단점:
    • Dead ReLU 문제
    • 학습률 설정 중요

3.4 Leaky ReLU

특징:

  • Dead ReLU 문제 해결
  • 음수 입력에 대해 작은 기울기 제공
  • alpha 값을 학습 가능하게 설정 가능 (PReLU)

4. 네트워크 아키텍처 설계

4.1 레이어 구성

기본 구성:

  1. 입력 레이어
  2. 은닉 레이어 (여러 개 가능)
  3. 출력 레이어

예시 코드:

class NeuralNetwork:
    def __init__(self, layer_sizes):
        self.weights = []
        self.biases = []
        
        for i in range(len(layer_sizes)-1):
            w = np.random.randn(layer_sizes[i+1], layer_sizes[i])
            b = np.random.randn(layer_sizes[i+1], 1)
            self.weights.append(w)
            self.biases.append(b)

4.2 순전파 연산

def forward(self, x):
    activation = x
    activations = [x]
    
    for w, b in zip(self.weights, self.biases):
        z = np.dot(w, activation) + b
        activation = relu(z)  # ReLU 활성화 함수 사용
        activations.append(activation)
    
    return activations

5. 네트워크 크기 결정

5.1 은닉층 크기 선정

고려사항:

  • 데이터 복잡도
  • 계산 자원
  • 과적합 위험

일반적 지침:

  • 입력층의 1.5~2배 크기로 시작
  • 성능에 따라 조정
  • 여러 개의 작은 층보다 하나의 큰 층이 더 효과적일 수 있음

5.2 과적합 제어

방법:

  1. L2 정규화
loss += lambda_ * np.sum(w * w for w in weights)

    2. 드롭아웃

def dropout(x, rate=0.5):
    mask = np.random.binomial(1, rate, size=x.shape)
    return x * mask
 
 
  1. 데이터 증강
  2. 조기 종료

6. 실전 최적화 팁

6.1 초기화

def he_initialization(layer_size):
    return np.random.randn(layer_size) * np.sqrt(2.0/layer_size)

6.2 학습률 설정

  • 시작값: 0.01
  • 학습 곡선 모니터링
  • 필요시 학습률 감소

6.3 미니배치 크기

  • 일반적으로 32~256
  • GPU 메모리 고려
  • 더 큰 배치가 항상 좋은 것은 아님

결론

뉴럴 네트워크의 성공적인 구현을 위한 핵심 포인트:

  1. ReLU 활성화 함수 사용
  2. 충분히 큰 네트워크 설계
  3. 적절한 정규화 적용
  4. 학습 과정 모니터링
  5. 하이퍼파라미터 최적화

실제 구현시 주의사항:

  • 데이터 전처리 중요성
  • 과적합 신중하게 모니터링
  • 계산 효율성 고려
  • 모델 평가 지표 선정

 

뉴럴 네트워크의 실전 구현 가이드: 데이터 전처리부터 모델 설정까지

1. 데이터 전처리 (Data Preprocessing)

1.1 일반적인 전처리 방법

데이터 행렬 X가 [N x D] 크기일 때 (N: 데이터 수, D: 차원) 세 가지 주요 전처리 방법이 있습니다:

1) 평균 제거 (Mean Subtraction)

# 각 특성별 평균 제거
X -= np.mean(X, axis=0)

# 이미지의 경우 전체 픽셀에서 단일 값 제거도 가능
X -= np.mean(X)

장점:

  • 가장 일반적인 전처리 방법
  • 데이터를 각 차원에서 원점 중심으로 이동
  • 기하학적 해석이 용이

2) 정규화 (Normalization)

# 표준편차로 나누기
X /= np.std(X, axis=0)

# 또는 min-max 스케일링 (-1에서 1 사이로)
X = (X - X.min()) / (X.max() - X.min()) * 2 - 1

적용 시기:

  • 입력 특성들의 스케일이 다를 때
  • 모든 특성이 동일한 중요도를 가져야 할 때

3) PCA와 백색화 (PCA and Whitening)

# 1. 데이터 중심화
X -= np.mean(X, axis=0)

# 2. 공분산 행렬 계산
cov = np.dot(X.T, X) / X.shape[0]

# 3. SVD 분해
U, S, V = np.linalg.svd(cov)

# 4. 데이터 회전
Xrot = np.dot(X, U)

# 5. 백색화
Xwhite = Xrot / np.sqrt(S + 1e-5)

특징:

  • 데이터의 상관관계 제거
  • 차원 축소 가능
  • 노이즈가 과도하게 강조될 수 있음

1.2 실전 팁

  1. 전처리 통계는 훈련 데이터에서만 계산
  2. 검증/테스트 데이터에는 훈련 데이터의 통계를 적용
  3. 합성곱 신경망(CNN)에서는 PCA/백색화 잘 사용하지 않음
  4. 데이터 중심화는 매우 중요

2. 가중치 초기화 (Weight Initialization)

2.1 잘못된 방법

W = 0  # 모든 가중치를 0으로 초기화 (사용하면 안 됨)

문제점:

  • 모든 뉴런이 동일한 출력 생성
  • 역전파 시 동일한 그래디언트 계산
  • 뉴런 간 비대칭성 없음

2.2 올바른 초기화 방법

1) 작은 랜덤 값 사용

W = 0.01 * np.random.randn(D, H)

2) Xavier 초기화

W = np.random.randn(n) / np.sqrt(n)

3) He 초기화 (ReLU용)

W = np.random.randn(n) * np.sqrt(2.0/n)

2.3 바이어스 초기화

b = 0  # 일반적으로 0으로 초기화
# 또는 ReLU의 경우
b = 0.01  # 작은 양수값

3. 정규화 (Regularization)

3.1 L2 정규화

loss = data_loss + 0.5 * lambda_reg * np.sum(W * W)

특징:

  • 가장 일반적인 정규화 방법
  • 가중치 벡터를 작고 분산된 값으로 유지

3.2 L1 정규화

loss = data_loss + lambda_reg * np.sum(np.abs(W))

특징:

  • 희소한 가중치 벡터 생성
  • 특성 선택에 유용

3.3 드롭아웃 (Dropout)

일반 드롭아웃

def train_step(X):
    H1 = np.maximum(0, np.dot(W1, X) + b1)
    U1 = np.random.rand(*H1.shape) < p
    H1 *= U1  # 드롭아웃 적용
    
def predict(X):
    H1 = np.maximum(0, np.dot(W1, X) + b1) * p

권장되는 Inverted Dropout

def train_step(X):
    H1 = np.maximum(0, np.dot(W1, X) + b1)
    U1 = (np.random.rand(*H1.shape) < p) / p
    H1 *= U1
    
def predict(X):
    H1 = np.maximum(0, np.dot(W1, X) + b1)  # 스케일링 필요 없음

4. 손실 함수 (Loss Functions)

4.1 분류 문제

# SVM 손실
loss = np.sum(np.maximum(0, scores - scores[y] + 1))

# Softmax 손실
softmax = np.exp(scores) / np.sum(np.exp(scores))
loss = -np.log(softmax[y])

4.2 회귀 문제

# L2 손실
loss = np.sum((predictions - targets) ** 2)

# L1 손실
loss = np.sum(np.abs(predictions - targets))

실전 추천 사항:

  1. 데이터 전처리:
    • 평균이 0이 되도록 중심화
    • [-1, 1] 범위로 스케일링
  2. 가중치 초기화:
    • ReLU: He 초기화 사용
    • tanh: Xavier 초기화 사용
  3. 정규화:
    • L2 정규화와 드롭아웃 조합 사용
    • 드롭아웃은 p=0.5가 기본값
    • Inverted Dropout 구현 사용
  4. 손실 함수:
    • 분류: Softmax Cross Entropy
    • 회귀: 가능하면 분류로 변환 고려
    • L2 손실 사용시 주의 필요
  5. 배치 정규화:
    • 완전연결층 또는 합성곱층 후에 배치정규화 삽입
    • 활성화 함수 전에 적용

 

딥러닝 학습 최적화 가이드

1. 그래디언트 체크 (Gradient Check)

1.1 중앙 차분법 사용

# 잘못된 방법 (Forward difference)
dx = (f(x + h) - f(x)) / h

# 올바른 방법 (Centered difference)
dx = (f(x + h) - f(x - h)) / (2*h)

1.2 상대 오차 확인

def rel_error(analytic_grad, numeric_grad):
    diff = np.abs(analytic_grad - numeric_grad)
    scale = np.maximum(np.abs(analytic_grad), np.abs(numeric_grad))
    return diff / scale

상대 오차 기준:

  • 1e-2: 그래디언트 잘못됨
  • 1e-2 ~ 1e-4: 주의 필요
  • 1e-4 ~ 1e-7: 대체로 양호
  • < 1e-7: 매우 좋음

1.3 그래디언트 체크 팁

  1. 더블 정밀도(double precision) 사용
  2. 적은 데이터로 테스트
  3. h 값 신중하게 선택 (보통 1e-5)
  4. regularization 효과 분리
  5. dropout/augmentation 비활성화

2. 학습 전 기본 점검사항

2.1 초기 손실값 확인

# CIFAR-10 예시
def check_initial_loss():
    # Softmax 분류기 초기 손실: 2.302 (= -ln(0.1))
    # SVM 분류기 초기 손실: 9.0 (마진이 1일 때)
    init_loss = model.compute_loss()
    print(f"Initial loss: {init_loss}")

2.2 과적합 테스트

def overfit_small_batch():
    small_data = get_small_dataset(20)  # 20개 샘플
    model.train(small_data, reg_strength=0)
    final_loss = model.compute_loss()
    assert final_loss < 1e-7  # 거의 0에 가까워야 함

3. 학습 과정 모니터링

3.1 손실 함수 모니터링

class LearningMonitor:
    def __init__(self):
        self.losses = []
        self.train_acc = []
        self.val_acc = []
        
    def update(self, loss, train_acc, val_acc):
        self.losses.append(loss)
        self.train_acc.append(train_acc)
        self.val_acc.append(val_acc)
        
    def plot_loss(self):
        plt.plot(self.losses)
        plt.title('Training Loss')
        plt.xlabel('Iteration')
        plt.ylabel('Loss')

3.2 가중치/업데이트 비율 모니터링

def check_update_ratio():
    param_scale = np.linalg.norm(W.ravel())
    update = -learning_rate * dW
    update_scale = np.linalg.norm(update.ravel())
    ratio = update_scale / param_scale
    print(f"Update ratio: {ratio:.2e}")  # ~1e-3이 바람직

4. 파라미터 업데이트 방법

4.1 기본 SGD

def sgd_update(w, dw, learning_rate):
    return w - learning_rate * dw

4.2 모멘텀 SGD

class MomentumSGD:
    def __init__(self, learning_rate=0.01, momentum=0.9):
        self.learning_rate = learning_rate
        self.momentum = momentum
        self.velocity = None
        
    def update(self, w, dw):
        if self.velocity is None:
            self.velocity = np.zeros_like(w)
            
        self.velocity = self.momentum * self.velocity - self.learning_rate * dw
        return w + self.velocity

4.3 Nesterov 모멘텀

class NesterovMomentum:
    def __init__(self, learning_rate=0.01, momentum=0.9):
        self.learning_rate = learning_rate
        self.momentum = momentum
        self.velocity = None
        
    def update(self, w, dw):
        if self.velocity is None:
            self.velocity = np.zeros_like(w)
            
        v_prev = self.velocity
        self.velocity = self.momentum * self.velocity - self.learning_rate * dw
        w_update = -self.momentum * v_prev + (1 + self.momentum) * self.velocity
        return w + w_update

4.4 Adam 옵티마이저

class Adam:
    def __init__(self, learning_rate=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8):
        self.learning_rate = learning_rate
        self.beta1 = beta1
        self.beta2 = beta2
        self.epsilon = epsilon
        self.m = None
        self.v = None
        self.t = 0
        
    def update(self, w, dw):
        if self.m is None:
            self.m = np.zeros_like(w)
            self.v = np.zeros_like(w)
            
        self.t += 1
        
        # Update biased first moment estimate
        self.m = self.beta1 * self.m + (1 - self.beta1) * dw
        # Update biased second moment estimate
        self.v = self.beta2 * self.v + (1 - self.beta2) * dw**2
        
        # Bias correction
        m_hat = self.m / (1 - self.beta1**self.t)
        v_hat = self.v / (1 - self.beta2**self.t)
        
        update = -self.learning_rate * m_hat / (np.sqrt(v_hat) + self.epsilon)
        return w + update

5. 학습률 스케줄링

5.1 단계적 감소

class StepLRScheduler:
    def __init__(self, initial_lr, drop_factor=0.5, drop_every=10):
        self.initial_lr = initial_lr
        self.drop_factor = drop_factor
        self.drop_every = drop_every
        
    def get_lr(self, epoch):
        return self.initial_lr * (self.drop_factor ** (epoch // self.drop_every))

5.2 지수적 감소

class ExponentialLRScheduler:
    def __init__(self, initial_lr, decay_rate):
        self.initial_lr = initial_lr
        self.decay_rate = decay_rate
        
    def get_lr(self, epoch):
        return self.initial_lr * np.exp(-self.decay_rate * epoch)

6. 하이퍼파라미터 최적화

6.1 무작위 탐색 구현

def random_search():
    param_distributions = {
        'learning_rate': lambda: 10 ** np.random.uniform(-6, 1),
        'reg_strength': lambda: 10 ** np.random.uniform(-3, 3),
        'momentum': lambda: np.random.uniform(0.8, 0.999),
        'dropout': lambda: np.random.uniform(0.3, 0.7)
    }
    
    best_params = None
    best_val_acc = -1
    
    for _ in range(100):  # 100회 시도
        params = {k: v() for k, v in param_distributions.items()}
        model = train_model(**params)
        val_acc = evaluate(model)
        
        if val_acc > best_val_acc:
            best_val_acc = val_acc
            best_params = params
            
    return best_params

6.2 단계적 탐색

def staged_search():
    # 1단계: 넓은 범위, 적은 에폭
    coarse_params = random_search(
        lr_range=(-6, 1),
        epochs=1
    )
    
    # 2단계: 좁은 범위, 중간 에폭
    medium_params = random_search(
        lr_range=(coarse_params['lr']-1, coarse_params['lr']+1),
        epochs=5
    )
    
    # 3단계: 매우 좁은 범위, 많은 에폭
    final_params = random_search(
        lr_range=(medium_params['lr']-0.1, medium_params['lr']+0.1),
        epochs=20
    )
    
    return final_params

7. 모델 앙상블

7.1 기본 앙상블

class ModelEnsemble:
    def __init__(self, models):
        self.models = models
        
    def predict(self, X):
        predictions = [model.predict(X) for model in self.models]
        return np.mean(predictions, axis=0)

7.2 가중치 평균화

class ExponentialMovingAverageModel:
    def __init__(self, model, decay=0.999):
        self.model = model
        self.decay = decay
        self.shadow = {}
        
    def update(self):
        for name, param in self.model.named_parameters():
            if name not in self.shadow:
                self.shadow[name] = param.data.clone()
            else:
                self.shadow[name].mul_(self.decay).add_(
                    param.data * (1 - self.decay))

8. 최종 체크리스트

  1. 그래디언트 체크 수행
  2. 초기 손실값 확인
  3. 작은 데이터셋으로 과적합 테스트
  4. 학습 과정 모니터링 설정
  5. 적절한 옵티마이저 선택 (Adam 또는 Nesterov Momentum SGD)
  6. 학습률 스케줄링 구현
  7. 하이퍼파라미터 최적화 수행
  8. 모델 앙상블 고려