Skip to content
psymon-ai
Go back

4. 분류 I — 로지스틱 회귀

0

Table of contents

Open Table of contents

들어가는 글

지금까지는 회귀(Regression) 문제를 다뤘다. 집값, 매출, 온도, 시험 점수처럼 답이 숫자로 나오는 문제다. 예측값은 72일 수도 있고, 105일 수도 있다. 중요한 것은 답이 연속된 숫자라는 점이다.

이번에는 다른 종류의 문제로 넘어간다.

이 이메일은 스팸인가, 아닌가?
사진 속 동물은 개인가, 고양이인가, 새인가?
응급실에 온 환자는 어느 진료실로 보내야 하는가?

이 질문들은 숫자를 예측하는 문제가 아니라 범주를 고르는 문제다. 이런 문제를 분류(Classification)라고 부른다. 분류는 머신러닝 실무에서 매우 자주 만난다. 스팸 필터, 이탈 예측, 질병 진단, 이미지 분류, 감정 분석이 모두 여기에 속한다.

이번 글은 분류 모델 중 하나인 로지스틱 회귀(Logistic Regression)를 다룬다. 이름에 “회귀”가 들어 있지만, 로지스틱 회귀는 분류 모델이다. 처음 보면 헷갈릴 수 있다. 선형 회귀처럼 입력에 계수를 곱해 하나의 값을 만들지만, 그 값을 그대로 예측값으로 쓰지는 않는다. 대신 0과 1 사이의 확률로 바꾼 뒤, 어느 클래스에 속할지 판단한다.

지금은 이 정도만 기억하면 된다. 로지스틱 회귀는 선형 회귀와 닮은 출발점에서 시작하지만, 목적은 숫자 예측이 아니라 분류다. 이 글에서는 그 차이가 어디서 생기는지 차근히 살펴본다.

분류 문제란 무엇인가

분류 문제는 가능한 답이 미리 정해져 있다. 이 답들을 클래스(class)라 부른다.

스팸 필터라면 가능한 클래스는 다음과 같다.

C={spam,not spam}C = \{\text{spam}, \text{not spam}\}

우리가 만들고 싶은 모델은 입력 벡터 xx를 받아, 이 클래스 집합 CC 안의 값 하나를 고르는 함수다.

f(x)Cf(x) \in C

이런 함수를 분류기(classifier)라 부른다.

하지만 실무에서는 답 하나론 부족할 때가 많다. 답과 함께 얼마나 확신하는지도 필요하다.

스팸 필터를 생각해보자. 어떤 메일이 스팸일 확률이 98%라면 바로 스팸함으로 보내도 괜찮다. 반면 55%라면 애매하다. 사용자가 “확실한 것만 스팸으로 보내라”고 설정했는지, “조금만 의심스러워도 보내라”고 설정했는지에 따라 판단이 달라진다.

그래서 좋은 분류 모델은 단순히 클래스를 고르는 데서 끝나지 않는다. 각 클래스에 속할 가능성을 함께 알려줘야 한다. 로지스틱 회귀는 바로 이 확률을 계산하는 가장 기본 분류 모델이다.

왜 선형 회귀로 분류하면 안 되는가

분류를 처음 접하면 자연스레 떠오르는 아이디어가 있다.

“그냥 0과 1로 놓고 선형 회귀를 돌리면 안 되나?”

나쁘지 않은 발상이다. 어차피 1과 0은 숫자니까 회귀 모델로 예측하면 되지 않을까? 신용카드 사용자를 예로 들어보자. 입력은 사용자의 소득(income)과 결제액(balance, 아직 갚지 않은 카드 대금)이고, 출력은 연체(default) 발생 여부다.

ml-4-1.png

Default 데이터에서는 소득보다 결제액이 더 뚜렷한 신호처럼 보인다. 결제액이 높을수록 default 위험이 커진다.

간단한 데이터를 만들어 선형 회귀로 풀어보면 문제가 바로 보인다.

import numpy as np
# 신용카드 사용자 데이터
np.random.seed(42)
income = np.concatenate([
np.random.normal(55, 12, 95),
np.random.normal(56, 12, 5),
])
balance = np.concatenate([
np.random.normal(850, 350, 95),
np.random.normal(1900, 220, 5),
])
y = np.array([0] * 95 + [1] * 5)
# 선형 회귀로 default 여부 예측
X_linear = np.column_stack([np.ones(len(balance)), balance])
beta_linear = np.linalg.lstsq(X_linear, y, rcond=None)[0]
y_pred = X_linear @ beta_linear
print(f"avg income: {income[y == 0].mean():.1f} / {income[y == 1].mean():.1f}")
print(f"avg balance: {balance[y == 0].mean():.1f} / {balance[y == 1].mean():.1f}")
print(f"prediction range: [{y_pred.min():.3f}, {y_pred.max():.3f}]")
print(f"predicted defaults: {(y_pred >= 0.5).sum()}")
print(f"actual defaults: {y.sum()}")
avg income: 53.8 / 53.3
avg balance: 863.5 / 1837.1
prediction range: [-0.171, 0.373]
predicted defaults: 0
actual defaults: 5

소득 평균은 거의 비슷하지만 결제액 평균은 크게 다르다. 그런데 선형 회귀 예측값은 최대가 0.373에 그친다. 0.5를 기준으로 분류하면 실제 default가 5명이나 있는데도 모델은 default를 한 명도 잡지 못한다. 데이터가 0 쪽에 많이 몰려 있으니 회귀 직선 기울기가 충분히 커지지 못해 생긴 일이다.

ml-4-3.png

선형 회귀는 직선으로 확률을 흉내 내지만, 로지스틱 회귀는 0과 1 사이에 머무르는 S자 곡선으로 default 확률을 모델링한다.

더 근본적 문제도 있다. 선형 회귀 출력은 -\infty에서 ++\infty까지 어떤 값이든 될 수 있다. “default 확률 -0.171” 같은 결과를 어떻게 해석해야 할까. “137% 확률로 default가 난다”는 말도 이상하다. 확률은 반드시 0과 1 사이여야 한다.

숫자 레이블 순서 문제

연체 여부 같은 이진 분류는 억지로 0과 1을 써도 어느 정도는 근사할 수 있다. 하지만 클래스가 세 개 이상이면 어떻게 될까? 응급실에 온 환자를 뇌졸중(stroke), 약물 과다복용(drug overdose), 발작(seizure) 중 하나로 분류한다고 해보자. 여기에 각각 1, 2, 3이라는 숫자를 붙이고 선형 회귀를 돌리면 문제가 생긴다.

모델은 1과 2의 차이, 2와 3의 차이 같은 의미 없는 간격을 학습한다. 또한 1, 2, 3 사이에 순서가 있다고 가정한다. 하지만 실제로 strokedrug overdose의 차이가 drug overdoseseizure의 차이와 정확히 같은가? 세 클래스 사이에 자연스러운 순서가 있는가? 아니다. 범주형 레이블을 숫자로 바꾸는 순간, 원래 없던 거리와 순서를 모델에 주입하게 된다.

분류에는 분류에 맞는 모델이 필요하다.

  1. 출력은 확률처럼 해석할 수 있어야 한다.
  2. 이진 분류라면 두 클래스 확률 합이 1이어야 한다.
  3. 다중 클래스 분류라면 모든 클래스 확률 합이 1이어야 한다.
  4. 학습을 위해 미분 가능해야 한다.

이진 분류에서 이 역할을 하는 대표 함수가 시그모이드(Sigmoid)다. 다중 클래스에서는 뒤에서 볼 소프트맥스(Softmax)가 같은 역할을 한다.

