
지난 글:
https://powderblue0.tistory.com/20
[딥러닝] ANN(인공신경망)의 원리
이번 글에서는 딥러닝의 기본이 되는 ANN(Artificial Nerual Network, 인공신경망)의 원리에 대해 설명합니다. 사실 직관적으로 이해하기에 어려운 부분은 없는데, 많은 개념이 나오는 만큼 쉽게 까먹게
powderblue0.tistory.com
지난 글에서는 ANN의 기본적인 학습 과정에 대해 정리했습니다.
딥러닝은 많은 시행착오들을 거치며 발전해 왔습니다. 이론에서 나오는 대부분의 개념은 '원리 적용 → 문제발생 → 문제해결'의 흐름을 따라 진행됩니다. 그러나 그 흐름을 한번에 배우려고 하면 나오는 개념이 너무 많고 용어도 길고 헷갈리는지라, 한번에 배우려고 했다가는 오히려 아무것도 못 얻어가는 경우가 많다고 항상 느낍니다.
그래서 이번 글에서는 "실제로 모델을 만들 때 무엇을 알고 있어야 하고, 무엇을 유의해야 하는지"에 조금 더 집중한 내용들을 정리하려고 합니다.
목차
1. Activation Function은 어디에 넣는가?
1.1. 활성화함수의 두 가지 역할
1.2. 중간층에서의 비선형성 부여 (ReLU)
1.3. Task에 맞는 형태의 Output 출력
2. OverShooting(오버슈팅) 발생 시
2.1. OverShooting 현상이란?
2.2. 오버슈팅 발생의 신호
2.3. 오버슈팅의 해결 방법
3. Early Stopping & Scheduler
3.1. Early Stopping (조기종료)
3.2. Scheduler
1. Activation Function은 어디에 넣는가?
1.1. 활성화함수의 두 가지 역할
지난 글에서 '비선형성 확보를 위해 활성화함수가 필요하다'고 설명한 부분에 이어 조금 더 자세히 설명해보겠습니다.
실제로 모델을 만들고, 레이어들을 쌓아가는 과정에서 활성화함수의 역할은 두 가지로 나눌 수 있습니다.
(1) Hidden Layer에서의 비선형성 부여
(2) 모델 Task에 맞는 형태의 Output 출력
이 둘을 나눠서 이해해야 실제로 코드를 작성할 때 헷갈리지 않습니다.
1.2. 중간층에서의 비선형성 부여 (ReLU)
Hidden Layer는 입력값을 받는 레이어와 출력값을 내는 레이어를 제외한 중간 부분을 통틀어 지칭합니다. Hidden Layer는 '중간층'이라고 번역되어 설명되고는 한데, '중간층'이라는 용어가 조금 더 직관적이니까 앞으로는 '중간층'이라고 부르겠습니다.

이전 설명에서의 '비선형성 확보를 위해 활성화함수가 필요하다'는 설명에 해당하는 것은 중간층에 해당하는 설명입니다.
레이어의 계산 과정에서 "Input에 가중치를 곱한 후 모두 더하는 과정"을 여러 번 반복해도 결국엔 선형적 결합으로만 남기 때문에, 이 선형 관계를 비틀어주기 위해 비선형 활성화함수를 사용합니다.

