[혼공머신] 4-1장 로지스틱 회귀

Changh2·2024년 10월 29일
0

[혼자 공부하는 머신러닝+딥러닝] 교재 4장을 기반으로 작성되었습니다.


구성품을 모른 채 먼저 구매하고, 배송받은 다음에야 비로소 구성품을 알 수 있는 상품인 럭키백을 기획하기로 했다. 럭키백에 포함된 생선의 확률을 알려주는 방향으로 이벤트를 기획하므로, 머신러닝으로 럭키백의 생선이 어떤 타깃에 속하는지 확률을 구해보자.

럭키백의 확률

이번에는 생선의 길이, 높이, 두께 외에도 대각선 길이와 무게도 사용 가능하다.

"k-최근접 이웃은 주변 이웃을 찾아주니까 이웃의 클래스 비율을 확률이라고 출력하면 되지 않을까?"

위와 같이 k-최근접 이웃 분류기로 럭키백에 들어간 생선의 확률을 계산해보자.

데이터 준비

먼저, 데이터를 준비하자.

import pandas as pd

fish = pd.read_csv('https://bit.ly/fish_csv_data')
fish.head()
>>>
	Species	 Weight	 Length	 Diagonal  Height   Width
0	 Bream	 242.0	  25.4	  30.0	  11.5200	4.0200
1	 Bream	 290.0	  26.3	  31.2	  12.4800	4.3056
2	 Bream	 340.0	  26.5	  31.1	  12.3778	4.6961
3	 Bream	 363.0	  29.0	  33.5	  12.7300	4.4555
4	 Bream	 430.0	  29.0	  34.0	  12.4440	5.1340

어떤 종류의 생선이 있는지 unique() 함수를 사용해 확인해보자.

print(pd.unique(fish['Species']))
>>> ['Bream' 'Roach' 'Whitefish' 'Parkki' 'Perch' 'Pike' 'Smelt']

이 데이터프레임에서 Species 열을 타깃으로 만들고 나머지 5개 열은 입력데이터로 사용할건데, 데이터프레임에서 열을 선택하려면 아래처럼 원하는 열을 리스트로 나열하면 된다.

# 입력 데이터 준비
fish_input = fish[['Weight', 'Length', 'Diagonal', 'Height', 'Width']].to_numpy()
print(fish_input[:5])
>>> [[242.      25.4     30.      11.52     4.02  ]
 	 [290.      26.3     31.2     12.48     4.3056]
	 [340.      26.5     31.1     12.3778   4.6961]
 	 [363.      29.      33.5     12.73     4.4555]
 	 [430.      29.      34.      12.444    5.134 ]]
# 타깃 데이터 준비
fish_target = fish['Species'].to_numpy()

입력 데이터와 타깃 데이터가 준비되었으니, 이제 각 데이터를 훈련 세트와 테스트 세트로 나누자.

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)
																							 # 교재와 같은 결과를 위해 랜덤값 지정

이제 마지막으로, StandardScaler 클래스를 사용해 훈련 세트와 테스트 세트를 표준화 전처리 해주자.

from sklearn.preprocessing import StandardScaler

ss = StandardScaler()
ss.fit(train_input)
train_scaled = ss.transform(train_input)
test_scaled = ss.transform(test_input)

k-최근접 이웃 분류기의 확률 예측

최근접 이웃 개수 k를 3으로 지정하여 모델을 훈련하고 점수를 확인해보자.

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))
>>> 0.8907563025210085
	0.85

아까 데이터프레임에서 타깃 데이터로 fish['Species']를 사용했기 때문에 7개의 생선 종류가 들어가 있는데, 이렇게 타깃 데이터에 2개 이상의 클래스가 포함된 문제를 다중 분류라고 부른다.

근데, 타깃값을 사이킷런 모델에 전달하면 알파벳 순으로 순서가 자동으로 매겨지는데,
이는 pd.unique(fish['Species']) 로 출력했던 순서와 다르다.
자동으로 정렬된 타깃값은 classes_ 속성에 저장되어 있다.