시그모이드 함수 - S자 곡선의 철학

로지스틱 회귀도 선형 회귀처럼 먼저 숫자 하나를 만든다.

z=βTxz = \beta^T x

이 값은 10-10, 00, 100100처럼 어떤 실수도 될 수 있다. 문제는 확률이다. 확률은 반드시 0과 1 사이에 있어야 한다. 그래서 로지스틱 회귀는 선형 결합 값 zz를 그대로 쓰지 않고, 0과 1 사이 값으로 바꾼다.

이때 사용하는 함수가 시그모이드 함수(sigmoid function)다.

σ(z)=11+ez\sigma(z) = \cfrac{1}{1 + e^{-z}}

시그모이드는 어떤 실수가 들어와도 0과 1 사이 값을 반환한다. 모양은 늘어진 S자 곡선이다.

ml-4-6.png

시그모이드는 점수 zz를 확률처럼 해석할 수 있는 값으로 바꾼다.
import numpy as np
def sigmoid(z):
return 1 / (1 + np.exp(-z))
z = np.array([-10, -1, 0, 1, 10])
print("z:", z)
print("sigmoid:", np.round(sigmoid(z), 4))
z: [-10 -1 0 1 10]
sigmoid: [0. 0.2689 0.5 0.7311 1. ]

시그모이드 값은 zz가 클수록 1에 가까워지고, 작을수록 0에 가까워진다. z=0z = 0이면 정확히 0.5다. 이진 분류에서 0.5를 기준으로 클래스를 나누는 이유도 여기 있다. zz가 0보다 크면 1번 클래스 쪽으로, 0보다 작으면 0번 클래스 쪽으로 기운다.

로지스틱 회귀는 이 구조를 그대로 사용한다.

P(y=1x)=σ(βTx)=11+eβTxP(y=1|x) = \sigma(\beta^T x) = \cfrac{1}{1 + e^{-\beta^T x}}

즉 입력 xx로 선형 결합 값 βTx\beta^T x를 만들고, 그 값을 시그모이드에 넣어 y=1y=1일 확률을 구한다.

선형 회귀와 차이는 이 지점에서 생긴다. 선형 회귀는 βTx\beta^T x를 그대로 예측값으로 쓴다. 로지스틱 회귀는 βTx\beta^T x를 시그모이드에 통과시켜 확률로 바꾼다. 이 차이 덕분에 로지스틱 회귀는 연속값 예측이 아니라 분류 문제에 쓸 수 있다.

로짓과 로그 오즈 - 시그모이드가 나오는 이유

“왜 하필 시그모이드인가?”라는 질문에 답하려면 오즈(odds)로그 오즈(log-odds)를 봐야 한다.

어떤 사건이 일어날 확률을 pp라고 하자. 그러면 그 사건이 일어나지 않을 확률은 1p1-p다. 이 둘의 비율을 오즈라고 부른다.

odds=p1p\text{odds} = \cfrac{p}{1-p}

예를 들어 어떤 메일이 스팸일 확률이 80%라면 p=0.8p = 0.8이다. 스팸이 아닐 확률은 0.20.2다. 이때 오즈는 다음과 같다.

odds=0.80.2=4\text{odds} = \cfrac{0.8}{0.2} = 4

오즈가 4라는 말은 스팸일 가능성이 스팸이 아닐 가능성보다 4배 크다는 뜻이다. 반대로 스팸일 확률이 20%라면 오즈는 다음과 같다.

odds=0.20.8=0.25\text{odds} = \cfrac{0.2}{0.8} = 0.25

이 경우에는 스팸일 가능성이 스팸이 아닐 가능성보다 작다. 확률 pp는 0과 1 사이에 갇혀 있지만, 오즈는 0부터 무한대까지 움직인다. p=0.5p=0.5이면 오즈는 1이다. 사건이 일어날 가능성과 일어나지 않을 가능성이 같다는 뜻이다.

여기에 로그를 씌운 값이 로그 오즈(log-odds)이고, 이를 다른 말로 로짓(logit)이라 한다.

logit(p)=logp1p\text{logit}(p) = \log \cfrac{p}{1-p}

로그 오즈를 쓰면 범위가 다시 바뀐다. 확률 pp는 0과 1 사이에 있지만, 로그 오즈는 -\infty부터 ++\infty까지 움직일 수 있다.

  • pp가 0에 가까우면 오즈도 0에 가까워지고, 로그 오즈는 큰 음수가 된다.
  • p=0.5p = 0.5이면 오즈는 1이고, 로그 오즈는 0이다.
  • pp가 1에 가까우면 오즈는 무한대로 커지고, 로그 오즈도 큰 양수가 된다.

즉, 로그 오즈는 확률을 실수 전체 범위로 펼쳐놓은 값이다. 확률은 0과 1 사이에 갇혀 있어서 선형 모델의 출력으로 바로 쓰기 어렵다. 반면 로그 오즈는 음수, 0, 양수를 모두 가질 수 있으므로 βTx\beta^T x 같은 선형식과 연결하기 좋다.

로지스틱 회귀의 핵심 가정은 이것이다.

확률 자체가 아니라, 로그 오즈가 입력 x\boldsymbol{x}에 대해 선형이다.

positive class일 확률을 p=P(y=1x)p = P(y=1|x)라고 두자. 이진 분류에서는 negative class일 확률이 P(y=0x)=1pP(y=0|x) = 1-p다. 따라서 로그 오즈는 다음과 같이 쓸 수 있다.

logP(y=1x)P(y=0x)=logp1p\log \cfrac{P(y=1|x)}{P(y=0|x)} = \log \cfrac{p}{1-p}

로지스틱 회귀는 이 값을 선형식과 같다고 둔다.

logp1p=βTx\log \cfrac{p}{1-p} = \beta^T x

이제 이 식을 pp에 대해 풀어보자. 오른쪽을 간단히 z=βTxz = \beta^T x라고 두면 다음과 같다.

logp1p=z\log \cfrac{p}{1-p} = z

양변에 지수 함수 ee를 취하면 로그가 사라진다.

p1p=ez\cfrac{p}{1-p} = e^z

이제 양변에 1p1-p를 곱한다.

p=ez(1p)p = e^z(1-p)

오른쪽을 펼치면 다음과 같다.

p=ezezpp = e^z - e^z p

pp가 들어간 항을 왼쪽으로 모은다.

p+ezp=ezp + e^z p = e^z

pp로 묶는다.

p(1+ez)=ezp(1+e^z) = e^z

양변을 1+ez1+e^z로 나누면 다음 식을 얻는다.

p=ez1+ezp = \cfrac{e^z}{1+e^z}

분자와 분모를 eze^z로 나누면 더 익숙한 시그모이드 형태가 된다.

p=11+ezp = \cfrac{1}{1+e^{-z}}

처음에 z=βTxz = \beta^T x라고 두었으므로 최종적으로 다음 식이 나온다.

P(y=1x)=11+eβTxP(y=1|x) = \cfrac{1}{1 + e^{-\beta^T x}}

이 식이 바로 시그모이드다. 시그모이드는 임의로 고른 함수가 아니다. 로그 오즈를 선형 모델로 두면 자연스럽게 나온다. 이 관점에서 보면 로지스틱 회귀는 로짓 공간에서는 선형 모델을 세우고, 그 결과를 다시 확률로 되돌리는 모델이다.

ml-4-8.png

로지스틱 회귀의 계수는 확률 변화량이 아니라 로그 오즈 변화량으로 해석하는 것이 정확하다.

