K-최근접 이웃 분류기, 로지스틱 회귀(Logistic Regression)

Yoon1013·2023년 7월 25일
0
post-thumbnail

❓ 문제 정의

럭키 백에는 7 종류의 생선 중 한가지의 생선이 들어있다!
과연 럭키 백에 들어있는 생선은 7종류 생선 중 어떤 생선일지 확률을 구하자.
앞서 설명했던 이진분류와 달리 분류하고자 하는 클래스가 여러개인 다중분류(multi-class classification) 문제이다!
요약하자면,

  • 사용할 수 있는 데이터: 생선의 길이, 높이, 두께, 대각선 길이, 무게
  • 출력해야 하는 값: 확률

💽 데이터 준비

마찬가지로 판다스(pandas) 라이브러리를 사용하여 데이터를 불러오도록 한다.
데이터를 불러오고 .head() 메서드를 이용하여 데이터가 잘 불러졌는지 확인하는 습관을 들이도록 하자.

import pandas as pd
fish = pd.read_csv('https://bit.ly/fish_csv_data')
fish.head()


pandas를 이용하여 데이터프레임 형태로 데이터를 불러들였기 때문에 열 이름이 포함된 표 형식으로 출력된 것을 확인할 수 있다.

불러온 데이터에 대한 대략적인 정보를 확인해보도록 하자.

fish.info()


데이터가 총 159개 샘플로 이루어져 있으며 결측치는 없는 것으로 확인되었다.

describe() 메서드를 이용하면 수치 데이터에 대한 통계값을 확인해볼 수 있다.

fish.describe()


문자형 데이터인 'Species' 열을 제외한 나머지 5개 열의 통계값을 확인할 수 있다.
다른 열에 비해 'Weight' 열의 값이 다른 열에 비해 크고 표준편차도 크므로 모델에 입력할 때 정규화를 해주는 것이 좋겠다.

오브젝트 타입인 'Species' 열에서 고유 값을 추출하여 어떤 종류의 생선이 있는지 알아보도록 하자.

print(pd.unique(fish['Species']))

우리가 예측하고 싶은 값은 클래스에 속한 확률 값이므로 Species 열을 타깃값으로, 나머지 수치 데이터를 input 데이터로 하기로 한다.

# 입력 데이터
fish_input = fish[['Weight', 'Length', 'Diagonal', 'Height', 'Width']].to_numpy()

input 데이터로 사용하고 싶은 열이 'Weight', 'Length', 'Diagonal', 'Height', 'Width'로 총 5개 열이므로 열 이름을 배열로 만들어 fish에서 추출한다. 따라서 2차원 배열 형태처럼 보이지만 사실 아래 코드와 같다.

input_columns = ['Weight', 'Length', 'Diagonal', 'Height', 'Width']
fish_input = fish[input_columns].to_numpy()
print(fish_input[:5])


.to_numpy() 메서드를 이용하여 넘파이 배열로 만들어줬기 때문에 열 이름은 제외하고 이차원 배열 형태로 수치형 데이터를 추출했다.

마찬가지로 타깃 데이터도 추출한다.

# 타깃 데이터
fish_target = fish['Species'].to_numpy()
print(fish_target[:5])


타깃 데이터는 'Species' 단일 열을 사용할 것이기 때문에 배열 형태로 넣어주지 않아도 된다. 만약 배열로 감싸줘 버리면 타깃 데이터 또한 이차원 배열 형태가 된다.

데이터를 입력 데이터와 타깃 데이터로 분류해줬으니 훈련 세트와 테스트 세트로 나눈다.

from sklearn.model_selection import train_test_split

train_input, test_input, train_target, test_target = train_test_split(fish_input, fish_target, random_state=42)
print(train_input.shape, test_input.shape)


비율 등을 따로 지정해주지 않았으므로 약 3:1의 비율로 훈련 세트와 테스트 세트를 나눠준다.

데이터를 나눴으니 위에서 설명한 대로 StandardScaler를 이용하여 정규화를 해준다.
정규화를 할 때에는 train_input을 정규화를 하고 같은 기준으로 test_input을 맞춰주어야 하므로 같은 변환기를 사용하여 정규화 시켜준다.

from sklearn.preprocessing import StandardScaler
ss = StandardScaler()
ss.fit(train_input)
train_scaled = ss.transform(train_input)
test_scaled = ss.transform(test_input)
print(train_scaled[:5])
print('-------------------------------------------------------------------')
print(test_scaled[:5])


정규화가 잘 된 것을 확인할 수 있다.

🤖 모델 학습

K-최근접 이웃 분류기

K-최근접 이웃은 주변 K개의 이웃을 찾아주므로 이웃 클래스의 비율을 확률이라고 출력하면 된다는 아이디어!

from sklearn.neighbors import KNeighborsClassifier