보이는 것처럼 비선형 활성화함수에는 여러 종류가 있는데,
자주 쓰이는 것, 즉 알아둬야 할 것은 ReLU, Sigmoid, tanh 이렇게 세 개입니다.
- ReLU함수의 경우 음수에서는 0값을 가지고, 양수에서는 들어온 함수값을 그대로 뱉는, 단위 Ramp 함수와 동일합니다. (동일한 공식을 가지는 함수고, 쓰이는 용어만 다름)
- Sigmoid의 경우 자연상수를 포함한 공식으로 정의되는 연속함수이고,
- tanh함수의 경우 Hyperbolic Tangent를 의미하며 말 그대로 탄젠트 함수의 역함수입니다.
이 중 tanh, Sigmoid의 경우 역전파 시 Output → Input 방향으로 나아가면서 각 weight 미분값의 계산 결과가 점점 사라지는, 이른바 Gradient Vanishing(기울기 손실) 문제를 야기하기 때문에,
보통은 그냥 ReLU를 사용합니다. "그냥 ReLU를 사용한다"만 기억하면 되겠습니다.
실제 코드에서는 중간층에서, Linear 레이어 뒤에 항상 ReLU를 붙여줘야 합니다. 이전에서 설명했듯이, Linear 레이어 두 개를 붙여 써도, 수학적으로는 하나의 Linear 레이어를 쓴 것과 동일한 효과를 내기 때문에 중간에 ReLU 층을 추가해 주는 것입니다.
import torch
import torch.nn as nn
class MyModel(nn.Module):
def __init__(self, input_dim):
super().__init__()
self.linear1 = nn.Linear(input_dim, 32)
self.linear2 = nn.Linear(32,10)
self.relu = nn.ReLU()
def forward(self, x):
x = self.linear1(x)
x = self.relu(x) # 비선형 활성화함수
x = self.linear2(x)
return x
예를 들어, 위 코드에서 중간에 x = self.relu(x)를 넣지 않는다면, linear1과 linear2를 연달아 쓰는 의미가 없어집니다.
참고로 맨 마지막 linear2 층 뒤에 relu를 넣어주지 않는 이유는 중간층이 아니라, 단순히 출력값을 뱉는 마지막 출력층이기 때문입니다.
1.3. Task에 맞는 형태의 Output 출력
활성화함수는 중간층에서는 비선형성을 부여하는 역할을 하고,
Output 층에서는 Task에 맞는 형태의 Output을 출력하게끔 하는 역할을 합니다. 모델의 Task는
(1) 회귀 (2) 이진분류 (3) 다중분류
로 나눌 수 있는데, 회귀의 경우 숫자 자체를 예측하는 것이기 때문에 별다른 Output값 형태 조정이 필요하지 않습니다. 예를 들어 예측한 값이 0.88이면, 0.88 그대로 출력하면 됩니다.
그런데 이진분류는 다릅니다. 0.88이 나온다면 0과 1 중에서 1에 가깝다고 판단하고, 1이라는 값을 출력해야 합니다. 이러한 출력 방식의 조정을 위해서도 활성화함수가 사용됩니다.
일단 Output의 출력 상태 조절을 위해 마지막층에 활성화함수가 쓰인다는 사실만 기억하고, 이를 이진분류의 경우와 다중분류의 경우로 나눠서 봅시다.
(1) 이진분류의 경우
이진분류의 경우 결과값이 0 혹은 1이어야 한다는 출력 형태의 제한이 있습니다.
이는 두 가지 형태로 구현할 수 있는데,
- 0 혹은 1의 형태가 나오도록, Sigmoid 함수를 모델 안에 넣는 경우 (별도의 Loss 함수 조정 없이)
- 0 혹은 1의 형태가 나오도록 Loss 함수를 설정하는 경우
별도의 Loss 함수 조정 없이, Sigmoid 함수를 모델 안에 넣는 경우는 다음과 같습니다. (이진분류이기 때문에 Loss함수는 BCE Loss를 사용한다고 가정합니다.)
import torch
import torch.nn as nn
class BinaryModel(nn.Module):
def __init__(self, input_dim):
super().__init__()
self.linear1 = nn.Linear(input_dim, 32)
self.relu = nn.ReLU()
self.linear2 = nn.Linear(32, 1) # 이진분류이기 때문에 output_dim = 1
self.sigmoid = nn.Sigmoid() # 확률로 변환
def forward(self, x):
x = self.linear1(x)
x = self.relu(x)
x = self.linear2(x)
x = self.sigmoid(x) # 마지막층에 sigmoid 활성화함수 포함
return x
model = BinaryModel(input_dim=10)
criterion = nn.BCELoss() # BCELoss() 사용함
반면 위 코드와 달리 Sigmoid 층을 모델 안에 (Output층에) 넣는 게 아니라, 아예 Loss 함수를 다르게 설정해서 0 혹은 1의 형태가 나오도록 조정하는 방법도 있습니다.
BCEWithLogitsLoss는 말그대로 Loss를 계산하는 과정에서 Sigmoid의 역할이 포함되기 때문에, Output층에 별도의 활성화함수를 넣을 필요가 없습니다. 정확히 말하면 Output층에 별도의 활성화함수를 추가해서는 "안 되는" 것입니다.
import torch
import torch.nn as nn
class BinaryModel(nn.Module):
def __init__(self, input_dim):
super().__init__()
self.linear1 = nn.Linear(input_dim, 32)
self.relu = nn.ReLU()
self.linear2 = nn.Linear(32, 1)
def forward(self, x):
x = self.linear1(x)
x = self.relu(x)
x = self.linear2(x) # Output층에 Sigmoid 없음
return x
model = BinaryModel_BCEWithLogits(input_dim=10)
criterion = nn.BCEWithLogitsLoss() # BCEWighLogitLoss() 사용
코드의 경우 BCEWithLogitsLoss 불러올 때 대문자 소문자 구별만 잘 해주시면 됩니다.
(2) 다중분류의 경우
다중분류의 경우도 동일한 흐름으로 진행됩니다.
- 원하는 레이블 출력이 나오도록, LogSoftmax 함수를 모델 안에 넣는 경우
- 원하는 레이블 출력이 나오도록 , Loss 함수를 CrossEntropyLoss로 설정하는 경우
다만 다중분류의 경우 후자의 경우를 압도적으로 많이 씁니다. 아니 그냥 후자의 경우만 쓴다고 생각하셔도 됩니다.
import torch
import torch.nn as nn
class MulticlassModel(nn.Module):
def __init__(self, input_dim):
super().__init__()
self.linear1 = nn.Linear(input_dim, 32)
self.relu = nn.ReLU()
self.linear2 = nn.Linear(32, 4)
def forward(self, x):
x = self.linear1(x)
x = self.relu(x)
x = self.linear2(x) # 별도의 활성화함수 없음
return x
model = MultiClassModel_CE(input_dim=10)
criterion = nn.CrossEntropyLoss() # CrossEntropyLoss() 사용
2. OverShooting(오버슈팅) 발생 시
2.1. OverShooting 현상이란?
지난 글에서 모델 학습 과정에서의 파라미터 업데이트는 손실함수의 가장 가파른 내리막길의 방향으로 파라미터를 한 걸음 이동시키는 원리로 진행된다고 설명한 바 있습니다.