print(kn.classes_)
>>> ['Bream' 'Parkki' 'Perch' 'Pike' 'Roach' 'Smelt' 'Whitefish']

이제 테스트 세트에 있는 처음 5개 샘플의 타깃값을 예측해 보자.

print(kn.predict(test_scaled[:5]))
>>> ['Perch' 'Smelt' 'Pike' 'Perch' 'Perch']

이제 처음 5개 샘플에 대한 확률을 출력해보자.

import numpy as np

proba = kn.predict_proba(test_scaled[:5])
print(np.round(proba, decimals=4))
>>> [[0.     0.     1.     0.     0.     0.     0.    ]
 	 [0.     0.     0.     0.     0.     1.     0.    ]
 	 [0.     0.     0.     1.     0.     0.     0.    ]
 	 [0.     0.     0.6667 0.     0.3333 0.     0.    ]
 	 [0.     0.     0.6667 0.     0.3333 0.     0.    ]]

predict_proba()

예측 확률을 반환하는 메서드.
이진 분류의 경우에는 샘플마다 음성 클래스와 양성 클래스에 대한 확률을 반환한다.
다중 분류의 경우에는 샘플마다 모든 클래스에 대한 확률을 반환한다.

predict_proba() 메서드의 출력은 아래와 같다.

이 모델이 계산한 확률이 가장 가까운 이웃의 비율이 맞는지 확인해보자.

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

이 샘플의 이웃은 다섯번째 클래스인 'Roach'가 1개, 세번째 클래스인 'Perch'가 2개이다.
따라서 다섯번째 클래스에 대한 확률은 1/3 = 0.3333,
세번째 클래스에 대한 확률은 2/3 = 0.6667이 되므로 확률을 성공적으로 예측했다.

하지만 아직은 확률이라 말하기엔 좀 어색하다. 더 좋은 방법을 찾아보자.

로지스틱 회귀

로지스틱 회귀는 이름은 회귀이지만 분류 모델인데, 선형 회귀와 동일하게 선형 방적식을 학습한다.
예를 들면 아래와 같다.

여기서 a,b,c,d,e는 가중치(계수)인데, z는 어떤 값이든 될 수 있다.
하지만 확률이 되려면 0~1 사이값이 되어야 하는데, 이 변환의 역할은 시그모이드 함수가 해준다.
이렇게 선형회귀에서 시그모이드 함수를 더해 확률값을 결과로 냄으로써 분류 하는 모델을 로지스틱 회귀 모델이라고 한다.

로지스틱 회귀의 목표

로지스틱 회귀는 특정 데이터가 특정 클래스에 속할 확률을 예측하는데, 이 확률 값은 항상 0과 1 사이여야 합니다. 예를 들어, 이메일이 스팸인지 아닌지 예측할 때, 예측 확률이 0.7이라면 해당 이메일이 스팸일 확률이 70%라는 의미입니다.

로지스틱 회귀와 시그모이드 함수의 관계

로지스틱 회귀는 본질적으로 선형 회귀와 비슷한 원리로 작동하지만, 선형 회귀에서는 예측 값이 무한대 범위로 나올 수 있습니다. 분류 문제에서 확률을 다루기 위해, 이 값을 0과 1 사이의 범위로 조정할 필요가 있습니다. 시그모이드 함수는 입력 값을 0과 1 사이로 압축하여 확률 값을 제공하기 때문에, 이 요구에 잘 부합합니다.

시그모이드 함수의 역할: 시그모이드 함수는 어떤 값이라도 0에서 1 사이의 값으로 압축할 수 있는 함수입니다. 로지스틱 회귀에서는 모델의 선형 결합을 시그모이드 함수에 넣어 확률로 변환합니다.

시그모이드 함수의 출력을 확인해보자.

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부터 1까지인 것을 볼 수 있다.

로지스틱 회귀로 이진 분류 수행 (sigmoid 함수)

이제 로지스틱 회귀 모델을 훈련시켜보자.

불리언 인덱싱
넘파이 배열은 True, False 값을 전달하여 행을 선택할 수 있는데, 이를 불리언 인덱싱이라 한다.