로지스틱 회귀 계수 해석 - 확률이 아니라 오즈를 본다

이제 계수의 의미를 보자. 입력 피처가 여러 개라면 선형식은 다음처럼 쓸 수 있다.

βTx=β0+β1x1+β2x2++βjxj++βpxp\beta^T x = \beta_0 + \beta_1 x_1 + \beta_2 x_2 + \cdots + \beta_j x_j + \cdots + \beta_p x_p

여기서 βj\beta_j는 피처 xjx_j 앞에 붙는 계수다. 다른 피처 값이 고정되어 있다고 할 때, xjx_j가 1 증가하면 βTx\beta^T xβj\beta_j만큼 증가한다. 그런데 로지스틱 회귀에서 βTx\beta^T x는 확률이 아니라 로그 오즈다. 따라서 βj\beta_j는 확률 변화량이 아니라 로그 오즈 변화량이다.

부호는 직관적으로 해석할 수 있다.

  • βj>0\beta_j > 0: 피처 xjx_j가 증가할수록 y=1y=1일 확률이 커진다.
  • βj<0\beta_j < 0: 피처 xjx_j가 증가할수록 y=1y=1일 확률이 작아진다.
  • βj=0\beta_j = 0: 피처 xjx_j는 분류에 영향을 주지 않는다.

크기 해석에는 주의해야 한다. 로지스틱 회귀에서 βj=0.5\beta_j = 0.5는 “xjx_j가 1 증가하면 확률이 0.5 오른다”는 뜻이 아니다. 확률 변화량은 현재 위치에 따라 달라진다. 시그모이드 곡선의 가운데에서는 확률이 빠르게 변하지만, 0이나 1에 가까운 구간에서는 같은 변화에도 확률이 조금만 움직인다.

정확한 해석은 “xjx_j가 1 증가하면 로그 오즈가 βj\beta_j만큼 증가한다”이다. 로그 오즈는 직관적으로 읽기 어렵기 때문에, 로지스틱 회귀 결과를 해석할 때는 오즈비(odds ratio)를 자주 사용한다.

오즈비는 다음과 같이 계산한다.

odds ratio=eβj\text{odds ratio} = e^{\beta_j}

eβje^{\beta_j}일까. xjx_j가 1 증가하기 전의 로그 오즈를 aa라고 하자. 그러면 증가 전 오즈는 eae^a다. xjx_j가 1 증가하면 로그 오즈는 a+βja+\beta_j가 된다. 이때 오즈는 다음과 같다.

ea+βj=eaeβje^{a+\beta_j} = e^a \cdot e^{\beta_j}

즉, xjx_j가 1 증가하면 오즈가 eβje^{\beta_j}배가 된다.

예를 들어 βj=0.5\beta_j = 0.5라면 오즈비는 다음과 같다.

e0.51.65e^{0.5} \approx 1.65

이는 xjx_j가 1 증가할 때 y=1y=1의 오즈가 약 1.65배가 된다는 뜻이다. 확률이 1.65배 된다는 뜻이 아니다. 오즈가 1.65배 된다는 뜻이다.

앞서 본 Default 데이터에서 얻은 계수 예시를 넣어보면 확률 계산은 이렇게 된다.

import numpy as np
# Default 예시와 비슷한 계수
def sigmoid(z):
return 1 / (1 + np.exp(-z))
beta0 = -10.6513
beta1 = 0.0055
for balance_value in [1000, 2000]:
z = beta0 + beta1 * balance_value
probability = sigmoid(z)
print(f"balance={balance_value}: default probability={probability:.3%}")
balance=1000: default probability=0.576%
balance=2000: default probability=58.630%

결제액이 1000달러일 때와 2000달러일 때 default 확률은 크게 다르다. 점수 zz는 결제액에 따라 일정하게 증가하지만, 확률은 시그모이드 곡선을 따라 변하기 때문이다. 같은 계수라도 곡선 위 어느 위치에 있느냐에 따라 확률 변화량이 달라진다.

ml-4-9.png

결제액이 1000달러일 때와 2000달러일 때의 default 확률은 시그모이드 곡선 위에서 크게 달라진다.

최대 우도 추정으로 학습하기

모델 형태는 정했다. 이제 남은 질문은 하나다.

β\boldsymbol{\beta}를 어떻게 찾을 것인가?

이 질문에 답하는 방법은 이미 선형 회귀에서 한 번 만났다. 최대 우도 추정(MLE)이다. 관측한 데이터가 가장 그럴듯하게 나오도록 만드는 β\beta를 찾는다. 선형 회귀에서는 오차가 정규 분포를 따른다고 가정하고 MLE를 적용하니 잔차 제곱합(RSS) 최소화가 자연스럽게 따라왔고, 그 해는 정규 방정식으로 한 번에 구할 수 있었다. 로지스틱 회귀의 출발점도 같다. 그러나 도착점은 꽤나 다른데 그 과정을 따라가 보자.

레이블을 어떻게 표기할 것인가

식을 쓰기 전에 정답 레이블 표기부터 정리하자. 보통 다음 두 가지 표기법을 사용한다.

0/1\boldsymbol{0/1} 표기는 negative class를 00, positive class를 11로 둔다. 코드에서 흔히 쓰는 방식이고, 뒤에서 다룰 크로스 엔트로피 손실과 자연스럽게 맞물린다.

1/+1\boldsymbol{-1/+1} 표기는 negative class를 1-1, positive class를 +1+1로 둔다. 수식에선 이쪽이 훨씬 편하다. 두 클래스 확률식이 부호만 다른 쌍둥이 형태라, yiy_i 값에 부호를 포함하면 하나의 식으로 묶을 수 있기 때문이다.

두 표기는 변환만 다를 뿐 본질은 같다. 이 글에선 깔끔한 공식 유도를 위해 먼저 1/+1-1/+1 표기로 출발하고, 마지막에 같은 손실을 0/10/1 표기로 다시 써 익숙한 형태도 함께 확인한다.

-1/+1 표기로 우도 유도하기

로지스틱 회귀가 만드는 값을 다음과 같이 두자.

zi=βTxiz_i = \beta^T x_i

이 값 자체는 확률이 아니다. 이를 확률로 변환하는 함수가 앞 절에서 본 시그모이드 함수다. 이때 positive class일 확률은 이렇게 쓴다.

P(yi=+1xi,β)=11+eziP(y_i = +1 \mid x_i, \beta) = \cfrac{1}{1 + e^{-z_i}}

ziz_i가 클수록 이 값은 1에 가까워진다. 모델이 positive class라고 강하게 판단한다는 뜻이다. 반대로 ziz_i가 작아질수록 이 값은 0에 가까워진다.

negative class일 확률은 그 여집합이다.

P(yi=1xi,β)=1P(yi=+1xi,β)=11+eziP(y_i = -1 \mid x_i, \beta) = 1 - P(y_i = +1 \mid x_i, \beta) = \cfrac{1}{1 + e^{z_i}}

마지막 등식이 곧바로 보이지 않는다면 분모와 분자에 ezie^{z_i}를 곱해 정리해 보면 된다. 이제 두 확률식을 나란히 놓자.

  • P(yi=+1xi,β)=11+eziP(y_i = +1 \mid x_i, \beta) = \cfrac{1}{1 + e^{-z_i}}

  • P(yi=1xi,β)=11+eziP(y_i = -1 \mid x_i, \beta) = \cfrac{1}{1 + e^{z_i}}