여기서 실제 모델의 학습 과정에서는 OverShooting(오버슈팅) 현상이 일어나기도 하는데,
Over Shotting(오버슈팅) 현상이란 파라미터 업데이트 과정에서 최소점을 향해 한 걸음씩 앞으로 나아가다가, 최소점을 지나버리는 현상입니다.

수학적으로 보면 이런데, 코드 작성하면서 수학 공식까지 같이 적을 거 아니니까 스킵하겠습니다.
오버슈팅 현상의 근본적인 원인은 "앞으로 한 걸음 나아갈" 때 "한 걸음의 크기가 너무 커서" 발생하는 것입니다. 여기서 말하는 "한 걸음의 크기"를 learning rate(코드에서는 lr)라 합니다. 우선은 오버슈팅 발생을 의심해볼 만한 신호들에 대해 알아봅시다.
2.2. 오버슈팅 발생의 신호
모델 학습 과정에서 오버슈팅이 발생했다고 의심할 수 있는 신호는 다음과 같습니다.
- 첫째는, Epoch에 따른 Loss 그래프가 안정적으로 감소하지 않는 경우입니다. 들쭉날쭉하게 진동하는 경우와, 수렴하지 않고 출렁이는 경우를 모두 포함합니다.
- 둘째는, Epoch에 따른 Loss 그래프가 발산하는 경우입니다. 첫 번째 상황에서 더욱 악화된 상태로, Loss가 줄어들지 않고 오히려 증가하는 경우입니다.
- 셋째는, weight값이 커졌다 작아졌다를 반복하는 경우입니다.
결론적으로는 안정적인 학습을 위해 Epoch에 따른 Loss 그래프를 확인할 필요가 있고, 그래프가 여러분들이 아는 그런 이상적인 형태에서 벗어난 경우 의심해 볼 만 하다는 것입니다.

