산점도는 좌표계 위에 점들을 표시하여 변수 간 관계를 나타내는 방법입니다. matplotlib에서는 pyplot.scatter로 사용할 수 있으며, scatter에서 데이터를 구분하기 위한 주요 시각화 요소로는 color(c), marker, size(s)가 있습니다. 실제로 적용할 수 있는 것들을 실습해보면서 연습해보겠습니다. 먼저 데이터 분석 공부할 때 많이 사용하는 붓꽃 데이터를 불러와서 준비하도록 하겠습니다.
위에서 준비한 데이터를 바탕으로 Sepal Length와 Sepal Width를 x,y 축으로 놓고, 종별로 색을 구분하고 Petal Width에 따라 사이즈를 구분해보겠습니다. 색은 label별, 사이즈는 petal width 값에 따라 변화하고, 겹치는 내용이 있을 수 있기 때문에 alpha 값을 통해 투명도를 조정했습니다.
import matplotlib.pyplot as plt
fig = plt.figure(figsize=(7, 7))
ax = fig.add_subplot(111)
for species in iris['Species'].unique():
iris_sub = iris[iris['Species']==species]
ax.scatter(x = iris_sub['sepal length (cm)'],
y = iris_sub['sepal width (cm)'],
s = iris_sub['petal width (cm)'] * 100, # 크기x100 키우기
alpha = 0.5, # 투명도 조정
label = species)
ax.legend()
plt.show()
실습 2. 여러 scatter plot 한번에 그리기
산점도는 두가지 변수에 대한 상관관계를 시각적으로 보기에 유리한 차트이지만, 여러 변수가 존재하는 경우 하나의 차트에 놓고 살펴보는 것이 좋습니다. 아래 코드는 여러 차트를 한꺼번에 for문을 통해 생성하는 코드입니다.
fig, axes = plt.subplots(4, 4, figsize=(14, 14))
features = iris.columns[:-1] # label만 제외
for i, f1 in enumerate(features):
for j, f2 in enumerate(features):
if i <= j :
axes[i][j].set_visible(False) # 중복 제거
continue
for species in iris['Species'].unique():
iris_sub = iris[iris['Species']==species]
axes[i][j].scatter(x=iris_sub[f2],
y=iris_sub[f1],
label=species,
alpha=0.7)
if i == 3: axes[i][j].set_xlabel(f2)
if j == 0: axes[i][j].set_ylabel(f1)
plt.tight_layout()
plt.show()
Matplotlib에서 선 그래프는 plot()을 통해 그릴 수 있습니다. plot()은 기본적으로 plot(x, y, fmt)으로 구성되며, fmt은 marker(데이터 표시), color(색), line(선 스타일)으로 구분됩니다. x, y는 별도 인자 선언없이 순서대로 넣어주면 되고, 나머지는 선언을 해주고 값을 입력하여 파라미터를 전달하는 과정이 필요합니다. 다만, plt를 통해 제공하고 있는 기능은 매우 다양하니, 필요할 때마다 찾아보는 습관이 필요합니다.
과학, 공학에서 x,y 축 외에 추가적인 축으로 설명이 필요한 경우가 있습니다. (예를 들어, 각도는 라디안과 ∘로 표기하고 그 값이 입력된 sin값 등) 이럴 경우 축을 추가할 수 있는데 ax 클래스에 secondary_xaxis, secondary_yaxis를 통해 추가 가능합니다. [1]
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(layout='constrained')
x = np.arange(0, 360, 1)
y = np.sin(2 * x * np.pi / 180)
ax.plot(x, y)
ax.set_xlabel('angle [degrees]')
ax.set_ylabel('signal')
ax.set_title('Sine wave')
def deg2rad(x):
return x * np.pi / 180
def rad2deg(x):
return x * 180 / np.pi
secax = ax.secondary_xaxis('top', functions=(deg2rad, rad2deg))
secax.set_xlabel('angle [rad]')
plt.show()
실습 3. 축 및 그래프 추가 (twinx, 보조축)
같은 x축을 공유하면서 맞은 다른 그래프를 추가하는 다른 방법도 존재합니다. twinx를 통해 x를 동일하게 공유하고, 같은데 ax에 추가하는 방법입니다. 아래와 같은 코드로 작성하면 됩니다만, 그래프를 추가할 경우 가독성이 떨어질 우려가 있으니 주의해서 사용할 필요가 있겠습니다.
import numpy as np
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
np.random.seed(97)
x = np.arange(20)
y1 = np.random.rand(20)
y2 = np.random.rand(20)
# 첫번째 시각화
ax.plot(x, y1, color='blue')
ax.set_ylabel('y1')
# 두번째(보조축) 시각화
ax2 = ax.twinx()
ax2.plot(x, y2, color='tomato')
ax2.set_ylabel('y2')
plt.show()
선형 분류(Linear Classifier)는 선형 회귀에 있던 것처럼 Wx+b의 선형 방정식 형태로 데이터를 분류하는 것을 말합니다. W를 바꾸게 되면 결정할 수 있는 기준(결정 경계)가 회전하게 되고, b를 조정하게 되면 결정 경계가 위/아래로 이동하게 됩니다. 비교적 단순하게 가중치 W와 편향 b만으로 데이터를 구분할 수 있습니다. 선형 기준(2차원에서는 직선, 3차원에서는 평면)에 따라 나뉘기 때문에 2가지 유형으로 분류가 가능합니다. (Binary Classifier) 하지만, 이 방법의 한계는 결정 경계를 기준으로 구분만 할 뿐 추가적으로 해석할 내용이 없습니다.
2. 로지스틱 회귀
로지스틱 회귀(Logistic Regression 또는 Logit Regression)는 데이터가 어떤 클래스에 속할지에 대한 확률을 계산해줍니다. 그리고 추정 확률이 50%가 넘을 경우 해당 데이터가 클래스에 속한다고 예측하는 모델입니다. 방법은 아래와 같이 진행합니다. [1]
1) 선형식에 대한 계산
2) 시그모이드 함수를 통해 계산값을 0~1 사이의 확률로 변환
3) 변환된 확률이 0.5를 기준으로 이상이면 1, 아니면 0으로 정의
위 방법에서 시그모이드 함수부터 살펴보겠습니다.
2.1. 시그모이드 함수
시그모이드 함수는 S자형 곡선(시그모이드 곡선)을 갖는 함수로, 실수 전체를 정의역으로 갖고 함수값은 단조증가(감소)하는 형태를 갖습니다. 대표적인 시그모이드 함수는 로지스틱 함수, 쌍곡탄젠트 함수, 아크탄젠트 함수 등이 있습니다. [2]
로지스틱 함수쌍곡탄젠트 함수아크탄젠트 함수
로지스틱 함수에 대해 조금 더 살펴보자면, 소프트맥스 함수의 특수한 상황이기도 합니다. 즉, 0/1로 이진 분류하는 경우에는 로지스틱 함수를 쓸 수 있지만 클래스가 더 많은 경우로 일반화하면 소프트맥스 함수를 생각해볼 수 있을 것 입니다.
소프트맥스 함수 [3]
2.2. 로지스틱 함수의 손실 함수
여러 시그모이드 함수 중 로지스틱 회귀에서 사용할 로지스틱 함수의 손실 함수에 대해 살펴보겠습니다. (머신러닝을 활용하려면 손실 함수가 필요함을 얘기한 적 있습니다 [4])
이전에 만들었던 선형회귀 모델[4]에서 손실함수와 모델에 시그모이드가 추가된 사실을 제외하면 거의 유사합니다. 이중에서 조금 다른 부분인 Sigmoid에 대해서만 살펴보고자 합니다.
3.1. Sigmoid in PyTorch
파이토치에서 sigmoid는 요소마다 로지스틱 함수를 적용하는 형태로 제공하고 있습니다. torch.nn.Sigmoid 클래스, torch.nn.functional.sigmoid 함수로 사용 가능합니다.
import torch
import torch.nn as nn
sigmoid = nn.Sigmoid()
input = torch.randn(2)
output = sigmoid(input)
3.2. 최종 모델
입력 데이터(x)와 레이블(y)을 제외하고는 아래와 같이 구현할 수 있습니다.
import torch.nn as nn
import torch.optim as optim
# 선형 회귀 모델 클래스
class BinaryClassification(nn.Module):
def __init__(self, input_dim, output_dim):
super(BinaryClassification, self).__init__()
self.layer_1 = nn.Linear(input_dim, output_dim)
self.sigmoid = nn.Sigmoid()
def forward(self, x):
y = self.layer_1(x)
z = self.sigmoid(y)
return z
model = BinaryClassificationRegression(1, 1)
# 손실함수
criterion = nn.BCELoss()
optimizer = optim.SGD(model.parameters(), lr=0.01)
# 모델 학습
# x,y 는 실제값으로 입력되어 있다고 가정
epochs = 100
for epoch in range(epochs):
y_hat = model(x)
loss = criterion(y_hat, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
선형 회귀(Linear Regression)는 종속 변수(y)와 하나 이상의 독립 변수(X, 설명 변수)의 상관관계를 설명하는 모델링을 하는 것으로 상관관계를 시각화하였을 때 선형으로 나타나기 때문에 '선형'이라 표현합니다. 선형회귀는 선형성, 독립성, 등분산성, 정규성이라는 4가지 가정을 두고 수행합니다. [1]
선형성 : 종속 변수와 독립 변수의 관계가 선형적 (y = wx + b)
독립성 : 데이터들이 무작위로 분포(독립적)되어 있어야 함. 만약 시간의 흐름에 따라 패턴이 있으면 독립성 x
등분산성 : 오차(잔차)의 분포가 일정
정규성 : 오차가 정규 분포를 따름
1.1. 모델 구성
선형 회귀는 출력(y)을 계산하기 위해 입력(x)에 가중치(A, 회귀 계수)를 곱하고 편향(bias)을 더해서 조정하는 방식으로 계산되며, 수식로 표현하면 다음과 같습니다
파이토치에서는 torch.nn.Module 상속을 통해 파이토치에서 이미 구현된 기능을 사용할 수 있습니다. 아래 코드는 선형 회귀 클래스(LinearRegression)를 만들어서 순전파까지 구현한 것입니다.
import torch.nn as nn
import torch.optim as optim
# 선형 회귀 모델 클래스
class LinearRegression(nn.Module):
def __init__(self, input_size, output_size):
super(LinearRegression, self).__init__() # 상속받은 nn.Module 초기화
self.linear = nn.Linear(input_size, output_size) # linear 속성 정의 : 선형 변환
def forward(self, x): # 순전파 연산 정의 : x를 입력 받아 linear 속성에 y를 계산
y = self.linear(x)
return y
model = LinearRegression(1, 1) # 모델 인스턴스 생성. (1, 1)
1. 2. 손실함수와 최적화
머신러닝에서 학습은 손실값을 줄이는 방향으로 파라미터를 업데이트 합니다. 선형 회귀 모델에서는 손실함수가 MSE가 되고, 파이토치에서는 torch.nn.MSELoss()로 구현할 수 있습니다. [2]
또한, 선형 회귀 모델에서 학습은 경사하강법을 사용하게 되는데, 전체 데이터셋에 대해서 적용하기 보다는 확률적 경사하강법으로 조금 더 효율적으로 이용이 가능합니다. 확률적 경사하강법은 아래와 같은 절차로 이뤄집니다.
1) 이전 단계에서 계산된 그래디언트 초기화
2) 손실 함수의 그래디언트 계산
3) 경사하강법 공식에 따라 파라미터 업데이트
또한, 확률적 경사하강법을 수행하는데 있어 같은 데이터셋을 여러 번 학습하여 모델 파라미터를 업데이트할 수 있습니다. 이렇게 모델이 전체 데이터셋을 학습하는 과정을 '에폭'이라고 하며, 여러 번의 에폭을 통해 모델의 성능이 향상될 수 있습니다.
criterion = nn.MSELoss() # 손실 함수 정의
optimizer = optim.SGD(model.parameters(), lr=0.01) # 최적화 정의
# 모델 학습
# x,y 는 실제값으로 입력되어 있다고 가정
epochs = 100
for epoch in range(epochs):
y_hat = model(x)
loss = criterion(y_hat, y) # 손실값 계산
optimizer.zero_grad() # 그래디언트 초기화
loss.backward() # 손실함수 그래디언트 계산 (역전파)
optimizer.step() # 계산된 그래디언트로 가중치 업데이트 : 위에서 SGD 정의로 최적화 진행
1.3. 최종 코드
이렇게 작성된 전체 코드는 아래와 같습니다. 입력값(x)과 목표값(y)은 별도로 정의해서 진행하면 됩니다.
import torch.nn as nn
import torch.optim as optim
# 선형 회귀 모델 클래스
class LinearRegression(nn.Module):
def __init__(self, input_dim, output_dim):
super(LinearRegression, self).__init__()
self.linear = nn.Linear(input_dim, output_dim)
def forward(self, x):
y = self.linear(x_tensor)
return y
model = LinearRegression(1, 1)
# 손실함수
criterion = nn.MSELoss()
optimizer = optim.SGD(model.parameters(), lr=0.01)
# 모델 학습
# x,y 는 실제값으로 입력되어 있다고 가정
epochs = 100
for epoch in range(epochs):
y_hat = model(x)
loss = criterion(y_hat, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
머신러닝은 데이터로부터 학습할 수 있도록 컴퓨터를 프로그래밍하는 것을 말합니다. 잘 설계된 머신러닝의 프로세스는 다음과 같이 정의할 수 있습니다. [1]
목표 설정 (Business Goal)
머신러닝 문제 정의 (ML Problem Framing)
데이터 처리 (Data Processing) : 수집, 전처리, 피처 엔지니어링 등
모델 개발 (Model Development) : 훈련, 평가, 튜닝
모델 적용 (Deployment)
모델 모니터링 (Monitoring)
이중에서 모델 개발에 좀 더 집중해서 살펴보려고 합니다. 모델 개발을 위한 여러가지 접근 방식이 있지만, 그 중에서 아래와 같은 방식으로 진행하는 것을 '모델 기반 학습(model-based learning)'이라고 합니다. [2]
1) 모델 설정
2) 무작위로 파라미터 설정
3) 설정된 파라미터에 따라 예측값 계산
4) 예측값과 실제값의 차이 계산 - 손실함수
5) 손실 값이 작아지도록 파라미터 업데이트 - 최적화
6) 충분히 작아질 때까지 3~5 반복
위 과정에서 손실함수와 최적화에 대해 조금 더 자세히 살펴보려고 합니다.
2. torch.nn
PyTorch에서는 이러한 모델 기반 학습을 지원할 수 있도록 계산 그래프를 쌓을 수 있는 torch.nn을 제공합니다. 아래 사진에서 보이는만큼 머신러닝에 필요한 여러가지 기능을 가진 계산 그래프를 제공하고 있기 때문에 실제로 모든 것을 구현할 필요는 없습니다.
torch.nn에서 제공하고 있는 다양한 블럭들 ('24.8.16 캡처)
특히, 손실함수는 torch.nn에 자체 클래스 또는 torch.nn.functional 함수로 제공하고 있고, 최적화는 torch.optim에 함수 형태로 이용 가능합니다.
3. 손실함수
손실함수는 머신러닝 모델이 얼마나 좋은지, 나쁜지를 정량화하는 함수로 모델을 통해 도출된 추정(예측)값과 기준(실제)값이 얼마나 다른지에 따라 모델에 페널티를 주는 정도를 다르게 합니다. 대표적인 손실함수는 MSE, Cross Entropy, 쿨백-라이블러 발산 등이 있습니다.
3.1. MSE
평균제곱오차(Mean Squared Error, MSE)는 추정값과 기준값의 차이의 제곱의 평균입니다. 일반적인 수식은 쉽게 찾아볼 수 있으니, 익숙해져야 하는 행렬식으로 표현하면 아래와 같습니다. 여기서 e는 오차들로 이뤄진 벡터입니다. [3]
PyTorch에서 MSE를 계산하는 방법은 nn모듈에서 MSELoss 클래스를 사용하거나, nn.functional의 함수를 사용하면 됩니다.
nn.MSELoss()
nn.functional.mseloss()
3.2. Cross Entropy
크로스엔트로피(Cross Entropy)는 정보이론에서 두 확률분포를 구분하는데 필요한 평균비트 수를 측정하는 것을 말합니다. 특별한 경우로 0/1과 같이 이진 문제에 대해서는 Binary Cross Entropy(BCE)가 존재합니다. BCE에 대한 정의는 아래와 같습니다. [4]
손실값이 가장 최소가 되는 순간은 미분적분 과목을 돌이켜볼 때 손실함수의 미분값, 즉 손실함수의 그래디언트가 0인 순간입니다. 최적화는 손실함수의 그래디언트가 0이 될 수 있도록 만드는 일련의 알고리즘으로 다양한 방식이 개발되어 왔습니다. 이 글에서는 경사하강법과 역전파를 우선 살펴보고자 합니다.
파이토치에서는 torch.optim 패키지를 통해 최적화 알고리즘을 지원하고 있습니다. torch.optim에 각 알고리즘 클래스를 해 적용이 가능합니다. 최적화는 아래 코드와 같이 일련의 과정이 필요한데, 각 코드의 세부 의미는 '4.2. 역전파'의 과정입니다. [5]
# Simple verison
for input, target in dataset:
optimizer.zero_grad() # 이전 단계에 계산된 그래디언트 초기화
output = model(input)
loss = loss_fn(output, target)
loss.backward() # 자동 미분 수행
optimizer.step() # 그래디언트 값을 통해 업데이트
# Clousre
for input, target in dataset:
def closure():
optimizer.zero_grad()
output = model(input)
loss = loss_fn(output, target)
loss.backward()
return loss
optimizer.step(closure)
4.1. 경사하강법
경사하강법(Gradient Descent)은 손실함수의 그래디언트(쉽게 말해 미분값)를 0이 되는 방향으로 계산하는 최적화 알고리즘입니다. 그림의 식은 경사하강법의 계산식이며,다음과 같은 방법으로 계산됩니다.
경사하강법의 계산식 [6]
1) 임의의 값(첫번째)의 그래디언트 계산
2) 그래디언트에 계수(학습률, learning rate)를 곱한 것을 그 값에서 빼기
3) 뺀 값을 다음 값으로 1~2과정을 그래디언트가 0이 될 때까지 계산
하지만, 일반적으로 전체 훈련 세트를 대상으로 위의 알고리즘을 수행하는 것은 (데이터가 많다면) 매우 시간이 많이 걸릴 수 있습니다. 이를 해결하기 위해 제안된 확률적 경사하강법(Stochastic Gradient Descent, SGD)은 무작위로 샘플을 선택하고 그 샘플에 대해 그래디언트를 계산하는 것입니다. 파이토치 코드는 아래같이 구현됩니다. (세부 설명 링크)
torch.optim.SGD(params, lr, ...)
4.2. 역전파
오늘날의 신경망 모델들은 여러 연산(선형 방정식, 비선형 활성화 함수 등)들을 겹겹이 쌓은 계산 그래프를 네트워크 형태로 구축하고 있습니다. (다층 퍼셉트론이라고도 합니다.) 경사하강법은 하나의 손실함수에 대해서는 최적화가 가능하지만, 이렇게 쌓여있는 다층 퍼셉트론을 훈련하기에는 연산이 너무 많아질 수 있기 때문에 최적화 알고리즘으로는 적절하지 않습니다.
이러한 문제를 해결하기 위해 제안된 역전파(Backpropagation)는 업그레이드된 경사하강법으로 출력값을 계산하는 정방향(forward), 오차 그래디언트를 계산하는 역방향(backward) 두가지 방향의 연산을 수행해 최적화합니다.
1) 임의의 값에 대한 예측값을 계산 (정방향)
2) 계산 그래프의 역으로 그래디언트를 곱하고 값을 대입하여 각 단계의 그래디언트 계산
3) 2)를 반복하면서 최종 단계인 손실함수의 그래디언트까지 계산 (역방향)
4) 계산된 그래디언트 값으로 경사하강법을 수행해 값 업데이트
(사실 이 과정을 글로 이해하는 것보다는 계산 그래프를 보면서 이해하는 것이 훨씬 나은데, 스탠포드 CS231n 강의자료가 제일 잘 이해되는 것 같아서 링크를 참고하시기 바랍니다.[7])
역전파는 각 노드별로 다양한 연산을 만들어야 하기 때문에 꽤 구조화된 프로그래밍이 필요합니다. 하지만, 파이토치에서는 아래와 같이 자동미분 연산을 지원하고 있습니다.
pred = model(data) # 정의된 model에 따른 예측값 (Forward pass 수행)
loss = (pred - label).sum() # 별도 정의한 손실함수 MSE, BCE 등 다른 것을 사용해도 됨
loss.backward() # Backward pass 수행
torch.optim.SGD(param, lr,...).step() # 경사하강법 수행. 결과값은 .grad에 저장
텐서를 이루는 요소들의 표준편차와 분산을 구하기 위해서 앞에서와 동일하게 메서드(var, std)를 사용할 수 있다. dim에 특별한 값을 넣지 않은 경우 전체 요소들의 계산 값을 반환한다. 표준편차와 분산에서 알아둘 것은 표본(텐서)을 대상으로 계산한다. ([1] & [2])
텐서끼리 연산은 산술연산( +, -, x ), 요소 간 비교(<, >, ==), 행렬곱이 있다. 각각 연산에 대해 세부적으로 연산 결과가 어떻게 되는지 살펴보려고 한다.
2.1. 산술연산
텐서 사이에서 덧셈, 뺄셈, 곱셈, 나눗셈, 거듭제곱 등을 지원한다. 파이썬의 기본연산(+, -, *, /)도 가능하지만, torch 메서드를 통해서도 지원하고 있다. 차원이 다른 스칼라나 벡터들에 대해서도 Broadcasting을 통해 계산이 가능하다.
a = torch.arange(1,5).reshape(2,2)
b = torch.arange(5,9).reshape(2,2)
c = 3
d = torch.tensor([1, 2])
print(a+b)
print(torch.add(a, b))
print(torch.add(a, c))
print(torch.add(a, d))
또한, 연산에서 알아둬야 할 것 중에 하나는 in-place 알고리즘(제자리 연산)이다. 명칭처럼 메모리를 추가로 할당하지 않고, 기존 메모리에서 제자리 연산을 수행한다는 점이다. in-place 연산은 메서드에 언더바( _ )를 추가하면서 수행할 수 있다. [3]
in-place 연산 수행 방법은 Tensor.add(Tensor) 형태로 수행 가능하다. 여기서 알아둘 점은 in-place 연산은 추가 할당이 없어 메모리 효율적이지만, 후에 배울 autograd와 호환 문제가 발생할 수 있어서 실제 사용에서는 주의가 필요하다.
a = torch.arange(1,5).reshape(2,2)
b = torch.arange(5,9).reshape(2,2)
print(id(a))
print("address(a) : ", id(a))
print("address(in-place addition) : ", id(a.add_(b)))
더하기 메서드인 add 이외에도, 빼기(sub), 곱하기(mul), 나누기(div), 제곱(pow)를 통해 계산이 가능하며, 또한 in-place 연산 지원을 위해 sub_, mul_, div_, pow_ 역시 가능하다.
2.2. 비교연산
텐서는 각각 요소들을 비교하는 연산도 존재한다. torch의 메서드(eq, ne, gt, ge, lt, le)를 활용하며, 결과는 Boolean Tensor를 출력한다.
# 비교연산
a = torch.tensor([1, 2, 3, 4])
b = torch.tensor([1, 3, 3, 5])
# == (equal)
print(torch.eq(a, b))
# != (not equal)
print(torch.ne(a, b))
# > (greater than)
print(torch.gt(a, b))
# >= (greater than or equal)
print(torch.ge(a, b))
# < (less than)
print(torch.lt(a, b))
# <= (less than or equal)
print(torch.le(a, b))
2.3. 논리연산
텐서 요소들의 Boolean 값을 계산하는 논리연산도 지원한다. 논리연산의 기본은 and, or, not, xor, not을 지원하고 있으며, 이러한 논리연산을 잘 조합하면 NAND, NOR 같은 연산도 만들 수 있다. 참고로 논리연산의 결과값인 진리표를 참고하여 논리연산을 만들면 될 것이다. [4]
# 논리연산
x = torch.tensor([True, True, False, False])
y = torch.tensor([True, False, True, False])
print(torch.logical_and(x, y)) # AND
print(torch.logical_or(x,y)) # OR
print(torch.logical_xor(x,y)) # XOR
print(torch.logical_not(x)) # NOT
print(torch.logical_and(x,y).logical_not()) # NAND
2.4. 행렬 곱
앞에서 수행했던 스칼라 곱이나, element-wise 곱과 다르게 행렬의 일반적인 곱셈은 다르다. [5] 이러한 행렬 곱을 지원하는 방법은 tensor.matmul(Tensor1, Tensor2), Tensor1.matmul(Tensor2), @ 를 통해 가능하다.
# Matrix Multiplication
X = torch.randn(2,3)
Y = torch.randn(3,4)
print(torch.matmul(X, Y))
print(X.matmul(Y))
print(X @ Y)
3. 텐서의 노름(norm)
수학에서 노름(Norm, 정확한 발음은 아니지만 통상적으로 많이 쓰이는 표현인 '노름' 사용)은 벡터들이 모인 공간에서의 거리를 말한다. 벡터들이 모인 공간에서 차원에 따라 거리의 정의는 다르게 표현되는데, 표현 방식은 아래 위키 링크를 통해 확인하는 것으로 충분할 것 같다.
우선 코드들을 실행하기 위한 PyTorch를 불러오고, 사용하고 있는 Python과 PyTorch 버전은 아래와 같다.
import torch
import sys
print("Python version : ", sys.version)
print("PyTorch version : ", torch.__version__)
2. 텐서 모양 바꾸기
2.1. view 와 reshape
텐서 모양을 바꾸는 대표적인 메서드는 view()와 reshape()이 있다. 두 메서드 모두 Tensor.view(Size) / Tensor.reshape(Size) 형태로 메서드를 사용하며, 조작하고 싶은 텐서의 사이즈를 입력받아 텐서의 모양을 바꿔준다. 다만, view는 메모리가 인접하게 연속적(contiguous)이어야만 사용이 가능하다.
딥러닝을 수행하기 위해 입력 데이터의 차원을 맞춰줘야 하는 경우가 발생한다. 이럴 때 torch.flatten(Tensor, start, end) 메서드를 활용해 평탄화를 수행하면 view나 reshape 외에 모양변경을 수월하게 수행할 수 있다.
# flatten
tensor_3d = tensor.reshape(2,2,3)
print(tensor_3d)
# 1차원으로 평탄화
print("1차원으로 평탄화 : \n", torch.flatten(tensor_3d))
# n 차원부터 마지막 차원까지 평탄화
n = 1
print("n차원부터 마지막 차원까지 평탄화 : \n", torch.flatten(tensor_3d, n))
# m 차원부터 n차원까지 평탄화
m = 0
n = 1
print("m차원부터 n차원까지 평탄화 평탄화 : \n",torch.flatten(tensor_3d, m, n))
2.3. squeeze와 unsqueeze
텐서의 여러 차원 중 사인즈가 1인 차원들에 대해 squeeze() 메서드를 통해 차원을 축소 가능하다. 사용방법은 Tensor.squeeze(d),torch.squeeze(Tensor, d) 모두 가능하다. 기본 값은 사이즈가 1인 모든 차원에 대해 적용하며, 특정 차원(값 또는 튜플)을 지정하면 해당 차원을 축소한다. 만약, 사이즈가 1이 아니면 차원이 축소되지 않는다.
# squeeze : size 1을 제거 (만약 없다면, 그대로 유지)
tensor_sq = torch.randn(1,1,4)
print("Squeeze 전 : \n", tensor_sq)
print("Squeeze 후 : \n", torch.squeeze(tensor_sq))
# 특정 차원 d에 대해서 squeeze : d는 int 또는 tuple
d = 0
print("d차원에 대해서 squeeze : \n", torch.squeeze(tensor_sq, d))
squeeze의 반대로 차원을 확장하고자 한다면, unsqueeze 메서드를 사용하면 된다. 사용 방법은 squeeze와 동일하며, 한 차원이 확장되는 효과를 볼 수 있다.
행과 열을 바꿔서 새로운 행렬을 만드는 전치 행렬(Transposed Matrix)이라는 개념이 있다.[1] 텐서에서도 특정 축끼리 바꿔서 만드는 텐서의 모양을 바꾸는 방법이 있다. Tensor.transpose(m, n)의 형태를 통해 사용이 가능하다.
# transpose
# m 차원과 n 차원 바꾸기
print("Transpose 전 : \n", tensor_3d)
m = 0
n = 1
print("Transpose 후 : \n",tensor_3d.transpose(m,n))
2.5. 텐서 연결 (stack, cat)
우선, 기존에 있던 차원은 그대로 유지하면서 텐서를 연결(붙이는) 방법은 torch.cat(Tensors, dim) 메서드를 활용하는 것이다. Tensors는 일련의 텐서들을 묶음(튜플, 리스트) 형태로 입력하고, dim에는 연결할 차원을 입력한다. cat을 사용하기 위해서는 크기가 같아야 한다.
# cat : 기존 차원에 따라 연결
print(torch.cat((x, y)))
print("Size : ", torch.cat((x, y)).size())
print(torch.cat((x, y), 1))
# 만약 크기가 다르다면, 맞춰주는 과정 필요
print(torch.cat((x, y[:1, :].reshape(2, 1)), 1))
서로 다른 텐서를 연결하는 방법 중 새로운 차원을 생성하면서 연결하려면 stack을 활용하여 torch.stack(Tensors, dim=)를 통해 사용한다. Tensors는 일련의 텐서들을 묶음(튜플, 리스트) 형태로 입력하고, dim은 새롭게 추가될 차원의 위치를 입력한다. stack 역시 사용하기 위해서는 모든 텐서들이 같은 사이즈여야 한다. [2]
크기가 1인 차원이 있는 텐서를 여러개를 붙여 확장하는 방법은 Tensor.expand(size) 형태로 사용한다. 사용하는 방법은 아래와 같이 정리할 수 있다.
1) 사이즈가 1이 아닌 차원은 그대로 유지 (-1을 대입하면 유지할 수 있음)
2) 사이즈가 1인 차원은 확장할만큼 사이즈를 대입
3) 새로운 차원으로 확대할 경우 sizes의 가장 앞에 값을 대입
# expand : tensor 차원 중 일부 크기가 1인 경우 해당 차원의 크기를 확장
x = torch.tensor([[1], [2], [3]])
print(x.shape)
print(x.expand(3, 4))
print(x.expand(3, 4).shape)
print(x.expand(2, -1, 4))
print(x.expand(2, -1, 4).shape)
차원이 1이라는 제한 조건이 있는 expand 대신에 repeat 메서드를 사용 가능하다. 해당 메서드는 Tensor.repeat(size)로 사용 가능하며, size에 입력된 차원 방향으로 입력된 숫자만큼 복제한다. repeat 메서드의 동작 방식은 데이터를 복사해서 확장하기 때문에 추가 메모리 할당으로 인해 메모리 효율성이 떨어지게 된다.[4]
# repeat
x = torch.tensor([1, 2])
print(x.repeat(3,2))
print(x.repeat(3,2).shape)
print(x.repeat(3,2,1))
print(x.repeat(3,2,1).shape)
PyTorch는 대표적인 Python을 위한 딥러닝 프레임워크로 Facebook에서 개발을 진행했다. Google에서 개발한 또 다른 프레임워크인 TensorFlow도 있는데, 둘 다 많이 사용된다. 실제로 갖고 있는 책(Hands-On Machine Learning)의 경우 TensorFlow를 기반으로 설명하고 있고, 현재 참여하고 있는 교육과정이나 다른 강의들에서는 PyTorch를 기반으로 설명하고 있다.
PyTorch의 대표적인 특징은 동적 계산 그래프(Define-by-Run)의 형태를 띈다는 점이다. '계산 그래프'는 일련의 연산 과정을 그래프로 나타난 것[2]이고, '동적'의 의미는 계산 그래프의 생성 단계와 훈련 데이터를 입력하고 손실함수를 계산하는 단계를 같이 진행한다는 뜻이다. Define-by-Run이 동작하는 방식에 대한 개요는 아래 그림과 같다. [3]
Define-by-Run 개요 [3]
Define-by-Run의 장점은 계산이 진행되면서 계산 그래프가 새롭게 생성하게 되기 때문에 유연하게 적용이 가능하다. [4]
1.2. 텐서의 정의
딥러닝을 배우다보면 텐서(Tensor)라는 개념이 많이 나온다. 학술적으로 엄밀하지는 않지만 위키피디아에서 텐서의 개념에 대해 살펴보면, 텐서는 다중선형대수학에서 다뤄지는 대상이다. [5] 이공계열 대학 교육과정에서 많이 배우는 선형대수학에서 다루는 벡터와 행렬에서 더 나아간 것이라고 생각할 수 있다.
0차원 : 스칼라
1차원 : 벡터
2차원 : 행렬
3+차원 : 텐서
2. 텐서의 생성
2.1. Version
우선 아래에서 나오는 코드는 PyTorch를 불러온 상태에서 실행 가능하므로 import를 통해 가져온다. Python과 PyTorch 버전은 아래와 같다.
import torch
import sys
print("Python version : ", sys.version)
print("PyTorch version : ", torch.__version__)
2.2. torch.tensor
텐서는 torch.tensor(데이터) 를 통해 생성 가능하다. 여기서 입력받는 '데이터'는 리스트, 튜플, ndarray(NumPy), 스칼라 값 등이 가능하다.
print(torch.tensor([1,2,3]))
또한, torch.tensor에서 입력할 수 있는 인자는 데이터 유형을 정할 수 있는 dtype, 기기를 할당하는 device 등이 있다.
모든 값이 0으로 이루어진 텐서는 torch.zeros(텐서 사이즈) 또는 torch.zeros_like(텐서)를 통해 만들 수 있다.
텐서 사이즈 : 생성하고 싶은 텐서의 사이즈 (예를 들어, 2 x 3 행렬을 원할 경우 2,3 입력)
텐서 : 어느 텐서와 같은 사이즈의 0 텐서를 생성하고 싶을 경우 입력
둘의 결과값은 거의 유사하지만, zeros_like는 입력받은 텐서의 dtype을 유지하고, zeros는 dtype 기본값 (보통 float32)을 따라가기 때문에 데이터 타입에 민감한 경우 주의해서 사용할 필요가 있다. 물론 메서드의 입력 인자로 dtype = 을 지정해주면 큰 차이 없이 사용할 수 있다.
모든 값이 1로 이뤄진 텐서를 만드는 torch.ones와 torch.ones_like는 0과 사용방법은 동일하다.
# tensor 생성
zero = torch.zeros(2,3)
one = torch.ones(2,3)
print(zero)
print(one)
# tensor를 입력받아 초기화된 tensor 생성
print(torch.ones_like(zero))
print(torch.zeros_like(one))
2.5. rand 텐서 생성
난수(random number) 생성은 0~1 사이 값으로 이뤄진 난수와 표준정규분포('n'ormal distributio)를 따르는 (크기는 매우 달라질 수 있음) 난수가 존재한다. 여기도 텐서 사이즈를 넣는 것과 텐서를 넣는 방법이 존재한다.
0~1 : torch.rand(사이즈), torch.rand_like(텐서)
표준정규분포 : torch.randn(사이즈), torch.randn_like(텐서)
# 난수 생성
print(torch.rand(3))
print(torch.randn(3))
print(torch.rand(2,3))
# tensor를 입력받아 난수 생성
print(torch.rand_like(zero))
print(torch.randn_like(zero))
2.6. torch.arange
파이썬에서는 range() 함수를 통해 특정 범위의 배열을 생성할 수 있다. PyTorch에서도 이와 유사하게 특정 범위의 텐서를 생성하는 arange(start, end, step)으로 생성할 수 있다.
empty 텐서는 torch.empty(사이즈)를 통해, empty_like(텐서)를 통해 초기화되지 않은(uninitialized) 텐서를 생성할 수 있다. empty 텐서의 효용은 0-텐서, 1-텐서처럼 초기화된 텐서를 생성하는데 사용하는 메모리를 절약할 수 있다는 점이다.
empty = torch.empty(2,3)
print(empty)
이렇게 만들어진 empty 텐서에 특정한 값을 넣는 메서드는 fill_가 있다. 해당 메서드는 별도의 반환값이 존재하지 않고 해당 변수에 값이 입력되는 연산이라 동일한 메모리를 갖는 것을 확인할 수 있다.
# id(obj) : 파이썬 내장함수로 해당 객체(obj)의 메모리 주소를 반환
print("empty id : ", id(empty))
print(empty.fill_(3))
print("empty.fill_ id : ", id(empty.fill_(3)))
2.8. 텐서 복사하기
때로는 똑같은 텐서를 새롭게 만들어서 사용해야 할 수 있다. 복사하는 방법은 clone()과 detach() 메서드 두가지가 있는데, 둘 사이의 중요한 차이점은 detach() 메서드는 기존에 사용하고 있던 계산 그래프에서 분리해서 저장한다는 점이다. 앞서 설명한 것처럼 PyTorch는 Define-by-Run 방식이기 때문에 계산 그래프에서 분리하는 것은 기존 계산 결과에서 분리할 수 있음을 의미합니다.
# 복사하기
a = torch.tensor([1,2,3])
b = a.clone()
print(b)
c = a.detach()
print(c)
2.9. 텐서 정보 확인하기
텐서에 담겨있는 데이터 이외에 연산을 수행하기 위해 중요한 정보 중 하나는 텐서의 사이즈입니다. 텐서의 사이즈를 보는 방법은 텐서의 속성 값인 .shape를 쓰는 방법과 메서드인 .size()를 사용하는 방법이 있습니다. 유사하게, 데이터 타입을 보는 방법도 속성값을 통해 확인할 수 있습니다.
a = torch.tensor([1,2,3])
print(a.size())
print(a.shape)
print(a.dtype)
딥러닝 프레임워크의 장점 중 하나는 GPU 연산을 쉽게 지원한다는 것입니다. PyTorch의 주요 객체들은 어느 device에 올라가는지를 입력받기 때문에 속성에 접근함으로써 디바이스를 확인할 수 있습니다. 실행 환경에서는 GPU를 지원하고 있지 않아서 False로 나오는 것을 확인할 수 있습니다.
print(a.device)
print(torch.cuda.is_available())
만약, GPU 지원을 할 경우 아래와 같은 코드를 통해 디바이스를 변경할 수 있습니다. 다만, to()와 cuda()의 미묘한 차이가 있기 때문에 주의는 필요합니다.[7]
# GPU 할당
a.to('cuda')
a.cuda()
# CPU 할당
a.to(device='cpu')
a.cpu()