두 식은 지수 부호 하나만 빼고 완전히 같다. 정답이 +1+1이면 지수에 -가 붙고, 정답이 1-1이면 ++가 붙는다. 이 부호가 정확히 yi-y_i다. 이제 두 식을 한 줄로 묶어 보자.

P(yixi,β)=11+eyiβTxiP(y_i \mid x_i, \beta) = \cfrac{1}{1 + e^{-y_i \beta^T x_i}}

정답이 무엇이든 이 식 하나로 정답 클래스의 확률을 계산할 수 있다. yi=+1y_i = +1을 대입하면 positive class 확률이, yi=1y_i = -1을 대입하면 negative class 확률이 나온다. 1/+1-1/+1 표기법의 가치가 여기서 드러난다.

우도에서 로그 손실로

iid(independent and identically distributed) 가정을 만족한다면, 데이터 전체 우도는 개별 확률의 곱이다.

L(β)=i=1n11+eyiβTxiL(\beta) = \prod_{i=1}^{n} \cfrac{1}{1 + e^{-y_i \beta^T x_i}}

MLE는 이 L(β)L(\beta)를 최대로 만드는 β\beta를 찾는다. 곱셈 형태는 그대로 다루기 불편하므로 로그를 취해 로그 우도(log-likelihood)로 바꾼다. 로그는 단조 증가 함수이므로 L(β)L(\beta)를 최대화하는 β\betalogL(β)\log L(\beta)를 최대화하는 β\beta가 같다.

(β)=i=1nlog11+eyiβTxi\ell(\beta) = \sum_{i=1}^{n} \log \cfrac{1}{1 + e^{-y_i \beta^T x_i}}

로그 안의 역수를 바깥으로 빼내면(즉, log(1a)=loga\log(\frac{1}{a}) = -\log a를 적용하면) 다음 형태가 된다.

(β)=i=1nlog(1+eyiβTxi)\ell(\beta) = -\sum_{i=1}^{n} \log(1 + e^{-y_i \beta^T x_i})

식 앞에 마이너스가 붙어 있다. 그러므로 (β)\ell(\beta)최대화하는 일은 마이너스를 떼어 낸 다음 값을 최소화하는 일과 정확히 같다.

i=1nlog(1+eyiβTxi)\sum_{i=1}^{n} \log(1 + e^{-y_i \beta^T x_i})

이 값이 로지스틱 회귀의 로그 손실(log loss)이다. 학습의 목표는 이 손실을 가장 작게 만드는 β\beta를 찾는 것이다. 수식으로는 다음처럼 쓴다.

β^=argminβi=1nlog(1+eyiβTxi)\hat{\beta} = \arg\min_\beta \sum_{i=1}^{n} \log(1 + e^{-y_i \beta^T x_i})

여기서 argmin\arg\min은 “최솟값을 만드는 입력값”이라는 뜻이다. 즉, 위 식은 손실값 자체가 아니라, 그 손실을 가장 작게 만드는 파라미터 β\beta를 찾는다는 의미다.

최적화 라이브러리 대부분은 최소화를 기본으로 한다. 그래서 실무에서는 우도를 최대화한다고 말하기보다, 로그 손실을 최소화한다고 표현하는 경우가 많다.

손실이 동작하는 원리

이 손실이 왜 합리적인지 들여다보자. 핵심은 yiβTxiy_i \beta^T x_i라는 한 덩어리다. 이 값은 “모델이 정답 방향으로 얼마나 강하게 예측했는가”를 한 숫자로 압축한다.

정답이 +1+1일 때는 βTxi\beta^T x_i가 큰 양수일수록 좋고, 정답이 1-1일 때는 βTxi\beta^T x_i가 큰 음수일수록 좋다. 두 경우 모두 정답과 예측의 부호가 일치할 때 yiβTxiy_i \beta^T x_i는 큰 양수가 된다.

yiβTxiy_i \beta^T x_i가 큰 양수이면 eyiβTxie^{-y_i \beta^T x_i}는 0에 가까운 값이 되고, log(1+eyiβTxi)\log(1 + e^{-y_i \beta^T x_i})도 0에 가까워진다. 손실이 거의 0이 되는 셈이다. 모델이 자신 있게 정답을 맞췄을 때 손실이 0에 수렴하는 것은 자연스럽다.

반대로 yiβTxiy_i \beta^T x_i가 큰 음수라면 모델이 정답과 반대 방향으로 강하게 예측한 셈이다. 정답은 +1+1인데 점수가 큰 음수이거나, 정답은 1-1인데 점수가 큰 양수인 경우다. 이때 eyiβTxie^{-y_i \beta^T x_i}는 폭발적으로 커지고 로그 손실도 함께 커진다. 틀린 예측을 강하게 할수록 큰 벌점을 받는다. 자신감 있게 틀린 모델이 가장 호되게 혼나는 구조다.

0/1 표기로 다시 쓰기

지금까지는 수식을 깔끔하게 쓰기 위해 1/+1-1/+1 표기를 사용했다. 하지만 실제 코드와 머신러닝 라이브러리에서는 보통 정답 레이블을 0/10/1로 둔다. 이 표기로 다시 쓰면, 우리가 흔히 보는 크로스 엔트로피 손실이 나온다.

이제 yi{0,1}y_i \in \{0, 1\}이라 두고, 모델이 예측한 positive class 확률을 다음처럼 쓰자.

pi=σ(βTxi)=11+eβTxip_i = \sigma(\beta^T x_i) = \cfrac{1}{1 + e^{-\beta^T x_i}}

손실 함수는 다음 식이 된다.

Loss(β)=1ni=1n[yilogpi+(1yi)log(1pi)]\text{Loss}(\beta) = -\cfrac{1}{n}\sum_{i=1}^{n}\left[y_i \log p_i + (1-y_i)\log(1-p_i)\right]

이 식을 크로스 엔트로피 손실(cross-entropy loss)이라 부른다. 대괄호 안에 항이 두 개 있지만 사실 한 번에 하나만 살아남는 구조다.

정답이 yi=1y_i = 1이면 (1yi)=0(1 - y_i) = 0이므로 뒤 항이 사라지고 logpi-\log p_i만 남는다. 모델이 pip_i를 1에 가깝게 만들수록 logpi-\log p_i가 0에 가까워져 손실이 작아진다.

정답이 yi=0y_i = 0이면 반대로 앞 항이 사라지고 log(1pi)-\log(1 - p_i)만 남는다. 모델이 pip_i를 0에 가깝게 만들수록 log(1pi)-\log(1 - p_i)가 0에 가까워져 손실이 작아진다.

두 경우를 한 줄로 묶으면 결국 이런 원리다.

정답 클래스에 높은 확률을 주면 손실이 작아지고, 낮은 확률을 주면 손실이 커진다.

표기만 다를 뿐 앞서 유도한 1/+1-1/+1 표기의 로그 손실과 정확히 같은 내용이다. 신경망으로 이미지를 분류하든 언어 모델로 다음 토큰을 예측하든, 내부에서는 이 계열의 손실 함수가 계속 등장한다.

닫힌 해는 없다

이제 자연스레 다음 질문이 따라온다. 로그 손실을 β\beta로 미분하고 0으로 놓으면, 선형 회귀의 정규 방정식처럼 답이 한 번에 나올까? 결론부터 말하면 그렇지 않다. 로지스틱 회귀에는 일반적으로 닫힌 해가 없다.

ml-4-12.png

로지스틱 회귀의 MLE 식을 미분할 수는 있지만, β\beta를 닫힌 형태로 풀어내는 식은 나오지 않는다.

