import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
뭔가 데이터는 다운 받아서 왔고, 학습에 6만개, 테스트에 1만개인 것은 알겠는데, numpy에서 보던 (60000, 28, 28) 뭐 이런 식의 shape이 아니라서 알 수가 없다. 게다가 batch 크기로 나누어 놓은 loader의 경우도 데이터의 크기를 바로 알 수 있는 형태가 아니다.
그래서 Gemini한테 물어보니 iter() 함수를 통해 iteration 반복자로 변환한 후 next() 함수로 불러오는 식으로 하면 된다더라.
# 2. DataLoader에서 첫 번째 배치 가져오기
# iter()로 반복자(iterator)를 만든 후 next()를 호출합니다.
data_iter = iter(train_loader)
images, labels = next(data_iter) # images: 이미지 텐서, labels: 레이블 텐서
# 3. 속성 확인 및 출력
print("\n--- DataLoader를 통해 얻은 배치(Batch) 속성 ---")
print(f"이미지 텐서 모양 (Image Tensor Shape): {images.shape}")
print(f"이미지 텐서 자료형 (Image Tensor Data Type): {images.dtype}")
print("-" * 30)
print(f"레이블 텐서 모양 (Label Tensor Shape): {labels.shape}")
print(f"레이블 텐서 자료형 (Label Tensor Data Type): {labels.dtype}")
이렇게 첫 batch에 있는 자료를 확인할 수 있었다.
Keras 때는 channel 정보를 shape의 마지막에 넣어두었었는데, 여기서는 그게 먼저 오는 것 같고, 값을 하나씩 보니, 이미지는 각 픽셀이 0에서 1 사이 값으로, label은 0, 1, 2, ..., 9 이렇게 들어있다.
사실 이제 그냥 해도 되긴 하는데, 예시 코드를 보니 평균과 표준편차를 이용해서 normalize를 하더라. 계산해야 겠지만, 이미 잘 알려져 있는 데이터이므로, 그 값은, MEAN = 0.1307; STD = 0.3081 이라고 한다.
이제 CNN 구조를 만들어 보자.
class CNNModel(nn.Module):
def __init__(self):
super().__init__()
# 1. 컨볼루션 계층 1 (Convolutional Layer 1)
# 입력 채널(Input Channel): 1 (흑백 이미지)
# 출력 채널(Output Channel): 32
# 커널 크기(Kernel Size): 3x3
self.conv1 = nn.Conv2d(1, 32, 3)
# 2. 컨볼루션 계층 2 (Convolutional Layer 2)
# 입력 채널: 32
# 출력 채널: 64
self.conv2 = nn.Conv2d(32, 64, 3)
# 3. 풀링 계층 (Pooling Layer) - Max Pooling
# nn.MaxPool2d는 보통 forward()에서 F.max_pool2d로 사용됩니다.
# 4. 드롭아웃 (Dropout)
# 과적합(Overfitting) 방지를 위해 사용됩니다.
self.dropout1 = nn.Dropout(0.25)
# 5. 선형 계층 (Linear Layer)
# Conv/Pooling을 거친 Flatten된 출력을 받아 10개의 클래스(0~9)로 분류합니다.
# 입력 크기는 (28x28 이미지 기준) 9216 (Flatten된 크기)을 계산하여 얻어야 합니다.
self.fc1 = nn.Linear(9216, 128) # fc: Fully Connected (전결합)
self.fc2 = nn.Linear(128, 10) # 출력: 10개 클래스 (0~9)
def forward(self, x):
# 1. Conv1 -> ReLU -> Max Pooling
x = self.conv1(x)
x = F.relu(x)
# 2. Conv2 -> ReLU -> Max Pooling (커널 크기 2x2)
x = self.conv2(x)
x = F.relu(x)
x = F.max_pool2d(x, 2)
# 3. 드롭아웃 적용
x = self.dropout1(x)
# 4. Flatten (평탄화): 2차원/3차원 데이터를 1차원 벡터로 만듦
# x.shape: (배치 크기, 채널, 높이, 너비) -> (배치 크기, 채널*높이*너비)
x = torch.flatten(x, 1)
# 5. Fully Connected Layers
x = self.fc1(x)
x = F.relu(x)
x = self.fc2(x)
# 최종 출력: 10개의 클래스에 대한 로짓(Logits)
return x
이미 위 코드에 주석으로 설명이 다 들어갔지만, 전체적인 설명을 하자면, 이전 글에서는 sequential하게 이미 __init__() 함수 내에서 layer 간의 연결이 다 이루어졌지만, 여기서는 __init__() 함수 안에서는 layer들만 정의가 되고, forward() 함수에서 모델 간의 관계가 정의되는 식이다. Keras에 익숙한 내가 보기에는 functional api 방식으로 모델을 만들 때와 비슷한 느낌이다. 다소 번거로울 수 있지만, 입출력 데이터의 모양을 명확하게 알아야 하는 부분이기도 하다.
이제 모델도 만들었겠다, 장치 설정도 하고 손실함수와 옵티마이저도 정의하자.
# GPU 사용 가능 여부를 확인하고 장치 설정
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 모델 인스턴스 생성 및 GPU로 이동
model = CNNModel().to(device)
print(f"모델은 {device}에서 실행됩니다.")
# 손실 함수(Loss Function): 분류 문제에 적합한 CrossEntropyLoss 사용
criterion = nn.CrossEntropyLoss()
# 옵티마이저(Optimizer): Adam 사용
optimizer = optim.Adam(model.parameters(), lr=0.001)
이제 학습 루프와 테스트 루프를 함수 형태로 제작하자. 그리고 원레 데이터 다운로드하면서 하는 게 일반적이지만, 본 글에서는 데이터를 normalize하지 않고 가져왔기 때문에, 여기서는 학습과 테스트 함수 내에서 normalize까지 함께 진행한다.
MEAN = 0.1307
STD = 0.3081
# 데이터 정규화(Normalization)를 수행하는 함수 정의
def normalize_data(data, mean, std):
# (data - mean) / std 수식 적용
return (data - mean) / std
# ----------------- train 함수 수정 (손실만 반환) ------------------
def train(model, device, train_loader, optimizer, epoch, mean, std):
model.train()
total_loss = 0 # Epoch 전체 손실을 누적할 변수
for batch_idx, (data, target) in enumerate(train_loader):
data = normalize_data(data, mean, std)
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
loss.backward()
optimizer.step()
total_loss += loss.item() * len(data) # 배치 크기를 곱해 실제 손실 기여도를 누적
if batch_idx % 100 == 0:
print(f'Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} ({100. * batch_idx / len(train_loader):.0f}%)]\tLoss: {loss.item():.6f}')
# 에포크 전체 평균 손실 반환
return total_loss / len(train_loader.dataset)
# ----------------- evaluate_train 함수 추가 (정확도 계산) ------------------
def evaluate_train(model, device, train_loader, mean, std):
model.eval()
correct = 0
with torch.no_grad():
for data, target in train_loader:
data = normalize_data(data, mean, std)
data, target = data.to(device), target.to(device)
output = model(data)
pred = output.argmax(dim=1, keepdim=True)
correct += pred.eq(target.view_as(pred)).sum().item()
accuracy = 100. * correct / len(train_loader.dataset)
# print(f'Train set: Accuracy: {correct}/{len(train_loader.dataset)} ({accuracy:.0f}%)')
return accuracy
# ----------------- test 함수는 이전과 동일 (손실/정확도 모두 반환) ------------------
def test(model, device, test_loader, mean, std):
model.eval()
test_loss = 0
correct = 0
with torch.no_grad():
for data, target in test_loader:
data = normalize_data(data, mean, std)
data, target = data.to(device), target.to(device)
output = model(data)
test_loss += criterion(output, target).item()
pred = output.argmax(dim=1, keepdim=True)
correct += pred.eq(target.view_as(pred)).sum().item()
test_loss /= len(test_loader.dataset)
print(f'\nTest set: Average loss: {test_loss:.4f}, Accuracy: {correct}/{len(test_loader.dataset)} ({100. * correct / len(test_loader.dataset):.0f}%)\n')
return test_loss, 100. * correct / len(test_loader.dataset)
이제 학습을 실행해보자. normalize를 위해 mean과 std를 함수에 전달해줘야 한다.... 역시나 데이터 다운로드 할 때 함께 할 걸 그랬다. ㅎㅎ;
keras에서는 학습하라는 쉬운 명령어가 있었던 것 같은데 pytorch는 일일히 다 만들어줘야 하는 건가? 아니면 교육자료로 Gemini한테 만들어달라고 해서 굳이 디테일하게 보여주는 건가? ㅎㅎ 학습 history 기록도 챙겨줘야 하니 뭔가 좀 번거롭다.
시간을 절약하기 위해 epoch 수는 10번으로 실행
num_epochs = 10
train_loss_history = [] # 훈련 손실 기록
test_loss_history = [] # 테스트 손실 기록
train_accuracy_history = [] # 훈련 정확도 기록
test_accuracy_history = [] # 테스트 정확도 기록
print("CNN 모델 학습 및 결과 기록 시작...")
for epoch in range(1, num_epochs + 1):
# 1. 훈련 및 훈련 손실 기록 (Training and Train Loss Recording)
# train 함수는 이제 Epoch의 평균 손실을 반환합니다.
avg_train_loss = train(model, device, train_loader, optimizer, epoch, MEAN, STD)
train_loss_history.append(avg_train_loss)
# 2. 훈련 정확도 계산 및 기록 (Train Accuracy Calculation)
train_accuracy = evaluate_train(model, device, train_loader, MEAN, STD)
train_accuracy_history.append(train_accuracy)
# 3. 테스트 손실 및 정확도 계산 및 기록 (Test Loss & Accuracy Recording)
avg_test_loss, test_accuracy = test(model, device, test_loader, MEAN, STD)
test_loss_history.append(avg_test_loss)
test_accuracy_history.append(test_accuracy)
print("CNN 모델 학습 완료!")
test loss가 0이라고??? 뭔가 이상한데;; 어쨌든 정확도가 엄청 높게 나왔다.
그래프로 결과를 확인해보자.
# ----------------------------------------------------
# 그래프 시각화 (Visualization)
# ----------------------------------------------------
# Epoch 인덱스 리스트 (X축)
epochs = range(1, num_epochs + 1)
plt.figure(figsize=(12, 5))
# A. 손실(Loss) 그래프: 훈련 손실 vs. 테스트 손실
plt.subplot(1, 2, 1)
plt.plot(epochs, train_loss_history, 'b-o', label='Training Loss') # 훈련 손실
plt.plot(epochs, test_loss_history, 'r-o', label='Test Loss') # 테스트 손실
plt.title('Loss over Epochs')
plt.xlabel('Epoch')
plt.ylabel('Loss (Cross Entropy)')
plt.grid(True)
plt.legend()
# B. 정확도(Accuracy) 그래프: 훈련 정확도 vs. 테스트 정확도
plt.subplot(1, 2, 2)
plt.plot(epochs, train_accuracy_history, 'b-o', label='Training Accuracy') # 훈련 정확도
plt.plot(epochs, test_accuracy_history, 'r-o', label='Test Accuracy') # 테스트 정확도
plt.title('Accuracy over Epochs')
plt.xlabel('Epoch')
plt.ylabel('Accuracy (%)')
plt.grid(True)
plt.legend()
plt.tight_layout()
plt.show()
test loss는 뭔가 이상하지만 어쨌든 accuracy는 그럴듯하게 나오는 것 같다.
뭔가 test loss 계산식에 오류가 있었는 것 같은데... 그건 다음에 확인해보도록 하자;; (정신력 부족)
더불어서, 굳이 하나만 더 언급하자면, 우리가 만드는 모델이 분류(classification) 모델인지 예측(regression) 모델인지 따져볼 필요도 있겠다. (regression은 선형회귀라고 부르지만... 예측이라고 일단 하고 넘어가겠다.) 분류는 카테고리가 여러개인 것 중에 어떤 카테고리 하나가 선택되어야 하는지에 대한 문제이고, 예측은 어떤 값이 나와야 하는지에 대한 문제이다. 예를 들어서, 이미지를 바탕으로 고양이와 개라는 두 개의 카테고리를 두고 어느 것이 선택되어야 하는가에 대한 것이라면 분류 문제이고, 나이와 몸무게를 바탕으로 키가 얼마나 될지에 대한 값을 예측하는 것이라면 예측 문제다. (참고로, 예측할 값을 카테고리로 나누어서 분류 문제로 접근할 수도 있지는 하다.)
여튼 가장 처음 만들어볼 모델은 예측 모델이다. 앞서 들었던 나이와 몸무게를 바탕으로 키가 얼마나 될지를 예측하는 인공신경망을 만들어보자.
우선, 데이터를 만들어야 하는데, 어디선가 데이터를 불러올 수도 있겠지만, 여기서는 가상의 데이터를 만들도록 하겠다.
torch.randn(100, 2)함수로 shape이 (100, 2) (또는 torch.Size([100,2])인 torch tensor를 만들었다. 각각의 값은 0부터 1 사이의 임의의 값이고, 여기에 10씩 곱한 다음, 첫번째 열에는 25씩을, 두번째 열에는 70씩을 더했으니, X_features는 첫번째 열에는 평균이 25이고 편차가 10인 100개의 값이 들어있고, 두번째 열에는 평균이 70이고 편차가 10인 100개의 값이 들어있게 된다.
X_features는 모델에 입력으로 사용될 값들이고, 이제 출력으로 사용될 키에 대한 값들도 만들어 보자.
모양을 굳이 한 차원 더 만들어서 표시하는 데에는 앞으로 만들 인공신경망이 그렇게 원하기 때문이겠지. 그리고 확장성을 생각하더라도 출력이 1개만 있는 경우보다는 n개가 있는 경우들도 많을텐데, 그 모양을 (샘플개수, 출력개수)로 만들어 두는 것이 일반적일 것임.
아, 참고로 데이터의 모양을 표시하는 방법으로 numpy에서 사용하는 튜플 형태 (M, N)이 표시하는데 더 좋아서 그걸 선호해서 쓰겠음... pytorch에서는 위에서 보는 것과 같이 shape을 출력시켜 보면, torch.Siez([ ]) 형태로 출력해준다. 그게 더 명확하긴 한데.. 여튼 설명에는 좀 귀찮다;; ㅎㅎ;
from torch import nn
뉴럴네트워크 사용하기
설명은 Gemini의 주석달기 기능으로 달아두었다. ㅎㅎ
# ----------------- 1. 모델 클래스 정의 -----------------
class HeightPredictor(nn.Module):
# 이 모델 클래스는 nn.Module의 모든 기능을 상속받습니다.
# 모델의 '뼈대'와 '부품'을 준비하는 단계입니다.
def __init__(self, input_size=2, hidden_size=64, output_size=1):
# nn.Module의 초기화 기능을 먼저 실행합니다. (필수!)
super().__init__()
# nn.Sequential: 계층(Layer)들을 일렬로 순서대로 쌓아주는 컨테이너입니다.
self.regressor_stack = nn.Sequential(
# nn.Linear (선형 계층): Wx + b 연산을 수행합니다.
# input_size=2 (나이, 몸무게)를 64개의 은닉 뉴런으로 변환합니다.
nn.Linear(input_size, hidden_size),
# nn.ReLU (활성화 함수): 비선형성을 추가하여 모델의 표현력을 높입니다.
# ReLu는 x가 0보다 크면 x를 그대로, 0보다 작으면 0을 출력합니다.
nn.ReLU(),
# 은닉 계층 2: 64개의 뉴런 -> 64개의 뉴런으로 다시 변환합니다. (깊이를 추가)
nn.Linear(hidden_size, hidden_size),
nn.ReLU(),
# 최종 출력 계층: 64개의 뉴런 -> output_size=1 (키 예측값)으로 출력합니다.
# **회귀(Regression) 문제이므로, 마지막에 활성화 함수는 사용하지 않습니다.**
nn.Linear(hidden_size, output_size)
)
# 2. 데이터의 흐름 정의 (모델을 통해 데이터가 흘러가는 계산 과정)
def forward(self, x):
# 입력 데이터 텐서(x)를 위에서 정의한 계층 스택에 통과시킵니다.
predicted_height = self.regressor_stack(x)
# 계산된 최종 예측값(키) 텐서를 반환합니다.
return predicted_height
Keras로 먼저 공부를 했던 터라, Sequential이라는 용어가 뭔가 친숙하다. 물론 방식은 다르지만, 뭐 층층히 layer와 활성함수가 추가되는 구조가 직관적이다.
여기서 특이? 특별?한 점은 forward 함수를 따로 만들었다는 점이다. keras에서는 모델명.predict(입력)으로 결과를 얻었는데, 여기서는 클래스명.forward(입력)으로 결과를 얻겠네. 물론 클래스를 정의하기 나름이겠지만, 일단 Gemini가 가르쳐 주는 대로 따라가 보자.
# ----------------- 2. 모델 사용 예시 -----------------
# 모델 인스턴스 생성: __init__ 함수가 실행됩니다.
model = HeightPredictor()
# 가짜 입력 데이터 (Batch size: 5, Features: 2)
# torch.randn(5, 2)는 (5, 2) 모양의 표준 정규 분포 난수 텐서를 만듭니다.
dummy_batch = torch.randn(5, 2)
# forward() 실행: model(입력)을 호출하면 자동으로 forward 메서드가 실행됩니다.
predictions = model(dummy_batch)
# 결과 출력
print("### 모델 구조 (nn.Module이 자동으로 관리하는 계층): ###")
print(model)
print("-" * 30)
print(f"입력 데이터 모양: {dummy_batch.shape}") # (5, 2)
print(f"출력 예측값 모양: {predictions.shape}") # (5, 1) - 5개 샘플의 키 예측값
print(prediction)
모델이 어떻게 생겼는지, 입력과 출력이 어떤 모양을 갖는지 볼 수 있다. 참고로 여기서 dummy_batch는 정말 아무런 값을 만들어 (실제 입력 범위랑도 다름) 넣었고, 모델도 학습되지 않은 상태이기 때문에 아무런 출력을 내뱉었다. ㅎㅎ 일단 모양만 눈여겨 보자.
import torch.optim as optim
# 1. 모델 인스턴스 (Model Instance)
model = HeightPredictor() # Step 4에서 정의한 모델
# 2. 손실 함수 (Loss Function)
loss_fn = nn.MSELoss()
# 3. 옵티마이저 (Optimizer)
optimizer = optim.Adam(model.parameters(), lr=0.01)
print("모델 학습을 위한 준비 완료: Model, Loss Function, Optimizer 설정 완료!")
이제 손실함수와 옵티마이저 설정을 마쳤다. 자꾸 Keras랑 비교하게 되긴 하지만... (당분간은 어쩔 수 없다 나는 그게 더 익숙한 몸이니...) 손실함수랑 옵티마이저를 바깥에다가 따로 준비시켜 두네.
# 필요한 모듈 및 설정 불러오기
import torch
from torch import nn
import torch.optim as optim
import matplotlib.pyplot as plt # 시각화를 위한 Matplotlib 라이브러리
# (이전 Step 4 & 5에서 정의했던 모델, 데이터, 손실 함수, 옵티마이저 설정은 동일)
# ... (model, X_features, y_labels, loss_fn, optimizer 정의 부분 생략)
# ----------------------------------------------------
# 학습할 에포크(Epoch) 횟수 설정
num_epochs = 1000
# 손실 값을 기록할 리스트 준비
loss_history = []
print(f"모델 학습 시작: {num_epochs} 에포크")
for epoch in range(num_epochs):
optimizer.zero_grad()
predictions = model(X_features)
loss = loss_fn(predictions, y_labels)
# 현재 손실 값을 리스트에 기록
loss_history.append(loss.item())
loss.backward()
optimizer.step()
if (epoch + 1) % 100 == 0:
print(f"Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}")
print("모델 학습 완료!")
1000번 학습 완료.
import matplotlib.pyplot as plt # 시각화를 위한 Matplotlib 라이브러리
# 손실 그래프 그리기 (Plotting the Loss)
plt.figure(figsize=(10, 6)) # 그래프 크기 설정
plt.plot(loss_history, label='Training Loss', color='blue') # 기록된 손실 값으로 그래프 생성
plt.title('Training Loss over Epochs') # 그래프 제목
plt.xlabel('Epoch') # X축 라벨
plt.ylabel('Mean Squared Error (MSE) Loss') # Y축 라벨
plt.grid(True) # 그리드 표시
plt.legend() # 범례 표시
plt.show() # 그래프 출력
손실값 그래프도 출력
뭔가 더 돌리면 더 줄어들 수도 있을 것 같은데, 1000번 더 돌려본다.
1000번 더?
이제 더 학습시키는 건 의미가 없어 보인다.
이제 모델을 이용해 예측을 해보자.
# 새로운 가상 데이터 (나이 30세, 몸무게 80kg)로 예측
new_input = torch.tensor([[30.0, 80.0]], dtype=torch.float32)
# **평가 모드 설정 (Evaluation Mode)**
# 드롭아웃(Dropout) 등 학습 때만 필요한 기능들을 비활성화합니다.
model.eval()
# 미분 추적 비활성화 (Disable gradient tracking)
# 예측할 때는 미분 계산이 필요 없으므로 메모리 절약 및 속도 향상을 위해 사용합니다.
with torch.no_grad():
predicted_height = model(new_input)
# 원래 데이터 생성 공식에 따르면 예상 키는 (30 * 0.5) + (80 * 1.5) + 50 = 15 + 120 + 50 = 185cm 근처
print(f"\n입력 (나이, 몸무게): (30, 80)")
print(f"예측된 키: {predicted_height.item():.2f} cm")
185cm 근처로 값이 나왔다.
다음 질문,
우리는 현재 모델이 일반화된 특징을 학습했다고 할 수 있을까?
1. 3000회를 학습시키면서 사실상 오버피팅을 의도했다.
2. 검증 데이터가 없었다.
그래서 Gemini한테 시켜서 다음의 코드로 그래프를 찍어보았다.
import torch
import matplotlib.pyplot as plt
import numpy as np
# -------------------- Step 4, 6에서 사용된 변수 가정 --------------------
# model: 학습이 완료된 HeightPredictor 모델 인스턴스
# X_features: (100, 2) 모양의 입력 데이터 텐서 (나이, 몸무게)
# y_labels: (100, 1) 모양의 실제 키 레이블 텐서
# ----------------------------------------------------------------------
# 모델을 평가 모드로 전환 (Dropout 등 비활성화)
model.eval()
# 미분 추적 비활성화 (메모리 및 속도 최적화)
with torch.no_grad():
# 1. 모델의 예측값 얻기
# 모든 학습 데이터 X_features에 대한 모델의 예측을 수행합니다.
predicted_y = model(X_features)
# 2. 텐서를 NumPy 배열로 변환 (Matplotlib 사용을 위함)
# CPU 텐서로 변환 (.cpu()) 후, NumPy 배열로 변환 (.numpy())
X_numpy = X_features.cpu().numpy()
y_numpy = y_labels.cpu().numpy()
predicted_numpy = predicted_y.cpu().numpy()
# 3. 모델 수식 그래프를 위한 가상 데이터 생성 (몸무게만 변화)
# 몸무게 범위를 설정하고, 나이는 평균 나이(25.0)로 고정하여 예측 라인을 그립니다.
weight_range = np.linspace(X_numpy[:, 1].min(), X_numpy[:, 1].max(), 100).reshape(-1, 1)
# 나이는 평균값(25.0)으로 고정하여 (100, 2) 모양의 입력 텐서 생성
mean_age = torch.full((100, 1), 25.0)
test_weights = torch.tensor(weight_range, dtype=torch.float32)
# 모델 입력 텐서: (100, 2) 모양의 [고정된 평균 나이, 변화하는 몸무게]
input_for_line = torch.cat((mean_age, test_weights), dim=1)
# 모델 예측
line_predictions = model(input_for_line)
line_numpy = line_predictions.cpu().numpy()
# 4. 실제 수식 라인 (Ground Truth Formula) 계산
# y = (나이 * 0.5) + (몸무게 * 1.5) + 50
true_line = (25.0 * 0.5) + (weight_range * 1.5) + 50
# -------------------- 5. 그래프 시각화 --------------------
plt.figure(figsize=(12, 7))
# 1. 실제 데이터 포인트 (Actual Data Points) - 산점도 (Scatter Plot)
# X_numpy[:, 1]는 몸무게(두 번째 특징)를 의미합니다.
plt.scatter(X_numpy[:, 1], y_numpy, color='skyblue', alpha=0.6, label='Actual Data Points (Weight vs. Height)')
# 2. 모델이 예측한 라인 (Learned Model Prediction Line)
# 모델이 학습한, 나이를 25세로 고정했을 때의 키 예측 라인
plt.plot(weight_range, line_numpy, color='red', linewidth=3, label='Learned Model Prediction Line (Age=25)')
# 3. 실제 데이터 생성 수식 라인 (Ground Truth Formula Line)
# 데이터가 생성된 실제 수식의 라인
plt.plot(weight_range, true_line, color='green', linestyle='--', linewidth=2, label='Ground Truth Formula (Age=25)')
plt.title('Height Prediction Visualization: Model vs. True Formula')
plt.xlabel('Weight (kg) [Input Feature]')
plt.ylabel('Height (cm) [Predicted/Actual Value]')
plt.legend()
plt.grid(True)
plt.show()
25세 기준으로 몸무게와 키 그래프를 확인해보았다. 생각보다 오버피팅이 덜하다. ㅎㅎ 아마도 우리 모델 자체가 파라메터 수가 많지 않아서였거나, 데이터 자체에 랜덤 값을 충분히 넣어둬서 였을지도.
그나저나 위 시각화 코드도 한 번 공부해봐야 할텐데...
모델을 평가 모드로 전환하는 부분과, with torch.no_grad():를 통해 미분 추적 비활성화를 하는 부분은 눈여겨볼만하다. 그리고 torch tensor에서 numpy 배열로 바꿔줘야 matplotlib이 사용가능한데, 이거 은근 번거롭겠네. (물론 이젠 인공지능이 다 알아서 해주겠지만..;; ㅎㅎ;;) 아, 그리고 numpy는 cpu 기반이라고 하네. 그래서 torch tensor에서 cpu()를 호출한 뒤에 바꾸네. 이것도 익숙해져야 할듯.
그러고보니 앞서 모델 학습시킬 때 특별히 GPU 사용 여부를 설정하지 않았는데, GPU를 사용하고 싶다면 아래와 같이 해야 한다고 Gemini가 알려준다. (맞겠지 ㅎㅎ. 나중에 무거운 모델 학습시킬 때 확실히 비교해볼 수 있겠다.)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"사용할 장치: {device}")
model = HeightPredictor()
X_features = ... # 데이터 텐서
y_labels = ... # 레이블 텐서
model.to(device)
X_features = X_features.to(device)
y_labels = y_labels.to(device)
이런 식으로 GPU 장치 확인 후 모델과 입출력 데이터를 to(device)로 보내면 된다고 한다. 순전파, 손실 계산, 역전파 코드는 그대로 사용해도 됨.
다음 글은 MINIST 데이터와 CNN 모델로 손글씨 데이터 분류하는 예제를 만들어봐야겠다.
이런 식으로 numpy array에서 torch tensor를 만들 수도 있다. 출력 결과는 같다.
x_ones = torch.ones_like(x_data) # x_data와 같은 모양이지만, 모든 값이 1인 텐서
x_rand = torch.rand_like(x_data, dtype=torch.float) # x_data와 같은 모양이지만, 무작위 값(0~1)을 가지는 Float형 텐서
같은 모양의 one tensor 또는 random 값을 갖는 tensor도 만들 수 있다. 다 필요가 있는 것이겠지.. ㅎㅎ
텐서의 주요 속성
텐서를 생성한 후에는 그 텐서의 크기(모양)나 자료형을 확인하는 것이 중요
모양(shape)
자료형(data type)
텐서가 저장된 장치(device)
# 텐서의 속성 확인 (Checking the tensor's attributes)
print(f"텐서의 모양 (Shape): {x_data.shape}")
print(f"텐서의 자료형 (Data Type): {x_data.dtype}")
print(f"텐서가 저장된 장치 (Device): {x_data.device}") # 현재는 CPU일 것입니다.
numpy array에서처럼 shape을 잘 보는 게 좋을텐데 shape 정보를 불러오는 형태는 같네. 자료형은 속성명을 dtype으로. 그리고 사실 device는 numpy에서는 못보던 속성이다. 이 속성은 잘 기억해둬야겠다. 그리고 출력 결과를 보니 다 torch에서 정의한 정보들(torch.Size([]), torch.int64 등)이라고 나오네.
텐서 연산 및 GPU 사용
Pytorch를 사용하는 핵심 이유 중 하나가 GPU를 활용하여 연산 속도를 극대화할 수 있기 때문이라고.
그래서 그런지 연산과 관련해서 그냥 흔히 쓰는 연산자 말고 torch에서 제공하는 함수를 쓰는 게 좋을 것 같다.
# 행렬 곱셈 (Matrix Multiplication - Dot product)
# tensor는 (2, 2) 모양, y는 (2, 2) 모양입니다.
z_matmul_1 = tensor.matmul(y)
z_matmul_2 = tensor @ y # 파이썬에서 행렬 곱셈을 위한 연산자 (Operator for matrix multiplication)
print(f"행렬 곱셈 결과 (z_matmul_1):\n{z_matmul_1}")
print(f"행렬 곱셈 결과 (z_matmul_2):\n{z_matmul_2}")
행렬 곱셈(내적)도 ㅇㅋ
이제 GPU를 써보자.
# GPU (CUDA) 사용 가능 여부 확인 (Check if GPU (CUDA) is available)
if torch.cuda.is_available():
device = torch.device("cuda")
print("GPU (CUDA)를 사용할 수 있습니다.")
else:
device = torch.device("cpu")
print("GPU를 사용할 수 없으므로 CPU를 사용합니다.")
아직은 Google Colab을 쓰고 있는데, 거기도 runtime 유형을 GPU로 선택하면 GPU를 사용가능하다고 나온다. 나중에 내 데스크톱에서도 하게 된다면 꼭 확인해볼 코드.
이제 torch tensor를 GPU로 이동해보자. (아까는 device 속성이 CPU였음)
# CPU에 있는 텐서 (Tensor currently on CPU)
x_data = torch.tensor([[1.0, 2.0], [3.0, 4.0]]) # 주의: GPU로 옮기려면 보통 float 타입 사용
# 텐서를 설정된 장치(device)로 이동 (Move the tensor to the configured device)
x_data_on_device = x_data.to(device)
print(f"원래 텐서의 장치: {x_data.device}")
print(f"이동된 텐서의 장치: {x_data_on_device.device}")
# GPU 텐서에 대한 연산도 동일하게 수행됩니다. (Operations on GPU tensors are performed the same way.)
z_gpu = x_data_on_device @ x_data_on_device
print(f"GPU에서 수행된 행렬 곱셈 결과:\n{z_gpu}")
뭐 잘 된 거겠지. ㅎㅎ 이정도 계산으로 차이를 체감할 수는 없을 테고. 특별한 건, torch tensor를 출력할 때 device 속성이 같이 출력된다는 점. CPU로 할 때는 안 보였었는데. 여튼 이렇게 다 확인해보니 마음이 편하다.
그나저나 GPU를 쓰고 싶으면 이렇게 다 모든 torch tensor에 대해 device 속성을 바꿔주고 해야 하는 건가..? 함수로 퉁치는 건 안되나... 뭐 일단 그건 나중에 더 알게 되겠지.
자동 미분 및 Requires Grad 이해하기
Keras로 인공지능 모델을 만들 때는 이런 것까지 공부하지는 않았던 것 같은데,
커스터마이징을 할 때는 필요할 수도 있다고 해서 일단 이 내용도 공부는 해본다.
어차피 내용이 많아보이지는 않고, 이것도 따지고 보면 진짜 미분 계산을 내가 해야 하는 게 아니고 그냥 연산 과정을 추적하게 하는 걸 허용하겠다 말겠다 정도의 수준이니 살펴는 보자.
torch tensor가 생성될 때 requires_grad=True로 설정되면 이 텐서에 대해 수행되는 모든 연산을 추적하기 시작하고, 이는 미분 값을 계산하는 데 필요한 정보를 저장함.
import torch
# requires_grad=True로 설정된 텐서 생성
x = torch.tensor(3.0, requires_grad=True)
y = torch.tensor(4.0, requires_grad=True)
# requires_grad가 False인 텐서 (기본값)
a = torch.tensor(5.0)
print(f"x의 requires_grad: {x.requires_grad}")
print(f"a의 requires_grad: {a.requires_grad}")
순전파(Forward Pass)는 입력 데이터가 모델(수식)을 통과하며 출력을 만들어내는 과정이며, 이 과정에서 Autograd가 연산 그래프르 구축한다...는데 Autograd가 뭔지는 굳이 알지 않아도 될 것 같다. requires_grad를 True로 하고 나중에 grad 속성으로 미분 값을 확인한다는 정도만 알면 될듯.
# 순전파: z = (x^2) + 2y
z = x**2 + 2 * y
print(f"순전파 결과 z: {z}")
# 미분 값 계산: z를 x와 y로 미분
# z.backward()를 호출하면 z가 정의된 연산 그래프를 역순으로 거슬러 올라가며 미분값을 계산합니다.
# 주의: 스칼라 값(하나의 숫자)에 대해서만 .backward()를 직접 호출할 수 있습니다.
z.backward()
# 계산된 미분 값 (Gradient) 확인
# 미분값은 텐서의 .grad 속성에 저장됩니다.
# 1. z를 x로 미분: dz/dx = 2x
# x=3.0 이므로, dz/dx = 2 * 3.0 = 6.0
print(f"x의 미분 값 (dz/dx): {x.grad}")
# 2. z를 y로 미분: dz/dy = 2
# y=4.0 이더라도, dz/dy = 2
print(f"y의 미분 값 (dz/dy): {y.grad}")
이런 식으로 수식에서 해당 x 값에서의 편미분값, 해당 y 값에서의 편미분값을 얻을 수 있다. 신긔신긔