kn = KNeighborsClassifier(n_neighbors=3)
kn.fit(train_scaled, train_target)
print(kn.score(train_scaled, train_target))
print(kn.score(test_scaled, test_target))


이전에 K-최근접 이웃 모델의 score가 나타내는 지표는 정확도(accuracy)라고 언급한 바 있다.

모델의 정확도가 꽤 쓸만하므로 테스트 세트의 처음 5개 샘플의 클래스를 예측해 보자.

print(kn.predict(test_scaled[:5]))


이전 사용했던 이진분류에서 클래스를 0, 1로 구분했던 것과 달리 문자열로된 타깃값을 그대로 사용하고 있다는 것을 알 수 있다!

.predict_proba() 메서드를 이용하면 모델이 클래스를 예측하는데 이용한 확률을 반환해 준다.

import numpy as np

proba = kn.predict_proba(test_scaled[:5])
print(np.round(proba, decimals=4)) # 소수점 4번째 자리까지 나타냄


예측한 5개의 샘플이 7개의 클래스 중 어떤 클래스에 속할지 확률을 출력한다.
이 때 주의해야 할 점은 문자열인 타깃값의 클래스 순서가 알파벳 순서라는 것이다!!

클래스의 순서는 classes_ 속성에 저장되어 있다.

print(kn.classes_)


따라서 위의 확률 출력에서 첫번째 샘플은 'Perch'로, 두번째 샘플은 'Smelt'로 예측된 것이다.

네번째 샘플의 이웃들이 어떤 샘플인지, 그리고 이웃들의 비율대로 확률을 제대로 출력했는지 확인해보자.

distances, indexes = kn.kneighbors(test_scaled[3:4])
print(train_target[indexes])


'Perch'인 이웃이 3개 중 2개(0.6667), 'Roach'인 이웃이 3개 중 1개(0.3333)으로 0.6667의 확률로 'Perch'라고 예측되었다.

위 모델은 주변 3개의 샘플을 기준으로 확률을 계산하기 때문에 출력 가능한 값이 0, 1/3, 2/3, 1 뿐이라 어색한 감이 있다.
n_neighbors 속성을 조정해도 분모에 변화만 있을 뿐이므로 여전히 어색함이 해결되지 않는다.
조금 더 세밀한 확률을 출력하기 위해 방법론을 바꿔보자.

로지스틱 회귀(Logistic Regression)

Linear Regression과 같이 어떤 방정식을 학습하지만 출력값이 확률이다!

시그모이드 함수를 pyplot으로 그려보면 다음과 같다.

import numpy as np
import matplotlib.pyplot as plt
z = np.arange(-5,5,0.1)
phi = 1 / (1 + np.exp(-z))
plt.plot(z, phi)
plt.xlabel('z')
plt.ylabel('phi')
plt.show()

로지스틱 회귀를 이용한 이진분류

다중 분류를 하기 전에 과제를 좀 단순화 시켜서 이진 분류를 먼저 수행해 보기로 한다.
로지스틱 회귀의 출력값은 확률이라고 언급했는데, 당연하게 이진분류에서는 출력값이 0.5보다 크면 양성 클래스, 작으면 음성이라고 판단한다.
정확하게 0.5인 경우 판단은 라이브러리마다 다르나, 여기서 사용하는 사이킷런에서는 음성 클래스로 판단한다.


그렇다면 우선 이진분류를 위해 도미 데이터와 빙어 데이터만 추출하여 이진 분류를 수행해보도록 하자.
불리언 인덱싱(boolean indexing)을 이용하면 boolean 값(True, False)을 이용하여 원하는 데이터만 쉽게 추출할 수 있다.

# boolean indexing 예시
char_arr = np.array(['A', 'B', 'C', 'D', 'E'])
print(char_arr[[True, False, True, False, False]])


True로 전달된 인덱스의 원소만 출력되었다!

마찬가지로 도미 또는 빙어인 행만 True로 만들어 인덱스로 전달하면 도미와 빙어 데이터만 쉽게 추출할 수 있다.

bream_smelt_indexes = (train_target == 'Bream') | (train_target == 'Smelt')
print(bream_smelt_indexes[:5])


bream_smelt_indexes에는 도미 또는 빙어인 인덱스만 True이고 나머지는 Flase인 불리언 배열이 저장되게 된다.

이제 이 배열을 전달하여 도미와 빙어 데이터를 추출하자.

train_bream_smelt = train_scaled[bream_smelt_indexes]
target_bream_smelt = train_target[bream_smelt_indexes]
print(train_bream_smelt[:5])
print(target_bream_smelt[:5])

위 데이터를 이용하여 모델에 학습시켜보자.
LogisticRegression 클래스는 사이킷런의 linear_model 패키지에 있다.