사진은 아래 링크에서 가져온 건데, 읽어볼 만 한 내용이니 참고하세용
https://deepdatascience.wordpress.com/2016/11/18/lstm-epoch-size/
LSTM Epoch Size Choice
Epoch size represents the total number of iterations the data is run through the optimizer[18] Too few epochs, then the model will prematurely stop learning and will not grasp the full knowledge of…
deepdatascience.wordpress.com
2.3. 오버슈팅의 해결 방법
(1) lr 줄이기
오버슈팅의 근본적인 원인은 "한 걸음 나아갈 때의 그 한 걸음이 너무 크다", 즉 learning rate가 너무 크게 설정되어 있기 때문이기에, 기본적인 해결 방법은 일단 learning rate를 줄이는 것입니다.
이전 글에서 Optimizer를 선언할 때 lr를 같이 선언한 것을 기억하신다면, 단순히 여기서 설정하는 lr의 크기를 더 줄여주면 됩니다. lr는 보통 0.01에서 시작하고, 줄일 떄는 1/10배씩 줄이는 것이 통상적입니다.
그러니까, 0.01에서 0.01 → 1e-4 → 1e-5 의 순으로 줄여나가는 것이죠.
import torch.optim as optim
optimizer = optim.SGD(model.parameters(), lr=0.01)
optimizer = optim.SGD(model.parameters(), lr=1e-3)
optimizer = optim.SGD(model.parameters(), lr=1e-4)
간단하게는 이런 식으로 줄여 나가면 됩니다. 여기서 1e-4의 경우 10의 -4승을 의미합니다.
lr를 조금 더 효율적으로 줄이는 방법으로는 Scheduler를 이용하는 방법도 있는데 이건 '3.2.Scheduler'에서 자세히 설명하겠습니다.
(2) Momentum 추가
Momentum은 옵티마이저의 발전 과정에서 나온 개념인데, 경사하강법에서 "어떻게 나아갈지"를 결정하는 최적화 알고리즘에 해당합니다. 정확히는 "Momentum 알고리즘"이라는 표현이 맞다고 볼 수 있습니다.
핵심 원리는 이전의 이동 방향을 기억해서, 현재 기울기와 과거 기울기의 누적을 반영하여 이동하는 것입니다.
알고리즘 자체에 대한 자세한 설명은 아래 링크에서 참고하시면 좋을 것 같습니다.
https://bruders.tistory.com/93
최적화 알고리즘 (ft. Momentum, RMSprop, Adam)
경사 하강법보다 빠른 몇 가지 최적화 알고리즘이 존재한다. 그 알고리즘들을 이해하기 위해서는 먼저 지수 가중 평균(Exponentially weighted averages)을 이해해야한다지수 가중 평균에 대한 글은 아래
bruders.tistory.com
코드 작성 시 중요한 건 파라미터로 momentum이 추가된다는 것이고 보통은 0.9를 쓴다는 사실만 기억하면 됩니다.
import torch.optim as optim
optimizer = optim.SGD(model.parameters(),lr=1e-3,momentum=0.9)
밑에서도 설명하겠지만 요즘은 Momentum과 RMSProp를 결합한 Adam이 압도적으로 많이 쓰이기 때문에, 개인적으로는 실제 모델링 과정에서 모멘텀 알고리즘 자체에 대해 자세히 알 필요는 없다고 생각합니다. (그러니까 웬만하면 Adam 쓰세여 여러분)
(3) RMSProp 혹은 Adam 이용
RMSProp 알고리즘의 경우 기울기가 큰 방향으로는 lr를 줄이고, 반대로 울기가 작은 방향은 lr를 증가시키는 등 lr를 기울기에 따라 유동적으로 조절하는 것이 핵심입니다. 그리고 이러한 RMSProp을 Momentum과 결합한 것이 Adam 옵티마이저인 것입니다.
결국에는 "파라미터를 어떻게 오버슈팅 없이 업데이트 시킬 것인가?" 하는 질문에서 출발하여,
Momentum → RMSProp → Adam 이런 식으로 발전해온 것이기 때문에, 특별한 사유가 없는 이상 Adam을 이용하시면 됩니다.
import torch.optim as optim
optimizer = optim.RMSprop(model.parameters(),lr=1e-3,
alpha=0.99)
optimizer = optim.Adam(model.parameters(),lr=1e-3,
betas=(0.9, 0.999))
코드를 살펴보자면,
RMSProp는 alpha라는 파라미터가 추가되는데, 통상적으로 0.99 기본값 유지하여 사용하는 경우가 대부분이고, Adam의 경우는 betas가 추가되는데 이것도 통상적으로 기본값 그대로 씁니다.
3. Early Stopping & Scheduler
지금까지 설명한 것들의 연장선에서, 실제로 많이 쓰이는 Early Stopping과 Scheduler에 대해 조금 더 자세히 정리해보겠습니다.
3.1. Early Stopping (조기종료)
실제로 모델을 학습시키는 과정에서는 모델을 처음 학습시킬 때 적절한 epoch를 알기 어렵기에, 우선 임의의 epoch를 정하여 사용하게 됩니다. 그런데 임의의 epoch를 사용하였을 때의 문제점은 과적합 발생 가능성이 있다는 것이죠.
이를 방지하기 위해 모델이 어느 정도 학습을 했다고 판단하면, 모델의 학습을 자동으로 중단하는 Early Stopping(조기종료)가 널리 사용됩니다. (사실상 Early Stopping의 개념 자체는 어렵지 않습니다.)
실전에서는 적당한 epoch 설정 후 모델 학습을 시킨 다음, 모델 성능이 과하게 좋은 경우 과적합을 의심해보고 이를 해결하기 위해 Early Stopping을 추가적으로 시도하는 것도 좋지만,
보통은 그냥 처음부터 Early Stopping을 적용하여 과적합의 발생 가능성을 사전에 방지합니다. (어디서 한 말은 아니고, 그냥 제 경험상 그렇습니다.)
전체 코드를 보고 알아봅시다. (DataLoader를 사용한 코드라 복잡할 수도 있지만, 중요한 건 if-else 문이 포함된 맨 마지막 부분입니다.)
import torch
patience = 5
best_val_loss = float('inf')
counter = 0
for epoch in range(100):
model.train() # 모델 학습 과정
for x, y in train_loader:
optimizer.zero_grad()
output = model(x)
loss = criterion(output, y)
loss.backward()
optimizer.step()
model.eval() # 모델 평가 과정
val_loss = 0
with torch.no_grad():
for x, y in val_loader:
output = model(x)
loss = criterion(output, y)
val_loss += loss.item()
val_loss /= len(val_loader)
print(f"Epoch {epoch}, Val Loss: {val_loss:.4f}")
# Early Stopping 부분
if val_loss < best_val_loss:
best_val_loss = val_loss
counter = 0
torch.save(model.state_dict(), "best_model.pt") # Best Model 저장
else:
counter += 1
if counter >= patience:
break
코드 작성 시 통상적으로 patience 변수와 counter 변수를 선언해서, counter가 patience를 넘어가면 학습을 중지하는 루프를 써줍니다. 여기서 counter 변수는 성능이 개선되지 않은 epoch를 세는 역할을 합니다.
코드의 핵심이 되는 부분만을 놓고 보면 바로 이해 가능합니다.
if val_loss < best_val_loss: # 가장 좋은 모델
best_val_loss = val_loss
counter = 0
torch.save(model.state_dict(), "best_model.pt") # Best Model 저장
else:
counter += 1 # 성능 개선이 되지 않는 경우, counter += 1
if counter >= patience: # counter가 patience를 넘어가면, 학습 종료
print("Early Stopping 발생")
break
3.2. Scheduler
Scheduler는 위에서 서술한 learning rate의 연장선에 있는 개념으로, 학습 도중에 learning rate를 자동으로 조절하는 장치입니다. 딥러닝 과정에서 오버슈팅이 발생한 경우 혹은 오버슈팅 발생이 의심되는 경우 사용 가능합니다.
핵심은 다음과 같습니다.
- 초반에는 큰 lr를 사용하여 빠르게 탐색을 진행하고,
- 후반에는 작은 lr를 사용하여 안정적인 수렴을 목표로 함.
종류는 여러 가지가 있는데, 좀 많으니까 설명은 생략하겠습니다... 궁금하신 분들 아래 링크 한 번 읽어보세요..
https://hichoe95.tistory.com/131
학습률 스케줄러(Learning Rate Scheduler) 완벽 가이드
학습률 스케줄러(Learning Rate Scheduler) 완벽 가이드 포스트 요약: 주요 PyTorch 학습률 스케줄러인 StepLR, ExponentialLR, CosineAnnealingLR, OneCycleLR, ReduceLROnPlateau의 동작 원리와 설정 방법을 상세히 설명하고,
hichoe95.tistory.com
간단한 코드를 살펴보면 다음과 같습니다.
import torch
import torch.optim as optim
optimizer = optim.Adam(model.parameters(), lr=0.1)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)
우선은 Optimizer를 기존의 선언과 동일한 방법으로 선언해줍니다. 이후 scheduler를 알맞게 선언합니다. 변수 선언 순서는 반드시 Optimizer → Scheduler 순서어야 합니다.
파라미터로 들어있는 step_size = 10이라는 것은 10 epoch마다 lr을 줄인다는 의미이고,
gamma = 0.1이라는 것은 lr을 줄일 때, 0.1배씩 줄여나간다는 것입니다. gamma = 10이 아님에 주의하세요.
이후 학습코드에서는 학습루프 안에 optimizer.step()을 넣는 것까지는 동일하고, epoch가 끝나고 나서 scheduler.step()도 추가해줘야 합니다. (StepLR의 경우)
model = MyModel(input_dim=100)
criterion = nn.CrossEntropyLoss()
num_epochs = 30
for epoch in range(num_epochs):
model.train()
total_loss = 0.0
for inputs, labels in train_loader:
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item()
scheduler.step() # epoch 끝나고 나서 scheduler.step()
실제로 제가 사용한 예시는 다음과 같습니다.

위의 이미지는 Scheduler를 사용하지 않은 경우의 Epoch에 따른 Loss 그래프이고,
import torch.optim as optim
criterion = nn.BCEWithLogitsLoss()
optimizer = optim.Adam(model_sc.parameters(), lr=0.01)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=100, gamma=0.5) # 100 epoch마다 lr을 0.5배로 줄임
num_epochs = 1000
train_losses = []
test_losses = []
for epoch in range(num_epochs + 1):
pred = model_sc(X_train)
loss = criterion(pred, y_train)
optimizer.zero_grad()
loss.backward()
optimizer.step()
scheduler.step() # 스케쥴러 업데이트
이런 식으로 scheduler를 선언하고 업데이트 과정을 추가해주면,

다음과 같이 조금은 안정적으로 변하게 됩니다. (실제로 모델 예측 성능은 크게 증가하진 않았으나, 안정적인 학습이 가능)
'머신러닝, 딥러닝' 카테고리의 다른 글
| [딥러닝] DL 용어정리(아키텍처, 파이프라인 등) (0) | 2026.02.24 |
|---|---|
| [딥러닝] ANN(인공신경망)의 원리 (0) | 2026.02.14 |