선형 회귀에서는 RSS를 미분해 0으로 놓으면 식이 β\beta에 대해 선형이었다. 그래서 다음처럼 한 줄 식으로 정리할 수 있었다.

β^=(XTX)1XTy\hat{\beta} = (\mathbf{X}^T\mathbf{X})^{-1}\mathbf{X}^T\mathbf{y}

로지스틱 회귀는 다르다. 시그모이드 함수가 들어가면서 미분식이 β\beta에 대해 비선형이 된다. 지수 함수가 끼어 있기 때문에 β=\beta = \cdots 꼴로 깔끔하게 풀리지 않는다. 손실 함수도 계산할 수 있고, 미분도 할 수 있지만, 최적의 β\beta를 공식 한 줄로 구할 수는 없다.

그렇다고 학습이 막히는 것은 아니다. 답을 한 번에 구하지 못하면, 손실이 줄어드는 방향으로 조금씩 움직이면 된다. 이 생각이 바로 경사 하강법(gradient descent)이다.

이 전환은 중요하다. 선형 회귀는 공식을 풀어 답을 찾는 모델에 가까웠다. 로지스틱 회귀부터는 손실 함수를 정하고, 그 손실이 줄어드는 방향으로 파라미터를 반복해서 업데이트한다. 이후 신경망과 트랜스포머도 같은 방식으로 학습한다.

경사 하강법 - 조금씩 답을 찾아가기

닫힌 해가 없다는 말이 답이 없다는 뜻은 아니다. 식 한 줄로 한 번에 풀 수 없을 뿐이다. 이때 문제를 바라보는 관점을 한 번 바꿔야 한다. 손실 함수를 가장 작게 만드는 β\boldsymbol{\beta}를 찾는 일, 그러니까 최적화 문제로 다시 정의하는 것이다. 손실 함수 Loss(β)Loss(\beta)β\beta를 입력으로 받아 하나의 실수를 내는 함수다. 이 함수의 그래프를 머릿속에 그려 보자. 우리가 찾는 답은 이 그래프에서 가장 낮은 지점이다.

산을 내려가는 비유

경사 하강법의 아이디어는 단순하다. 눈을 가린 채 산에서 내려가야 한다고 해보자. 주변은 전혀 보이지 않는다. 발밑의 경사만 느낄 수 있다. 이때 할 수 있는 최선은 발끝으로 사방을 더듬어 보고, 지금 위치에서 가장 가파르게 내려가는 방향으로 한 걸음 옮기는 일이다. 그렇게 옮긴 새 자리에서 다시 발밑을 더듬는다. 또 한 걸음. 끝없이 반복하다 보면 어느 순간 더 내려갈 곳이 없는 지점에 도착한다. 거기가 골짜기다.

이 비유의 핵심은 지역 정보만으로 목적지에 다가갈 수 있다는 점이다. 산 전체 지도를 알 필요가 없다. 매 순간 발밑의 경사 하나만 있으면 된다. 손실 함수가 아무리 복잡해도 한 점에서 미분만 할 수 있다면 같은 전략이 통한다.

수학적으로 다변수 함수에서 “가장 가파르게 증가하는 방향”을 가리키는 벡터가 기울기(gradient) Loss(β)\nabla Loss(\beta)다. 손실을 줄이고 싶다면 이 벡터의 반대 방향으로 움직이면 된다. 그래서 업데이트 식은 다음과 같이 생겼다.

βnew=βoldαLoss(βold)\beta_{\text{new}} = \beta_{\text{old}} - \alpha \cdot \nabla Loss(\beta_{\text{old}})

기울기 자체는 손실이 커지는 방향을 가리키므로, 부호를 뒤집어야 손실이 줄어드는 방향이 된다. 식 앞에 마이너스가 붙는 이유가 여기에 있다. 한 변수만으로 다시 풀어 보면 직관이 더 분명해진다. 어느 지점에서 미분값이 양수라면 오른쪽으로 갈수록 손실이 커진다는 뜻이므로 β\beta를 줄여야 한다. 미분값이 음수라면 반대로 β\beta를 키워야 한다. 두 경우 모두 - gradient가 가야 할 방향을 정확히 가리킨다.

식 안의 α\alpha학습률(learning rate)이라 부른다. 한 걸음의 보폭을 결정하는 값이다. 너무 크면 골짜기를 지나쳐 반대쪽 사면으로 넘어가 진동하거나 아예 발산한다. 너무 작으면 안전하지만 골짜기에 닿기까지 한없이 오래 걸린다. 적절한 학습률을 고르는 일이 경사 하강법 실무의 가장 큰 골칫거리 중 하나다.

다변수에서 기울기 벡터를 풀어 쓰면, 각 파라미터에 대한 편미분을 한 줄씩 쌓은 형태다. 그래서 위 식은 각 파라미터별 업데이트로 다시 쓸 수 있다.

βjnew=βjoldαLossβj\beta_j^{\text{new}} = \beta_j^{\text{old}} - \alpha \cfrac{\partial Loss}{\partial \beta_j}

벡터로 한 번에 쓰든 파라미터별로 따로 쓰든 같은 연산이다. 편의에 따라 골라 사용하면 된다.

언제 멈출 것인가

산을 내려간다고 했지만, 발밑 경사가 완벽히 0이 되는 지점에 정확히 닿는 일은 거의 없다. 컴퓨터 연산은 유한한 정밀도를 갖기 때문이다. 그래서 경사 하강법은 “어느 정도 가까이 갔으면 그만 멈춘다”라는 종료 조건을 따로 둔다.

가장 흔한 두 가지 기준은 다음과 같다. 첫째, 기울기 크기가 충분히 작아졌을 때 멈춘다. 발밑이 거의 평평해졌다는 뜻이다.

Loss(β)<ϵ\|\nabla Loss(\beta)\| < \epsilon

둘째, 손실 값이 더 이상 의미 있게 줄어들지 않을 때 멈춘다. 직전 반복과 비교해 손실 감소가 미리 정한 임계값보다 작아지면 종료한다. 실무에서는 두 조건을 함께 쓰고, 여기에 “최대 반복 횟수”라는 안전장치를 추가해 무한 루프를 막는다.

ml-4-15.png

현재 위치의 기울기를 보고 손실이 줄어드는 방향으로 한 걸음씩 이동한다. 이 과정을 반복하면 최저점 근처에 도달한다.

로지스틱 회귀에 적용해 보기

말로 설명한 전략을 코드로 옮겨 보자. 신용카드 결제금액(balance)을 입력으로, 채무 불이행 여부(default)를 출력으로 두는 간단한 예제다. 데이터의 95%는 정상, 5%는 불이행이라는 불균형한 상황이다. 핵심은 두 함수다. 손실을 계산하는 logistic_loss와 그 손실의 기울기를 계산하는 logistic_gradient. 경사 하강법 루프는 매 반복마다 기울기를 계산하고, 그 반대 방향으로 β\beta를 조금씩 옮긴다.

기울기 식은 미리 한 번 짚어 두자. 로지스틱 회귀의 손실을 β\beta로 미분하면 다음 형태가 나온다.

Loss(β)=1nXT(py)\nabla Loss(\beta) = \cfrac{1}{n}X^T(p-y)

pp는 모델이 예측한 확률, yy는 실제 레이블이다. (py)(p - y)는 “각 데이터에서 모델이 얼마나 틀렸는가”를 나타내고, 거기에 입력 XX를 곱해 어느 방향으로 얼마나 고쳐야 할지를 만든다. 시그모이드라는 비선형 함수가 들어 있었는데도 기울기는 이렇게 깔끔하다. 우연이 아니라 시그모이드와 로그 손실 조합의 수학적 특성 덕분이다.

