Machine Learning

이진 분류 with pytorch

해파리냉채무침 2024. 4. 11. 22:19

출처: 차근차근 실습하며 배우는 파이토치 딥러닝 프로그래밍

 

이전 선형 회귀 모델과 비교했을 때, 이진 로지스틱 회귀 모델은 시그모이드 함수가 새롭게 추가되었다. 

오버피팅이란 train data에서만 정확도가 높고, 다른 데이터에 대해서 정확도가 높지않은 것을 의미한다. 또한 오버피팅은 학습 횟수가 늘어날수록 train 데이터의 acc가 좋아지는 반면, 검증데이터의 정확도 좋아지지 않는다. 학습을 반복해도 향상되지 않는다.

검증(test) 데이터는 학습된 모델의 정확도를 평가하는 목적으로만 쓰인다.

시그모이드 함수는 항상 값이 증가하고(단조증가 함수), 0과 1사이의 값을 취하고, x=0 일때 값이 0.5, 그래프는 점 (0,0.5)기준으로 점대칭이다. 

sigmoid 변환값이 0.5보다 큰 경우, 예측결과는 1 반대로 0.5보다 작을 경우 예측 결과는 0으로 해석한다. 

이렇게 해석되는 이유는 경사하강법 알고리즘과 관련이 있는데 경사하강법은 파라미터 값에 아주 조금의 변화를 주었을 때, 손실함수가 가장 작아지는 방향으로 파라미터 값을 수정해 나간다. 그 전제 조건으로 아주 작은 파라미터 값의 변화에 대해 예측값도 마찬가지로 그 변화가 아주 작아야 한다. 1또는 0 으로 판명될 예측값을 일부러 도중 단계에서 확률값을 간주하는 것이다.

 

선형 회귀에서 2차함수 였던 손실함수는 분류모델에서 교차 엔트로피 함수를 사용한다. 손실함수에 최우 추정이라는 개념을 도입한다. 최우 추정이란 어떤 함수를 최대로 하는 파라미터를 찾아내는 것이다. 

이진 분류의 손실 함수는 nn.BCELoss로 표현한다.

class Net(nn.Module):
  def __init__(self,n_input,n_output):
    super().__init__()
    self.l1 = nn.Linear(n_input,n_output)
    self.sigmoid = nn.Sigmoid()

    self.l1.weight.data.fill_(1.0)
    self.l1.bias.data.fill_(1.0)

  def forward(self,x):
    x1= self.l1(x)
    x2 = self.sigmoid(x1)
    return x2

입력 텐서를 선형 함수에 적용한 결과에 시그모이드 함수를 다시 적용한 결과를 출력하고 있다.

net = Net(n_input, n_output)
print(net)

Net( (l1): Linear(in_features=2, out_features=1, bias=True) (sigmoid): Sigmoid() )

 

nn.Linear 뒤에 시그모이드 함수가 추가되어 있다는 점을 확인할 수 있다. 

 

criterion = nn.BCELoss()
lr = 0.01
optimizer = optim.SGD(net.parameters(),lr = lr)
inputs = torch.tensor(x_train).float()
labels = torch.tensor(y_train).float()
labels1 = labels.view((-1,1))
inputs_test = torch.tensor(x_test).float()
labels_test = torch.tensor(y_test).float()
labels1_test = labels_test.view((-1,1))

손실함수를 교차 엔트로피 함수로 정의하였고, SGD로 최적화를 진행했다.

입력데이터와 정답데이터를 텐서화 한수, 정답데이터는 N행 1열행렬로 변환하였다. 손실함수로 BCELoss를 사용할 경우, 두번째 인수로 들어갈 정답 데이터는 첫번째 인수(훈련데이터)와 shape 이 같아야하기 때문이다. 

계산 그래프를 보면 선형함수 계산 뒤에 시그모이드 함수, 교차 엔트로피 함수 계산을 거쳐 손실을 구하고 있다. 

# 학습률
lr = 0.01

# 초기화
net = Net(n_input, n_output)

# 손실 함수: 교차 엔트로피 함수
criterion = nn.BCELoss()

# 최적화 함수: 경사 하강법
optimizer = optim.SGD(net.parameters(), lr=lr)

# 반복 횟수
num_epochs = 10000

# 기록용 리스트 초기화
history = np.zeros((0,5))

앞장에서는 history 함수가 2열이였으나 여기서는 5열까지 늘어난다. 데이터를 훈련데이터와 검증 데이터로 나눴기 때문에 손실도 이에 따라 두번 계산해준다. 반복횟수 -> 훈련 loss 값 -> 훈련 acc 값-> 검증 loss값 -> 검증 acc값으로 나눠지기 때문이다. 이렇게 총 5열까지 늘어난다. 