char_arr = np.array(['A', 'B', 'C', 'D', 'E'])
print(char_arr[[True, False, True, False, False]])
>> ['A' 'C']

위와 같은 방식으로 훈련세트에서 도미(Bream)와 빙어(Smelt)의 행만 골라내겠다.

bream_smelt_indexes = (train_target == 'Bream') | (train_target == 'Smelt')
train_bream_smelt = train_scaled[bream_smelt_indexes]
target_brea_smelt = train_target[bream_smelt_indexes]

이제 이 데이터로 로지스틱 회귀 모델을 훈련해보자.
LogisticRegression 클래스는 선형 모델이므로 sklearn.linear_model 패키지 내에 있다.

from sklearn.linear_model import LogisticRegression

lr = LogisticRegression()
lr.fit(train_bream_smelt, target_bream_smelt)

훈련한 모델을 사용해 처음 5개 샘플을 예측해보자.

print(lr.predict(train_bream_smelt[:5]))
>>> ['Bream' 'Smelt' 'Bream' 'Bream' 'Bream']

처음 5개의 샘플의 예측 확률을 확인해보자.

print(lr.predict_proba(train_bream_smelt[:5]))
>>> [[0.99760007 0.00239993]
 	 [0.02737325 0.97262675]
 	 [0.99486386 0.00513614]
 	 [0.98585047 0.01414953]
 	 [0.99767419 0.00232581]]

샘플마다 두 개의 확률이 출력되었는데, 이는 각 음성 클래스(0), 양성 클래스(1)이다. 위에서 설명했듯이 사이킷런은 타깃값을 알파벳순으로 정렬하여 사용한다. classes_ 속성에서 확인해보자.

print(lr.classes_)
>>> ['Bream' 'Smelt']

음성 클래스는 도미(Bream), 양성 클래스는 빙어(Smelt)인 것을 볼 수 있다.
이를 토대로 분석해보면, 두번째 샘플만 빙어일 확률이 높고, 나머지는 모두 도미로 예측할 것이다.

성공적으로 이진 분류를 했는데, 선형 회귀에서 처럼, 모델이 학습한 계수를 확인해보자.

print(lr.coef_, lr.intercept_)
>>> [[-0.40451732 -0.57582787 -0.66248158 -1.01329614 -0.73123131]] [-2.16172774]

위에 의하면 이 로지스틱 회귀 모델이 학습한 방적식은 아래와 같다.

LogisticRegression 클래스는 decision_function() 메서드로 z값을 출력할 수 있다.

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

decision_function()

모델이 학습한 선형 방정식의 출력을 반환하는 메서드.
이진 분류의 경우 양성 클래스의 확률이 반환되는데,
값이 0보다 크면 양성 클래스, 작거나 같으면 음성 클래스로 예측한다.
다중 분류의 경우 각 클래스마다 선형 방정식을 계산하는데,
가장 큰 값의 클래스가 예측 클래스가 된다.

>>> [-6.02991358  3.57043428 -5.26630496 -4.24382314 -6.06135688]

이 z 값을 시그모이드 함수에 통과시키면 확률을 얻을 수 있는데, 파이썬의 사이파이(scipy) 라이브러리에도 시그모이드 함수expit() 로 있다. decisions 배열의 값을 확률로 변환해보자.

from scipy.special import expit
print(expit(decisions))
>>> [0.00239993 0.97262675 0.00513614 0.01414953 0.00232581]

출력된 값을 보니 predict_proba() 메서드 출력의 두번째 열의 값, 즉 양성 클래스에 대한 z값과 동일한 것을 볼 수있다. 성공적으로 로지스틱 회귀모델을 훈련하고 이진 분류한 것이다!

로지스틱 회귀로 다중 분류 수행 (softmax함수)

다중 분류도 이중 분류와 크게 다르진 않지만, 아래 코드에서 7개의 생선 데이터가 모두 들어 있는 train_scaled와 train_target을 사용한 점을 눈여겨보자.

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))
>>> 0.9327731092436975
	0.925
    