import numpy as np
# 신용카드 사용자 데이터
np.random.seed(42)
income = np.concatenate([
np.random.normal(55, 12, 95),
np.random.normal(56, 12, 5),
])
balance = np.concatenate([
np.random.normal(850, 350, 95),
np.random.normal(1900, 220, 5),
])
y = np.array([0] * 95 + [1] * 5)
# 시그모이드 함수
def sigmoid(z):
return 1 / (1 + np.exp(-z))
# 로지스틱 회귀의 손실 함수
def logistic_loss(X, y, beta):
z = X @ beta
probs = sigmoid(z)
probs = np.clip(probs, 1e-10, 1 - 1e-10)
return -np.mean(y * np.log(probs) + (1 - y) * np.log(1 - probs))
# 손실 함수의 기울기
def logistic_gradient(X, y, beta):
z = X @ beta
probs = sigmoid(z)
return X.T @ (probs - y) / len(y)
# 경사 하강법
def gradient_descent(X, y, lr=0.1, n_iters=1000):
beta = np.zeros(X.shape[1])
losses = []
for i in range(n_iters):
loss = logistic_loss(X, y, beta)
grad = logistic_gradient(X, y, beta)
beta = beta - lr * grad
losses.append(loss)
if i % 250 == 0:
print(f"iter {i:4d} | loss: {loss:.6f}")
return beta, losses
# 데이터 정규화 후 학습
balance_scaled = (balance - balance.mean()) / balance.std()
X_logistic = np.column_stack([np.ones(len(balance_scaled)), balance_scaled])
beta_learned, losses = gradient_descent(X_logistic, y, lr=0.5, n_iters=1000)
# 결과 확인
probs = sigmoid(X_logistic @ beta_learned)
preds = (probs >= 0.5).astype(int)
print(f"final loss: {losses[-1]:.6f}")
print(f"final beta: {np.round(beta_learned, 4)}")
print(f"accuracy: {np.mean(preds == y):.2%}")
iter 0 | loss: 0.693147
iter 250 | loss: 0.066769
iter 500 | loss: 0.057737
iter 750 | loss: 0.054180
final loss: 0.052239
final beta: [-6.5603 3.0857]
accuracy: 97.00%

결과를 짚어 보자. 초기 손실 0.6930.693은 우연이 아니다. β=0\beta = 0에서 출발하면 모델이 모든 데이터에 확률 0.5를 부여하므로 손실이 정확히 log0.50.693-\log 0.5 \approx 0.693이 된다. 거기서 시작해 1,000번 반복하는 동안 손실은 0.0520.052까지 떨어졌고, 정확도는 97%에 도달했다. 닫힌 해가 없다는 결론에서 학습된 β=[6.56,3.09]\beta = [-6.56, 3.09]라는 구체적인 숫자에 도달하기까지, 단 한 줄짜리 업데이트 식 ββαLoss\beta \leftarrow \beta - \alpha \nabla Loss를 1,000번 반복한 것이 전부다.

경사 하강법의 한계

경사 하강법은 단순하고 강력하다. 손실이 줄어드는 방향으로 파라미터를 조금씩 움직이면 된다. 하지만 이 방법이 언제나 쉽게 작동하는 것은 아니다. 실제로는 몇 가지 문제가 따라온다.

첫째, 지역 최적점(Local Optima) 문제다. 손실 함수를 산과 골짜기로 생각해보자. 풍경이 하나의 큰 그릇처럼 매끄럽게 생겼다면 어디서 출발해도 결국 가장 낮은 지점으로 내려갈 수 있다. 이런 함수를 볼록 함수(convex function)라고 부른다.

다행히 로지스틱 회귀의 로그 손실은 볼록 함수다. 적절한 학습률을 쓰면 시작점이 달라도 같은 최저점에 도달할 수 있다. 즉, 로지스틱 회귀에서는 지역 최적점 문제가 크게 걱정할 대상은 아니다.

문제는 신경망처럼 훨씬 복잡한 모델에서 생긴다. 신경망의 손실 함수는 보통 비볼록이다. 산길이 울퉁불퉁하고, 작은 골짜기와 평평한 고개가 여기저기 흩어져 있다. 이때 경사 하강법은 가장 낮은 골짜기가 아니라 근처의 작은 골짜기에 머물 수 있다. 또는 기울기가 거의 0이지만 최저점은 아닌 안장점(saddle point) 근처에서 느려질 수도 있다. 모멘텀, Adam 같은 최적화 기법은 이런 어려움을 줄이기 위해 등장했다.

둘째, 기울기를 계산할 수 있어야 한다. 경사 하강법은 이름 그대로 경사를 따라 움직이는 방법이다. 따라서 손실 함수가 현재 위치에서 어느 방향으로 증가하고 감소하는지 계산할 수 있어야 한다. 즉, 미분 가능해야 한다.

만약 손실 함수가 계단처럼 뚝뚝 끊겨 있거나, 뾰족하게 꺾이는 지점이 많다면 기울기를 그대로 계산하기 어렵다. 이런 경우에는 일반적인 경사 하강법을 바로 쓰기 힘들다. 필요하면 서브그래디언트(subgradient)를 쓰거나, 미분 가능한 함수로 근사해 문제를 푼다. 지금 단계에서는 “경사 하강법은 기울기를 계산할 수 있어야 쓸 수 있다” 정도로 이해하면 충분하다.

셋째, 수렴이 느릴 수 있다. 경사 하강법은 한 번에 답으로 점프하지 않는다. 손실이 줄어드는 방향으로 조금씩 움직인다. 그래서 골짜기까지 가는 데 시간이 오래 걸릴 수 있다.

특히 최저점 근처에서는 경사가 완만해진다. 발밑이 거의 평평해지면 어느 방향으로 가야 할지 신호가 약해진다. 이때 학습률을 너무 작게 잡으면 거의 움직이지 못하고, 너무 크게 잡으면 최저점을 지나쳐 반대쪽으로 튈 수 있다. 학습률 조절이 중요한 이유가 여기에 있다. 학습률 스케줄링이나 적응적 학습률 기법은 이 문제를 줄이기 위한 방법이다.

넷째, 계산량이 크다. 기본 경사 하강법은 한 번 업데이트할 때 전체 데이터를 모두 사용해 기울기를 계산한다. 데이터가 100만 개라면 한 걸음 움직일 때마다 100만 개 샘플을 전부 봐야 한다. 이 과정을 1,000번 반복하면 총 10억 개 샘플을 처리하는 셈이다.

작은 데이터에서는 큰 문제가 아니다. 하지만 현대 머신러닝에서는 데이터가 너무 크다. 매번 전체 데이터를 다 보고 움직이는 방식은 금방 부담이 된다. 그래서 실제로는 전체 데이터 대신 일부 샘플만 보고 기울기를 추정하는 방법을 쓴다. 다음 절에서 볼 확률적 경사 하강법(SGD)이 바로 이 문제를 해결하기 위한 방법이다.

ml-4-17.png

비볼록성, 미분 불가능성, 느린 수렴, 계산량은 기본 경사 하강법을 실제 대규모 학습에 그대로 쓰기 어렵게 만든다.

네 가지 문제 가운데 로지스틱 회귀에서 가장 먼저 부딪히는 한계는 계산량이다. 데이터가 늘어날수록 한 걸음 옮기는 비용이 비례해서 커지기 때문이다. 이 문제를 정면으로 풀기 위해 등장한 방법이 다음 절의 주제, 확률적 경사 하강법(Stochastic Gradient Descent, SGD)이다. 전체 데이터를 다 보지 않고도 한 걸음을 옮기는 영리한 변형이다.