for epoch in range(num_epochs):
    # 훈련 페이즈
    
    # 경삿값 초기화
    optimizer.zero_grad()

    # 예측 계산
    outputs = net(inputs)

    # 손실 계산
    loss = criterion(outputs, labels1)

    # 경사 계산
    loss.backward()
    
    # 파라미터 수정
    optimizer.step()

    # 손실 저장(스칼라 값 취득)
    train_loss = loss.item()

    # 예측 라벨(1 또는 0) 계산
    predicted = torch.where(outputs < 0.5, 0, 1)
    
    # 정확도 계산
    train_acc = (predicted == labels1).sum() / len(y_train)

    # 예측 페이즈

    # 예측 계산
    outputs_test = net(inputs_test)

    # 손실 계산
    loss_test = criterion(outputs_test, labels1_test)

    # 손실 저장(스칼라 값 취득)
    val_loss =  loss_test.item()
        
    # 예측 라벨(1 또는 0) 계산
    predicted_test = torch.where(outputs_test < 0.5, 0, 1)

    # 정확도 계산
    val_acc = (predicted_test == labels1_test).sum() / len(y_test)
    
    if ( epoch % 10 == 0):
        print (f'Epoch [{epoch}/{num_epochs}], loss: {train_loss:.5f} acc: {train_acc:.5f} val_loss: {val_loss:.5f}, val_acc: {val_acc:.5f}')
        item = np.array([epoch, train_loss, train_acc, val_loss, val_acc])
        history = np.vstack((history, item))

에폭만큼 반복을 진행한다. 예측-> 손실구하기-> backward 경사 계산 -> 파라미터 수정 -> loss 계산 -> 예측 라벨 계산 -> acc 계산 순으로 진행한다.

print(f'초기 상태 : 손실 : {history[0,3]:.5f}  정확도 : {history[0,4]:.5f}' )
print(f'최종 상태 : 손실 : {history[-1,3]:.5f}  정확도 : {history[-1,4]:.5f}' )

초기 상태 : 손실 : 4.49384 정확도 : 0.50000

최종 상태 : 손실 : 0.15395 정확도 : 0.96667

loss와 정확도가 크게 성능이 개선됨을 알 수 있다.

 

 

BCELoss 함수와 BCEWithLogitsLoss 함수의 차이

 BCEWithLogitsLoss 

class Net(nn.Module):
    def __init__(self, n_input, n_output):
        super().__init__()
        self.l1 = nn.Linear(n_input, n_output)
                
        # 초깃값을 모두 1로 함
        # "딥러닝을 위한 수학"과 조건을 맞추기 위한 목적        
        self.l1.weight.data.fill_(1.0)
        self.l1.bias.data.fill_(1.0)        
        
    # 예측 함수 정의
    def forward(self, x):
        # 입력 값과 행렬 곱을 계산
        x1 = self.l1(x)
        return x1

여기서는 nn.Sigmoid 함수부분이 생력되었다. 

# 학습률
lr = 0.01

# 초기화
net = Net(n_input, n_output)

# 손실 함수 : logits가 붙은 교차 엔트로피 함수
criterion = nn.BCEWithLogitsLoss()

# 최적화 함수 : 경사 하강법
optimizer = optim.SGD(net.parameters(), lr=lr)

# 반복 횟수
num_epochs = 10000

# 기록용 리스트 초기화
history = np.zeros((0,5))

BCELoss가 아닌 BCEWithLogitsLoss가 쓰였다. 

 

두 패턴의 차이는 다음과 같다.

BCEWithLogitsLoss를 시그모이드 함수를 예측 함수에서 손실함수로 이동시켰다. 한가지 주의해야할 점은 BCEWithLogitsLoss의 출력이 어떤 분류 대상에 속해있는지 판별하는 기준은 0.5보다 크거나 혹은 작거나가 아니라, 0보다 큰가 혹은 아닌가 이다. 모델의 출력이 sigmoid 함수의 입력값이고, 시그모이드 함수는 입력이 0보다 큰 경우에만 출력값이 0.5를 넘긴다.  

 

아래 반복처리는 다음과 같다.

for epoch in range(num_epochs):
    # 훈련 페이즈
    
    # 경삿값 초기화
    optimizer.zero_grad()

    # 예측 계산
    outputs = net(inputs)

    # 손실 계산
    loss = criterion(outputs, labels1)

    # 경사 계산
    loss.backward()
    
    # 파라미터 수정
    optimizer.step()

    # 손실값 스칼라화
    train_loss = loss.item()

    # 예측 라벨(1 또는 0) 계산
    predicted = torch.where(outputs < 0.0, 0, 1)
    
    # 정확도 계산
    train_acc = (predicted == labels1).sum() / len(y_train)

    # 예측 페이즈
    
    # 예측 계산
    outputs_test = net(inputs_test)

    # 손실 계산
    loss_test = criterion(outputs_test, labels1_test)

    # 손실값 스칼라화
    val_loss =  loss_test.item()
        
    # 예측 라벨(1 또는 0) 계산
    predicted_test = torch.where(outputs_test < 0.0, 0, 1)

    # 정확도 계산
    val_acc = (predicted_test == labels1_test).sum() / len(y_test)
    
    if ( epoch % 10 == 0):
        print (f'Epoch [{epoch}/{num_epochs}], loss: {train_loss:.5f} acc: {train_acc:.5f} val_loss: {val_loss:.5f}, val_acc: {val_acc:.5f}')
        item = np.array([epoch, train_loss, train_acc, val_loss, val_acc])
        history = np.vstack((history, item))

predicted = torch.where(outputs < 0.0, 0, 1) 여기부분을 이전 0.5로 한것과 다르게 설정하였다.