# 과대적합이나 과소적합으로 치우친 것 같진 않다. 

테스트 세트 처음 5개 샘플에 대한 예측을 출력해보자.

print(lr.predict(test_scaled[:5]))
>>> ['Perch' 'Smelt' 'Pike' 'Roach' 'Perch']

이번엔 테스트 세트 처음 5개 샘플에 대한 예측 확률을 출력해보자.

proba = lr.predict_proba(test_scaled[:5])
print(np.round(proba, decimals=3))
>>> [[0.    0.014 0.842 0.    0.135 0.007 0.003]
	 [0.    0.003 0.044 0.    0.007 0.946 0.   ]
 	 [0.    0.    0.034 0.934 0.015 0.016 0.   ]
	 [0.011 0.034 0.305 0.006 0.567 0.    0.076]
 	 [0.    0.    0.904 0.002 0.089 0.002 0.001]]

첫번째 샘플을 보면 세번째 열의 확률이 가장 높은데, 세번째 열이 농어(Perch)에 대한 확률인지 classes_ 속성에서 확인해보자.

print(lr.classes_)
>>> ['Bream' 'Parkki' 'Perch' 'Pike' 'Roach' 'Smelt' 'Whitefish']

# 세번째 열이 농어(Perch)인 것을 볼 수 있다.
# 정확하게 다중 분류에 성공한 것!

이렇게 다중 분류일 경우의 선형 방정식은 어떻게 되는지 확인해보자.

print(lr.coef_.shape, lr.intercept_.shape)
>>> (7, 5) (7,)
# coef_ 배열의 열은 특성의 수 5개가 맞다. 근데 행이 7이다. intercept_도 7개나 있다.

다중 분류는 클래스마다 z 값을 하나씩 계산한다. 당연히 가장 높은 z 값을 출력하는 클래스가 예측 클래스가 된다. 그럼 확률은 어떻게 계산한걸까?

위에서 이중 분류는 시그모이드 함수를 사용해 z를 0과 1사이의 값으로 변환했다.
이와 달리 다중 분류는 소프트맥스 함수를 사용하여 여러 개의 z 값을 확률로 변환한다.

소프트맥스 함수

하나의 선형 방정식의 출력값을 0~1 사이로 압축하는 시그모이드 함수와 달리,
여러 개의 선형 방정식의 출력값을 0~1 사이로 압축하고 전체 합이 1이 되도록 만든다.

이진 분류에서 했던 것과 같이, decision_function()메서드로 z1~z7까지의 값을 구한 다음 소프트맥스 함수를 사용해 확률로 바꾸어 보자.
먼저, 테스트세트의 처음 5개 샘플의 z1~z7을 구해보자.

decision = lr.decision_function(test_scaled[:5])
print(np.round(decision, decimals=2))
>>> [[ -6.51   1.04   5.17  -2.76   3.34   0.35  -0.63]
	 [-10.88   1.94   4.78  -2.42   2.99   7.84  -4.25]
	 [ -4.34  -6.24   3.17   6.48   2.36   2.43  -3.87]
	 [ -0.69   0.45   2.64  -1.21   3.26  -5.7    1.26]
	 [ -6.4   -1.99   5.82  -0.13   3.5   -0.09  -0.7 ]]

사이파이는 소프트맥스 함수도 softmax()로 제공한다.

from scipy.special import softmax

proba = softmax(decision, axis=1)
print(np.round(proba, decimals=3))
>>> [[0.    0.014 0.842 0.    0.135 0.007 0.003]
	 [0.    0.003 0.044 0.    0.007 0.946 0.   ]
	 [0.    0.    0.034 0.934 0.015 0.016 0.   ]
	 [0.011 0.034 0.305 0.006 0.567 0.    0.076]
	 [0.    0.    0.904 0.002 0.089 0.002 0.001]]

위에서 구한 proba 배열과 정확히 일치하는 것을 볼 수 있다. 완벽하다!

profile
Shoot for the moon!

0개의 댓글