SGD - 전체를 보지 않고 조금씩 내려가기

확률적 경사 하강법(Stochastic Gradient Descent, SGD)은 전체 데이터를 보는 대신 매번 일부 데이터만 보고 gradient를 계산하는 방법이다. 한 번에 샘플 하나만 보면 순수한 SGD이고, 보통은 수십 개나 수백 개씩 묶어 보는 미니배치(mini-batch) 경사 하강법을 쓴다. 현실에서 “SGD”라고 부르는 것은 대부분 미니배치 버전이다.

ml-4-18.png

SGD는 전체 데이터가 아니라 일부 미니배치로 gradient를 계산한다. 배치가 커질수록 안정적이지만 계산량도 커진다.
import numpy as np
# 신용카드 사용자 데이터
np.random.seed(42)
income = np.concatenate([
np.random.normal(55, 12, 95),
np.random.normal(56, 12, 5),
])
balance = np.concatenate([
np.random.normal(850, 350, 95),
np.random.normal(1900, 220, 5),
])
y = np.array([0] * 95 + [1] * 5)
# 로지스틱 회귀 학습에 필요한 함수
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def logistic_loss(X, y, beta):
z = X @ beta
probs = sigmoid(z)
probs = np.clip(probs, 1e-10, 1 - 1e-10)
return -np.mean(y * np.log(probs) + (1 - y) * np.log(1 - probs))
def logistic_gradient(X, y, beta):
z = X @ beta
probs = sigmoid(z)
return X.T @ (probs - y) / len(y)
# 미니배치 SGD
def sgd(X, y, lr=0.1, n_iters=1000, batch_size=16):
beta = np.zeros(X.shape[1])
n = len(y)
losses = []
for i in range(n_iters):
idx = np.random.choice(n, batch_size, replace=False)
X_batch = X[idx]
y_batch = y[idx]
grad = logistic_gradient(X_batch, y_batch, beta)
beta = beta - lr * grad
if i % 200 == 0:
losses.append(logistic_loss(X, y, beta))
return beta, losses
balance_scaled = (balance - balance.mean()) / balance.std()
X_logistic = np.column_stack([np.ones(len(balance_scaled)), balance_scaled])
np.random.seed(7)
beta_sgd, losses_sgd = sgd(X_logistic, y, lr=0.5, n_iters=1000, batch_size=16)
print("monitored losses:", np.round(losses_sgd, 4))
print("sgd beta:", np.round(beta_sgd, 4))
monitored losses: [0.5866 0.0734 0.0613 0.0571 0.0556]
sgd beta: [-6.5011 3.202 ]

SGD의 장점은 속도다. 데이터가 100만 개여도 배치 크기가 32라면 한 스텝에서 32개만 보면 된다. 대신 단점도 있다. 매번 보는 데이터가 다르므로 gradient가 흔들리고, 손실 곡선도 울퉁불퉁해진다.

그래서 SGD에서는 “직전 스텝보다 손실이 조금 올라갔으니 멈춘다” 같은 규칙을 쓰면 위험하다. 미니배치가 우연히 대표성이 낮으면 한두 번 손실이 올라갈 수 있기 때문이다. 보통은 정해진 횟수만큼 학습하거나, 최근 여러 번의 이동 평균을 보고 더 이상 개선되지 않을 때 멈춘다. 이 계열의 아이디어가 early stopping, learning rate scheduling, Adam, AdamW 같은 현대 옵티마이저로 이어진다.

여러 피처를 넣어도 구조는 같다

지금까지는 설명을 단순하게 하려고 balance 하나만 썼다. 하지만 로지스틱 회귀는 입력 변수가 여러 개여도 자연스럽게 확장된다.

로지스틱 회귀의 핵심 가정은 로그 오즈가 입력에 대해 선형이라는 것이다. 피처가 pp개라면 이렇게 쓰면 된다.

logP(y=1x)P(y=0x)=β0+β1x1+β2x2++βpxp\log \cfrac{P(y=1|x)}{P(y=0|x)} = \beta_0 + \beta_1 x_1 + \beta_2 x_2 + \cdots + \beta_p x_p

이를 벡터 형태로 쓰면 βTx\beta^T x다. 그래서 확률은 그대로 다음과 같다.

P(y=1x)=11+e(β0+β1x1++βpxp)P(y=1|x) = \cfrac{1}{1 + e^{-(\beta_0 + \beta_1 x_1 + \cdots + \beta_p x_p)}}

즉 변수가 하나든 백 개든 구조는 같다. 달라지는 것은 β\betaxx가 더 긴 벡터가 된다는 점뿐이다. income, balance, age, credit_score 같은 피처를 모두 넣어도 모델의 기본 형태는 바뀌지 않는다.

다중 클래스 분류 - 소프트맥스로의 확장

지금까지는 “예/아니오”의 이진 분류였다. 클래스가 3개 이상인 경우는 어떻게 할까? 개/고양이/새를 구분하거나, 한국어 품사 20개를 구분하거나, LLM이 다음 토큰을 어휘집 안의 수만 개 후보 중 하나로 고르는 문제를 생각하면 된다.

이때 시그모이드를 일반화한 도구가 소프트맥스(Softmax)다.

아이디어는 이렇다. 각 클래스마다 자기만의 점수 sks_k를 계산한다.

sk=βkTxs_k = \beta_k^T x

그 다음 각 점수에 지수 함수를 씌워 양수로 만들고, 전체 합으로 나누어 확률 분포를 만든다.

P(y=kx)=eskj=1KesjP(y=k|x) = \cfrac{e^{s_k}}{\sum_{j=1}^{K} e^{s_j}}

ml-4-23.png

소프트맥스는 각 클래스의 점수를 지수화한 뒤 전체 합으로 나누어 확률 분포를 만든다.

이 식이 확률 조건을 만족하는지 확인해보자.

  1. 지수 함수의 출력은 항상 양수다.
  2. 각 클래스 확률은 자기 지수값을 전체 지수값의 합으로 나눈 것이므로 0과 1 사이다.
  3. 모든 클래스 확률을 더하면 분자들이 분모와 같아지므로 합이 1이다.

코드로 쓰면 이렇게 된다.

import numpy as np
# 소프트맥스 함수
def softmax(scores):
shifted = scores - np.max(scores, axis=1, keepdims=True)
exp_scores = np.exp(shifted)
return exp_scores / np.sum(exp_scores, axis=1, keepdims=True)
# 3개 클래스에 대한 점수
scores = np.array([
[2.0, 1.0, 0.1],
[0.5, 2.5, 0.3],
[0.1, 0.3, 3.0],
])
probs = softmax(scores)
print(np.round(probs, 4))
print("row sums:", probs.sum(axis=1))
print("predicted classes:", np.argmax(probs, axis=1))
[[0.659 0.2424 0.0986]
[0.1086 0.8025 0.0889]
[0.049 0.0599 0.8911]]
row sums: [1. 1. 1.]
predicted classes: [0 1 2]

코드에서 최댓값을 빼는 이유는 수치 안정성 때문이다. 소프트맥스는 각 점수에 지수 함수 eze^z를 적용한다. 이때 zz가 너무 크면 eze^z가 순식간에 커져 컴퓨터가 표현할 수 있는 범위를 넘어설 수 있다. 이를 오버플로우라고 한다. 모든 점수에서 같은 값을 빼도 소프트맥스 결과는 바뀌지 않으므로, 가장 큰 점수를 0으로 맞춘 뒤 계산하면 더 안전하다.