from sklearn.linear_model import LogisticRegression
lr = LogisticRegression()
lr.fit(train_bream_smelt, target_bream_smelt)
print(lr.predict(train_bream_smelt[:5]))


모델이 train_bream_smelt의 처음 5개 샘플을 위와 같이 예측했다.

K-최근접 이웃 모델과 마찬가지로 .predict_proba() 메서드를 사용하면 클래스 예측에 사용한 확률을 반환한다.

print(lr.predict_proba(train_bream_smelt[:5]))


확률이므로 행별로 더하면 합계가 1이다.

print(lr.classes_)


타깃값 순서 또한 알파벳 순서이고 위 확률값 또한 타깃값 순서대로 출력된다.

위에서 로지스틱 회귀 또한 선형 방정식을 학습한다고 언급했는데 이 방정식이 다음과 같다고 해보자.
z=a(Weight)+b(Length)+c(Diagonal)+d(Height)+e(Width)+fz = a * (Weight) + b * (Length) + c * (Diagonal) + d * (Height) + e * (Width) + f

print(lr.coef_, lr.intercept_)


계수를 확인해봤을 때 학습한 방정식은 다음과 같다.

.decision_funcion()을 이용하면 확률로 변환되기 전 z값 또한 출력할 수 있다.

decisions = lr.decision_function(train_bream_smelt[:5])
print(decisions)

위 z 값을 확률로 변환시키기 위해 시그모이드 함수에 넣어 계산하면 확률로 변환할 수 있다.
scipy의 special 패키지 아래 expit을 이용하면 쉽게 계산할 수 있다.

from scipy.special import expit
print(expit(decisions))


양성 클래스(Smelt)에 대한 확률값이 출력된 것을 알 수 있다.

로지스틱 회귀를 이용한 다중분류

이제 다시 원래 문제인 다중 분류 문제를 해결해 보도록 하자.
이진분류에서 확률 값 출력을 위해 시그모이드 함수를 사용했다면 다중 분류에서는 소프트맥스 함수(softmax)함수를 사용한다.

코드를 작성하기 전에!
LogisticRegression 클래스는 기본적으로 100번을 반복(max_iter=100)하는 알고리즘인데 우리는 7개의 데이터를 사용해야 하므로 반복 횟수를 1000으로 늘려주도록 한다.
또한 LogisticRegression은 기본적으로 릿지회귀처럼 계수의 제곱을 기준으로 규제를 시행하는데, 여기서는 L2 규제라고 하고 C라는 매개변수를 통해 규제 강도를 조절한다.
alpha와 반대로 숫자가 작을 수록 강한 규제(기본값 1)이므로 지금 예제에서는 20으로 늘려주기로 한다.

lr = LogisticRegression(C=20, max_iter=1000)
lr.fit(train_scaled, train_target)
print(lr.score(train_scaled, train_target))
print(lr.score(test_scaled, test_target))


score가 나타내는 지표가 무엇인지 조금 더 심화 공부가 필요하다...!

위 코드 실행 결과 과대적합 또는 과소적합이 일어나지 않은 것으로 보여 계속 예측에 이용해보도록 한다.

print(lr.predict(test_scaled[:5]))

이진분류와 마찬가지로 각 클래스별 확률을 출력해보도록 하자.

proba = lr.predict_proba(test_scaled[:5])
print(np.round(proba, decimals=3))


5개의 샘플에 대한 7개 클래스의 확률이 출력되었다!

클래스의 순서는 다음과 같다.

print(lr.classes_)


마찬가지로 알파벳 순서이다.

위에서 설명했듯이 다중 분류의 경우 클래스 별로 선형 방정식을 만들게 되는데, 가중치는 다음과 같다.

print(lr.coef_.shape, lr.intercept_.shape)


7개의 클래스에 대한 각각의 수식이 있음을 알 수 있다.

print(lr.coef_)


따라서 현재 예제에서는 각 클래스에 대해 z1z_1부터 z7z_7까지 7개의 zz값을 구한다.

decision = lr.decision_function(test_scaled[:5])
print(np.round(decision, decimals=2))


각 샘플별로 z1z_1부터 z7z_7까지 7개의 zz값이 계산된다.

zz값 들을 소프트맥스 함수에 넣어주면 확률 값으로 변환할 수 있다.
우리는 행별로 합계를 1로 만들어주어야 하므로 axis = 1로 지정하여 샘플 각각에 대한 소프트맥스를 계산해야 한다.

from scipy.special import softmax
proba = softmax(decision, axis=1)
print(np.round(proba, decimals=3))


행별 합계가 1인 것을 알 수 있다.

📚 Reference

혼자 공부하는 머신러닝+딥러닝, 박해선, 한빛미디어

profile
Data Science & AI

1개의 댓글

comment-user-thumbnail
2023년 7월 25일

많은 도움이 되었습니다, 감사합니다.

답글 달기