시그모이드와 소프트맥스의 관계

클래스가 두 개인 소프트맥스를 수식으로 풀어보면 시그모이드가 나온다. 두 클래스의 점수를 s1s_1, s0s_0라고 하자.

P(y=1x)=es1es0+es1P(y=1|x) = \cfrac{e^{s_1}}{e^{s_0} + e^{s_1}}

분자와 분모를 es1e^{s_1}로 나누면 다음과 같다.

P(y=1x)=11+e(s1s0)P(y=1|x) = \cfrac{1}{1 + e^{-(s_1 - s_0)}}

이것은 시그모이드다.

P(y=1x)=σ(s1s0)P(y=1|x) = \sigma(s_1 - s_0)

따라서 시그모이드는 소프트맥스의 특수한 경우이고, 소프트맥스는 시그모이드의 다중 클래스 확장이라고 볼 수 있다. 현대 LLM이 하는 “다음 토큰 예측”도 본질적으로는 거대한 소프트맥스 분류 문제다. 어휘집 크기가 10만이라면, 모델은 매 토큰마다 10만 개 클래스에 대한 확률 분포를 만든다.

학습 과정 전체 정리

로지스틱 회귀의 전체 흐름을 지도 학습의 4단계로 정리하면 이렇다.

1단계 - 모델 형태 결정: 입력 xx로 점수 βTx\beta^T x를 만들고, 시그모이드 또는 소프트맥스를 씌워 확률로 해석한다.

2단계 - 목표 정의: 관측된 레이블의 likelihood를 최대화한다. 동등하게는 로그 손실 또는 크로스 엔트로피 손실을 최소화한다.

3단계 - 학습: 닫힌 해가 없으므로 경사 하강법, SGD, Adam 같은 최적화 방법으로 β\beta를 반복 업데이트한다.

4단계 - 예측: 학습된 β\beta로 새 데이터의 확률을 계산하고, 이진 분류에서는 임계값을 기준으로 클래스를 결정한다.

이제 전체 과정을 코드로 살펴보자.

import numpy as np
# 예제 데이터
np.random.seed(42)
balance = np.concatenate([
np.random.normal(850, 350, 95),
np.random.normal(1900, 220, 5),
])
y = np.array([0] * 95 + [1] * 5)
# 시그모이드 함수
def sigmoid(z):
return 1 / (1 + np.exp(-z))
class LogisticRegression:
def __init__(self, lr=0.1, n_iters=1000):
self.lr = lr
self.n_iters = n_iters
self.beta = None
def fit(self, X, y):
X_aug = np.column_stack([np.ones(len(X)), X])
self.beta = np.zeros(X_aug.shape[1])
for _ in range(self.n_iters):
probs = sigmoid(X_aug @ self.beta)
grad = X_aug.T @ (probs - y) / len(y)
self.beta = self.beta - self.lr * grad
def predict_proba(self, X):
X_aug = np.column_stack([np.ones(len(X)), X])
return sigmoid(X_aug @ self.beta)
def predict(self, X, threshold=0.5):
return (self.predict_proba(X) >= threshold).astype(int)
balance_scaled = ((balance - balance.mean()) / balance.std()).reshape(-1, 1)
model = LogisticRegression(lr=0.5, n_iters=1000)
model.fit(balance_scaled, y)
preds = model.predict(balance_scaled)
print("beta:", np.round(model.beta, 4))
print(f"accuracy: {np.mean(preds == y):.2%}")
print(f"defaults predicted: {preds.sum()}")
beta: [-7. 3.6222]
accuracy: 99.00%
defaults predicted: 4

몇 줄의 코드 안에 지도 학습의 4단계, 시그모이드, 로그 손실, 경사 하강법, 이진 분류의 전 과정이 들어 있다. 현대의 큰 신경망은 훨씬 복잡하지만, 기본 골격은 여기서 크게 벗어나지 않는다.

임계값이라는 실용적 주제

로지스틱 회귀는 확률을 내놓는다. 하지만 서비스에서는 결국 클래스를 결정해야 한다. 보통 이진 분류에서는 0.5를 기준으로 삼는다.

{1if P(y=1x)0.50otherwise\begin{cases} 1 & \text{if } P(y=1|x) \ge 0.5 \\ 0 & \text{otherwise} \end{cases}

그런데 0.5가 항상 최선은 아니다.

암 진단을 생각해보자. 모델이 어떤 환자에 대해 “암일 확률 40%“라고 예측했다. 임계값 0.5를 쓰면 이 환자는 정상으로 분류된다. 이게 옳을까? 40% 확률로 암이라고 보면서 “정상”이라고 말하기는 어렵다. 이 경우에는 임계값을 더 낮게 설정해 조금만 의심스러워도 추가 검사를 권고하는 쪽이 합리적이다.

반대 경우도 있다. 스팸 필터가 “80% 확률로 스팸”이라고 예측한 이메일이 사실은 중요한 업무 메일이라면? 한 번의 오탐(false positive)으로 큰 손실이 생길 수 있다. 이 경우에는 임계값을 높여 확실한 것만 스팸으로 보내는 정책이 더 낫다.

임계값의 선택은 비용의 비대칭성을 반영하는 결정이다. False Positive와 False Negative의 비용이 다르면 임계값도 달라져야 한다. 이 조정에 대한 더 체계적인 분석은 Confusion Matrix, Precision, Recall, ROC 곡선, AUC 같은 평가 지표에서 이어진다.

다음 글 예고

로지스틱 회귀는 분류 문제를 확률로 다루는 가장 기본 모델이다. 입력 xx가 주어졌을 때, 각 클래스에 속할 확률을 직접 계산한다. 지금까지 본 방식은 결국 “이 데이터가 어느 클래스에 속할 가능성이 큰가?”를 바로 모델링하는 접근이었다.

이런 모델을 판별 모델(discriminative model)이라고 부른다. 판별 모델은 클래스 사이의 경계를 배우는 데 초점을 둔다. 로지스틱 회귀는 P(yx)P(y \mid x), 즉 입력이 주어졌을 때 클래스가 무엇일지를 직접 추정한다.

분류에는 다른 접근도 있다. 클래스를 바로 맞히기보다, 먼저 각 클래스의 데이터가 어떻게 생겼는지를 모델링하는 방식이다. 예를 들어 “스팸 메일은 보통 어떤 단어 분포를 갖는가?”, “정상 메일은 어떤 단어 분포를 갖는가?”를 먼저 배운 뒤, 새 메일이 들어오면 어느 쪽 분포에서 나왔을 가능성이 큰지 판단한다.

이런 접근을 생성 모델(generative model)이라고 부른다. 생성 모델은 P(yx)P(y \mid x)를 바로 배우기보다, 각 클래스에서 데이터가 생성되는 방식, 즉 P(xy)P(x \mid y)를 모델링한다. 그런 다음 베이즈 정리를 이용해 우리가 원하는 P(yx)P(y \mid x)를 계산한다.

다음 글에서는 이 생성 모델 계열을 다룬다. 베이즈 정리로 사후 확률을 계산하는 방법을 살펴보고, 데이터 분포를 가정해 분류하는 판별 분석(QDA, LDA)나이브 베이즈의 원리를 배운다. 마지막에는 분류 모델을 제대로 평가하기 위한 지표까지 이어간다.






이미지 출처: [ML/DL] Lecture 5. Classification I