Published on

NumPy로 소프트맥스 회귀 밑바닥부터 구현하기

Authors

TensorTonic의 Softmax Regression 문제를 NumPy만으로 경사하강법으로 풀면서 정리했다. 로지스틱 회귀를 2개 클래스에서 KK개 클래스로 일반화한 모델이다. 출력이 벡터가 되면서 가중치와 편향의 모양이 바뀌고 cross-entropy 손실과 소프트맥스의 조합이 등장한다. 수식 자체는 로지스틱 회귀와 거의 같지만 "벡터 출력"이라는 차이 하나가 구현을 어떻게 바꾸는지가 핵심이라 그 부분을 중심으로 다룬다.

소프트맥스 회귀 모델

소프트맥스 회귀(Softmax Regression)는 로지스틱 회귀를 KK개 클래스로 확장한 모델이다. 분류 신경망의 마지막 출력층이 곧 소프트맥스 회귀라서 ML 인터뷰에서도 단독 코딩 문제로 자주 등장한다. 소프트맥스 함수와 cross-entropy 손실 그리고 수치 안정화 트릭만 정리해 두면 분류 신경망의 출력단에서 벌어지는 일을 그대로 이해할 수 있다.

샘플 수 nn, feature 수 dd, 클래스 수 KK일 때 모델은 다음과 같다.

Z=XW+1bRn×KZ = XW + \mathbf{1}\mathbf{b}^\top \in \mathbb{R}^{n \times K}
  • WRd×KW \in \mathbb{R}^{d \times K}: 가중치 행렬 (클래스마다 열 벡터 하나씩)
  • bRK\mathbf{b} \in \mathbb{R}^K: 편향 벡터
  • ZikZ_{ik}: 샘플 ii가 클래스 kk에 대해 갖는 로짓(정규화 전 점수)

편향이 왜 스칼라가 아니라 벡터인가

선형 회귀와 이진 로지스틱 회귀에서는 bb가 숫자 하나였는데 소프트맥스 회귀에서는 벡터다. 규칙은 하나로 설명된다.

편향의 개수 = 출력의 개수

출력 수WW 모양bb 모양
선형 회귀1(d,)(d,)스칼라
이진 로지스틱1(d,)(d,)스칼라
소프트맥스 회귀KK(d,K)(d, K)(K,)(K,) 벡터

KK개 클래스를 분류한다는 것은 "클래스마다 선형 함수를 하나씩 돌린다"는 뜻이다. 각 클래스가 자기만의 가중치 벡터와 편향을 갖는다. 예를 들어 알파벳 26자를 분류하려면 "A를 알아보는 눈"부터 "Z를 알아보는 눈"까지 26개의 선형 함수가 필요하고 각 함수에 편향이 하나씩 따라붙는다.

편향은 "그 클래스가 기본적으로 얼마나 자주 등장하는가"를 담당한다. 학습 데이터에 특정 클래스가 드물게 나타나면 해당 클래스의 편향이 낮게 학습된다. 클래스마다 등장 빈도가 다르므로 편향도 클래스 수만큼 필요하다.

소프트맥스 함수

로짓 ZZ는 그냥 실수일 뿐 확률이 아니다. 음수도 있고 합도 1이 아니다. 이것을 확률 분포로 바꾸는 함수가 소프트맥스다.

P(y=kxi)=ezikj=1KezijP(y = k \mid \mathbf{x}_i) = \frac{e^{z_{ik}}}{\sum_{j=1}^{K} e^{z_{ij}}}

이 함수가 만드는 출력은 세 조건을 항상 만족한다.

  • 모든 원소가 0 이상 (exp는 항상 양수)
  • 모든 원소가 1 이하 (분모 \geq 분자)
  • KK개를 더하면 정확히 1

세 번째 조건은 수식에서 자동으로 따라 나온다. KK개 항의 분모가 전부 같고 분자들의 합이 곧 그 분모이므로 다 더하면 분모/분모 = 1이다. 즉 소프트맥스의 분모가 "정규화 상수" 역할을 해서 확률 분포의 조건이 따로 강제하지 않아도 충족된다.

exp를 씌우는가

"그냥 값을 합으로 나누면 안 되나?"라는 의문이 먼저 들었다. 로짓을 바로 비율로 만들지 않고 exp를 한 번 거쳐서 확률로 바꾸는 이유는 여러 가지가 맞물려 있다.

음수 제거. 로짓은 XW+bXW + b의 결과라 음수가 자연스럽게 나온다. 그대로 합으로 나누면 확률이 음수가 되어 버린다. exp는 어떤 입력이 들어와도 양수를 뱉는다.

차이 기반 정규화. 소프트맥스는 로짓 전체에 같은 상수를 더하거나 빼도 결과가 변하지 않는다(평행이동 불변성). ez+c=ezece^{z + c} = e^z \cdot e^c라 분자/분모에서 ece^c가 소거되기 때문이다. 즉 로짓의 절대값이 아니라 상대적 차이만 확률에 반영된다. 이 성질이 있어야 "클래스 간 순위"가 본질적인 정보로 남고 뒤에서 다룰 수치 안정화 트릭도 이 성질에서 바로 유도된다.

큰 값 강조. exp는 볼록 함수라서 큰 입력을 더 크게 벌려 놓는다. 같은 로짓 차이라도 소프트맥스는 가장 큰 값에 더 많은 확률을 몰아준다. 분류 문제에서 원하는 성질이다.

미분이 깔끔하다. exp는 자기 자신이 미분이다. 이 성질 덕분에 소프트맥스와 cross-entropy를 짝지으면 최종 기울기가 (PY)(P - Y)라는 간결한 형태로 떨어진다. 로지스틱 회귀의 BCE + 시그모이드 조합과 정확히 같은 구조다.

log와 짝을 이룬다. cross-entropy는 확률에 log를 씌우고 소프트맥스는 exp로 확률을 만든다. 두 연산이 서로 상쇄되어 logPik=ziklogjezij\log P_{ik} = z_{ik} - \log \sum_j e^{z_{ij}} 형태로 정리된다. log-sum-exp 트릭으로 수치 안정성까지 같이 챙긴다.

"soft" max라는 이름

일반 max는 가장 큰 값만 1로 두고 나머지를 0으로 버린다.

hardmax([2.0, 1.0, 0.5])=[1, 0, 0]\text{hardmax}([2.0,\ 1.0,\ -0.5]) = [1,\ 0,\ 0]

이 연산은 미분이 거의 모든 곳에서 0이라 경사하강법으로 학습할 수 없다. 소프트맥스는 "가장 큰 값에 높은 확률을 주되 나머지에도 확률을 남기는" 부드러운 버전이다.

softmax([2.0, 1.0, 0.5])=[0.69, 0.25, 0.06]\text{softmax}([2.0,\ 1.0,\ -0.5]) = [0.69,\ 0.25,\ 0.06]

매끄럽게 미분 가능하고 로짓 차이를 극단적으로 키우면 hardmax에 수렴한다. 이 부드러움 때문에 "soft" max라고 부른다.

수치 안정화: row-wise max 빼기

소프트맥스를 공식 그대로 구현하면 exp 오버플로에 쉽게 부딪힌다. float64의 상한이 약 1.8×103081.8 \times 10^{308}인데 e710e^{710}부터 이 상한을 넘어 inf로 처리된다. 학습 중 로짓이 조금만 커지면 실제로 발생하는 문제다.

해결은 앞서 말한 평행이동 불변성을 그대로 쓰는 것이다. 각 행의 최댓값을 로짓에서 빼 준다.

P(y=kxi)=ezikmij=1Kezijmi,mi=maxjzijP(y = k \mid \mathbf{x}_i) = \frac{e^{z_{ik} - m_i}}{\sum_{j=1}^{K} e^{z_{ij} - m_i}}, \quad m_i = \max_j z_{ij}

결과는 수학적으로 원래 소프트맥스와 동일하다. 분자/분모에 곱해지는 emie^{-m_i}가 소거되기 때문이다. 다만 중간 계산에 나오는 수치는 다르다. 최댓값이 0으로 맞춰지므로 exp 결과가 1을 넘지 않는다. 언더플로도 같이 완화된다. 최소 한 클래스는 e0=1e^0 = 1을 유지하므로 분모가 0이 되지 않는다.

Cross-Entropy 손실

예측 분포가 정답 분포와 얼마나 다른지를 재는 함수다. 분류 문제의 표준 손실이다.

L=1ni=1nlogP(y=yixi)L = -\frac{1}{n} \sum_{i=1}^{n} \log P(y = y_i \mid \mathbf{x}_i)

한 줄로 읽으면 이렇다.

각 샘플마다 정답 클래스에 모델이 준 확률을 꺼내 log-\log를 씌우고 평균 낸다.

P(y=yixi)P(y = y_i \mid \mathbf{x}_i) 표기가 처음엔 어색한데 뜯어보면 단순하다. yy는 "정답"이라는 확률 변수이고 yiy_i는 "ii번째 샘플의 구체적 정답 값"이다. 즉 모델이 내놓은 KK개 확률 중 정답 자리에 있는 값 하나를 가리킨다.

one-hot 라벨 Y{0,1}n×KY \in \{0, 1\}^{n \times K}을 쓰면 같은 식을 이렇게도 쓸 수 있다.

L=1ni=1nk=1KYiklogPikL = -\frac{1}{n} \sum_{i=1}^{n} \sum_{k=1}^{K} Y_{ik} \log P_{ik}

YY는 정답 위치만 1이고 나머지는 0이라서 안쪽 합에서 정답 자리 하나만 살아남는다. 결국 위 식과 완전히 동일하다.

log-\log인가

log\log 함수의 모양을 보면 바로 납득이 된다.

정답 확률log(확률)\log(\text{확률})log(확률)-\log(\text{확률}) (= 손실)
1.000.000.00
0.90-0.110.11
0.50-0.690.69
0.10-2.302.30
0.01-4.614.61
0.001-6.916.91

정답 확률이 1에 가까우면 손실이 0에 가깝고 0에 가까워질수록 손실이 가속도로 커진다. 자신 있게 틀린 답을 강하게 벌주는 성질이다. 확률이 10배 낮아질 때마다 손실이 일정량씩 쌓이므로 "애매하게 틀림"과 "확신하며 틀림"을 다르게 다룬다.

log\log 함수 자체의 도함수가 1/x1/x인 것도 같은 이야기다. xx가 0에 가까울수록 기울기가 폭발한다. 즉 log는 작은 값 근처에서 민감하게 반응하도록 설계된 함수다.

음수 부호와 1/n1/n

앞에 붙은 마이너스는 두 가지 일을 한다. 첫째는 log(확률)\log(\text{확률})이 항상 음수라서 손실을 양수로 뒤집기 위한 것이고 둘째는 최대화 문제를 최소화 문제로 변환하기 위한 것이다. 원래 원하는 것은 "정답 확률의 최대화"지만 경사하강법은 최소화 도구이므로 부호를 뒤집어 negative log-likelihood 형태로 둔다.

1/n1/n은 평균을 내기 위한 장치다. 합만 쓰면 데이터가 많을수록 손실이 자동으로 커져서 서로 다른 데이터셋을 비교할 수 없다. 기울기 크기도 배치 크기에 따라 변하므로 learning rate 튜닝이 어려워진다. 평균으로 두면 샘플 수와 무관한 스케일이 유지된다.

기울기

기울기 식도 로지스틱 회귀와 거의 같은 모양이다.

LW=1nX(PY)\frac{\partial L}{\partial W} = \frac{1}{n} X^\top (P - Y) Lb=1n1(PY)\frac{\partial L}{\partial \mathbf{b}} = \frac{1}{n} \mathbf{1}^\top (P - Y)

PRn×KP \in \mathbb{R}^{n \times K}는 소프트맥스 출력이고 YY는 one-hot 라벨 행렬이다. 오차항 (PY)(P - Y)"예측 분포 - 정답 분포" 그 자체다. 이렇게 간결한 형태가 나오는 이유는 소프트맥스와 cross-entropy가 둘 다 지수족(exponential family)에 속하기 때문이다. BCE + 시그모이드에서 y^(1y^)\hat{y}(1-\hat{y})가 약분되던 것과 같은 메커니즘이 KK개 클래스로 일반화된 모습이다.

1\mathbf{1}^\top이란 무엇인가

편향 기울기 식에 1\mathbf{1}^\top이 갑자기 등장해서 처음엔 의아했다. 알고 보면 "모든 원소가 1인 벡터"의 전치일 뿐이다.

1=[111],1=[1 1  1]\mathbf{1} = \begin{bmatrix} 1 \\ 1 \\ \vdots \\ 1 \end{bmatrix}, \quad \mathbf{1}^\top = [1\ 1\ \cdots\ 1]

행벡터 1\mathbf{1}^\top을 행렬 ARn×KA \in \mathbb{R}^{n \times K}의 왼쪽에 곱하면 열 방향 합이 나온다.

1A=[iAi1, iAi2, , iAiK]\mathbf{1}^\top A = \left[\sum_i A_{i1},\ \sum_i A_{i2},\ \cdots,\ \sum_i A_{iK}\right]

1(PY)\mathbf{1}^\top (P - Y)모든 샘플의 오차를 클래스별로 합산한 (K,)(K,) 벡터다. 코드에서는 (P - Y).sum(axis=0)과 동일한 연산이다.

왜 편향 기울기에는 XX^\top 대신 1\mathbf{1}^\top이 붙는 걸까. 편향 bkb_k는 모든 샘플에 동일하게 더해지기 때문에 샘플별 feature로 가중할 필요가 없다. 샘플마다의 오차를 그대로 합치기만 하면 된다. 반면 WW는 각 샘플의 feature와 곱해지므로 XX^\top으로 가중합해야 한다. 두 기울기 식이 대칭으로 보이는 이유는 XX 자리에 1\mathbf{1}을 끼워 넣은 형태이기 때문이다.

one-hot 인코딩이 하는 일

기울기 식에 (PY)(P - Y)가 들어 있으려면 정답 YYPP와 같은 모양의 확률 분포여야 한다. one-hot이 그 역할을 한다.

정답을 그냥 정수 라벨(0, 1, 2, ...)로 두면 두 가지 문제가 생긴다. 하나는 순서 오해다. "0, 1, 2, 3"을 입력처럼 다루면 모델이 "클래스 2는 클래스 0의 두 배 크기"라는 엉뚱한 관계를 학습하게 된다. 고양이/강아지/햄스터에 0, 1, 2를 붙였다고 햄스터가 고양이의 두 배라는 건 말이 안 된다. 다른 하나는 예측과 모양이 맞지 않는다는 점이다. 모델은 KK차원 확률 벡터를 뱉는데 정답이 스칼라면 직접 비교할 수 없다.

클래스 2[0,0,1,0,0]\text{클래스 2} \Rightarrow [0, 0, 1, 0, 0]

one-hot으로 바꾸면 모든 클래스가 서로 같은 거리로 떨어진 벡터 위치에 놓이고 예측 벡터와 모양이 일치해 원소별 뺄셈이 바로 가능해진다. 이 구조 덕분에 gradient가 "예측 - 정답"이라는 한 줄로 정리된다.

참고로 실무에서는 메모리 절약을 위해 one-hot을 실제로 만들지 않고 정답 인덱스만 써서 -log P[i, y_i]를 바로 계산하는 방식을 쓰기도 한다. 개념적으로는 동일하고 PyTorch의 CrossEntropyLoss가 이 방식이다.

NumPy 구현

import numpy as np

def softmax_regression(X, y, n_classes, lr=0.01, n_iters=1000):
    """
    Returns: tuple (weights, bias) where weights is a 2D list (d x K) and bias is a list of length K
    """
    X = np.asarray(X, dtype=float)
    y = np.asarray(y, dtype=int)

    n_samples, n_features = X.shape

    w = np.zeros((n_features, n_classes))
    b = np.zeros(n_classes)

    y_oh = np.zeros((n_samples, n_classes))
    y_oh[np.arange(n_samples), y] = 1.0

    for _ in range(n_iters):
        z = X @ w + b
        z -= z.max(axis=1, keepdims=True)
        exp_z = np.exp(z)
        p = exp_z / exp_z.sum(axis=1, keepdims=True)

        error = p - y_oh
        dw = (1 / n_samples) * (X.T @ error)
        db = (1 / n_samples) * error.sum(axis=0)

        w -= lr * dw
        b -= lr * db

    return (w.tolist(), b.tolist())

로지스틱 회귀 구현과 뼈대가 거의 같다. 스칼라 출력이 KK차원 벡터 출력으로 바뀌면서 w(d,K)(d, K) 행렬이 되고 b(K,)(K,) 벡터가 된 것 그리고 시그모이드 자리에 수치 안정 소프트맥스 세 줄이 들어간 것이 차이의 전부다.

one-hot을 루프 밖에서 한 번만 만들기

y_oh = np.zeros((n_samples, n_classes))
y_oh[np.arange(n_samples), y] = 1.0

y_oh는 학습 중에 변하지 않으므로 루프 밖에서 한 번만 계산한다. 내부에 넣으면 매 epoch마다 불필요한 할당이 생긴다.

두 번째 줄이 y_oh를 채우는 핵심이다. numpy의 fancy indexing 패턴인데 두 개의 배열을 인덱스로 동시에 넘기면 같은 자리끼리 짝을 이뤄 좌표가 된다.

행 인덱스: [0, 1, 2, 3, ...]   ← np.arange(n_samples)
열 인덱스: [2, 0, 1, 2, ...]y (정답 클래스 번호)
           ↓  ↓  ↓  ↓
좌표:     (0,2)(1,0)(2,1)(3,2) ...

이 좌표들에 11을 넣어 주면 각 샘플의 정답 위치만 11이 되는 one-hot 행렬이 완성된다. 파이썬 for 루프로 y_oh[i, y[i]] = 1을 반복하는 것과 동일한 동작이지만 내부 구현이 C로 돌아가 훨씬 빠르다.

이 인덱싱을 쓰려면 y가 정수여야 한다. numpy는 인덱스로 float을 받지 않는다. feature XX는 계산 편의상 float으로 받지만 라벨 yy는 클래스 번호라 처음부터 int로 받는 게 맞다. 관례적으로 feature는 float, label은 int라고 기억해 두면 비슷한 에러를 피할 수 있다.

수치 안정 소프트맥스

z -= z.max(axis=1, keepdims=True)
exp_z = np.exp(z)
p = exp_z / exp_z.sum(axis=1, keepdims=True)

세 줄 모두 axis=1, keepdims=True 패턴이 반복된다. 이유가 같다.

axis=1은 "행별로" 연산한다는 뜻이다. 소프트맥스는 각 샘플이 자기 로짓 내에서 독립적으로 정규화되어야 하므로 행별 연산이 맞다.

keepdims=True는 연산 후에도 차원을 유지한다. (n, K) 행렬에 max(axis=1)을 하면 (n,) 벡터가 되는데 keepdims=True를 주면 (n, 1) 열 벡터로 남는다. 이 차이가 브로드캐스팅에 영향을 준다. 원래 zz의 모양 (n,K)(n, K)에서 (n,1)(n, 1)을 빼면 numpy가 (n,1)(n, 1)을 열 방향으로 복제해 (n,K)(n, K)로 맞춘 뒤 원소별로 뺀다. 각 샘플의 max가 그 샘플 행 전체에 적용된다는 의도대로 동작한다.

keepdims=False로 두면 (n,K)(n, K)에서 (n,)(n,)을 빼려는데 numpy가 (n,)(n,)(1,n)(1, n)으로 해석해서 shape mismatch로 에러가 나거나 상황에 따라서는 "클래스별 max를 뺀다"는 엉뚱한 연산이 조용히 수행된다. 후자 쪽이 훨씬 위험하므로 이 패턴은 외우다시피 쓰는 게 안전하다.

마지막 나눗셈 exp_z / exp_z.sum(axis=1, keepdims=True)도 같은 원리다. 각 행의 합을 그 행 전체에 브로드캐스팅해 원소별로 나눈다.

기울기 계산

error = p - y_oh
dw = (1 / n_samples) * (X.T @ error)
db = (1 / n_samples) * error.sum(axis=0)

수식이 코드로 그대로 옮겨진다. 1nX(PY)\frac{1}{n} X^\top (P - Y)1n1(PY)\frac{1}{n} \mathbf{1}^\top (P - Y)가 각각 한 줄이다.

error.sum(axis=0)이 앞에서 본 1\mathbf{1}^\top의 numpy 버전이다. ones 벡터를 만들어 곱할 필요 없이 sum 한 번이면 "샘플 축을 뭉개 클래스별로 더한다"는 동일한 연산이 된다.

가중치 초기화

w = np.zeros((n_features, n_classes))
b = np.zeros(n_classes)

영 벡터로 출발했다. 신경망에서는 대칭성 문제 때문에 영 초기화가 위험하지만 소프트맥스 회귀는 단일 선형 층이라 애초에 깰 대칭성 자체가 없다. 손실이 볼록이므로 어디서 출발해도 같은 최적점에 수렴한다.

로지스틱 회귀와의 관계

K=2K = 2일 때 소프트맥스 회귀는 로지스틱 회귀와 정확히 동치다. 두 클래스의 소프트맥스를 전개하면 시그모이드가 튀어나온다.

ez1ez0+ez1=11+e(z1z0)=σ(z1z0)\frac{e^{z_1}}{e^{z_0} + e^{z_1}} = \frac{1}{1 + e^{-(z_1 - z_0)}} = \sigma(z_1 - z_0)

소프트맥스는 시그모이드의 KK-클래스 일반화다. 대응 관계를 한 번 정리해 두면 서로 넘나들기가 편하다.

이진 로지스틱소프트맥스
출력y^(0,1)\hat{y} \in (0, 1)pΔK1\mathbf{p} \in \Delta^{K-1} (확률 단체)
활성화시그모이드소프트맥스
가중치wRd\mathbf{w} \in \mathbb{R}^dWRd×KW \in \mathbb{R}^{d \times K}
편향스칼라 bb벡터 bRK\mathbf{b} \in \mathbb{R}^K
손실 함수BCECross-Entropy
기울기 형태1nX(y^y)\frac{1}{n} X^\top (\hat{\mathbf{y}} - \mathbf{y})1nX(PY)\frac{1}{n} X^\top (P - Y)

기울기 식이 거의 동일한 모양이다. "오차 × 입력" 이라는 구조가 유지되고 오차의 모양만 스칼라에서 벡터로 확장된다.

파라미터 중복 (Overparameterization)

소프트맥스 회귀는 같은 확률을 내놓는 파라미터 조합이 하나가 아니다. WW의 모든 열에 같은 벡터를 더해도 출력 확률이 그대로다. 앞서 본 평행이동 불변성 때문이다. b\mathbf{b} 전체에 같은 상수를 더해도 마찬가지다. 결국 서로 다른 (W,b)(W, \mathbf{b})가 무한히 많이 존재하면서 전부 동일한 예측을 만들어 낸다. 필요한 것보다 파라미터가 하나 더 많은 셈이고 이것을 과매개변수화라고 부른다.

실무에서는 이 중복이 문제가 되는 경우가 거의 없다. 경사하강법은 여전히 수렴하고 예측 성능도 영향을 받지 않는다. 다만 해가 유일해야 하는 상황에서는 중복을 제거하는 두 가지 방법이 있다. L2 정규화를 걸면 "가중치 크기가 최소인 해" 하나만 남아 중복이 사라진다. 또는 한 클래스의 가중치와 편향을 00으로 고정해 독립 파라미터를 K1K-1개로 줄이는 방법도 있다. 이진 로지스틱이 한쪽 클래스의 로짓을 암묵적으로 00으로 두는 것과 같은 맥락이다.

one-vs-rest 대신 소프트맥스를 쓰는 이유

KK개 클래스를 다루는 가장 단순한 방법은 "클래스마다 이진 분류기를 하나씩 만들어서 KK개를 따로 돌린다"는 one-vs-rest(OvR)다. 구현도 간단하고 기존 이진 분류기를 그대로 재활용할 수 있다. 그럼에도 실무에서 소프트맥스가 기본 선택인 이유가 있다.

OvR이 내놓는 KK개의 확률은 서로 독립적이다. 합이 1이라는 보장이 없고 같은 입력에 대해 여러 분류기가 동시에 높은 확률을 내놓기도 한다. 반면 소프트맥스는 합이 1인 확률 분포를 한 번에 내놓는다. 클래스 사이의 상대적 신뢰도가 한 벡터에 녹아 있다는 뜻이다. argmax로 예측을 뽑든 상위 kk개를 보든 확률을 그대로 의사결정에 쓰든 다루기가 깔끔하다.

또 하나 큰 차이는 학습 신호의 정렬이다. OvR은 각 이진 분류기가 자기 문제만 본다. 고양이 분류기는 "고양이인지 아닌지"만 판단할 뿐 강아지 분류기의 출력과 조율되지 않는다. 소프트맥스는 cross-entropy를 통해 KK개 점수가 한 번에 경쟁하며 학습되므로 "정답 클래스의 점수를 올리는 것"이 곧 "나머지 클래스의 점수를 낮추는 것"과 이어진다. 클래스가 서로 배타적인 상황에서 이 구조가 자연스럽다.

물론 OvR이 더 맞는 상황도 있다. 한 샘플이 여러 클래스에 동시에 속할 수 있는 멀티라벨 문제가 대표적이다. 이런 경우에는 "합이 1"이라는 제약이 오히려 방해가 되므로 클래스마다 독립 시그모이드를 쓰는 편이 맞다. 정리하면 클래스가 상호 배타적인 다중 분류에서는 소프트맥스가 기본이고 멀티라벨에서는 OvR 형태가 기본이다.

Temperature scaling: 같은 모델에서 확신을 조절하기

소프트맥스에 로짓을 넣기 전에 상수 TT로 나누는 것만으로도 출력 분포의 "날카로움"을 조절할 수 있다.

P(y=kxi)=ezik/Tjezij/TP(y = k \mid \mathbf{x}_i) = \frac{e^{z_{ik}/T}}{\sum_j e^{z_{ij}/T}}

T>1T > 1이면 로짓 간 차이가 줄어들어 분포가 평평해지고 T<1T < 1이면 차이가 증폭되어 날카로워진다. T0T \to 0이면 hardmax에 수렴하고 TT \to \infty면 균등 분포에 수렴한다. 언어 모델에서 "다양성을 높이고 싶다"고 할 때 조절하는 temperature 옵션이 정확히 이것이다.

이미 학습된 모델을 재학습 없이 재보정하는 용도로도 쓰인다. 분류기의 확률이 지나치게 0이나 1로 쏠려 있을 때 검증 데이터에서 적절한 TT를 찾아 분포를 완만하게 다듬는 temperature calibration 기법이 따로 있다.

Label smoothing: 과신을 깎아 일반화 높이기

cross-entropy는 모델이 정답 클래스에 1.0을 몰아줄수록 손실이 내려가도록 설계돼 있다. 이 설계 때문에 모델이 학습 데이터에 지나친 확신을 갖게 되고 과적합 쪽으로 기울기 쉽다. 이를 완화하는 간단한 기법이 label smoothing이다.

방법은 간단하다. one-hot 라벨의 정답 자리에 1ϵ1 - \epsilon을 두고 나머지 K1K-1개 자리에 ϵ/(K1)\epsilon / (K-1)씩 채워 살짝 퍼뜨린다. ϵ=0.1\epsilon = 0.1 정도를 많이 쓴다.

[0, 0, 1, 0, 0][0.025, 0.025, 0.9, 0.025, 0.025][0,\ 0,\ 1,\ 0,\ 0] \Rightarrow [0.025,\ 0.025,\ 0.9,\ 0.025,\ 0.025]

손실 함수는 그대로 두고 목표 분포만 부드럽게 만든 셈이다. 모델이 정답 확률을 0.9 이상으로 밀어붙이면 오히려 손실이 올라가므로 과신이 억제된다. 작은 변경이지만 일반화 성능과 확률 보정(calibration)을 함께 개선하는 효과가 있어 이미지 분류나 NMT 같은 분야에서 흔하게 쓰인다.

소프트맥스가 닿지 못하는 지점

소프트맥스 회귀는 결국 선형 모델이다. 결정 경계가 초평면이라 선형 분리 가능한 문제에서는 강하지만 그렇지 않은 경우에는 어떻게 학습해도 정답에 도달하지 못한다. 원 안팎을 가르는 패턴이나 XOR 같은 고전적 반례가 여기에 해당한다.

해결 방향은 두 가지다. 하나는 feature를 비선형으로 확장해서 소프트맥스가 선형적으로 다룰 수 있는 공간으로 옮겨 주는 커널 방법이다. 다른 하나는 은닉층을 쌓아 네트워크가 스스로 비선형 feature를 학습하게 하는 신경망 방식이다. 후자가 현대 딥러닝의 표준이 된 이유는 단순하다. 은닉층으로 뽑은 feature 위에 이번에 구현한 소프트맥스 회귀를 마지막 층으로 올리면 그것이 바로 분류 신경망이다. 앞단이 어떻게 생겼든 출력층에서 벌어지는 일은 이 글의 내용과 정확히 같다.

마무리

이번 구현에서 남는 포인트는 네 가지다.

  • 출력 차원이 늘면 파라미터의 모양도 같이 늘어난다. 스칼라 출력이었던 이진 로지스틱이 KK개 출력이 되면서 ww(d,K)(d, K) 행렬이 되고 bb(K,)(K,) 벡터가 된다. 편향의 개수는 언제나 출력의 개수와 같다.
  • 소프트맥스는 확률 분포의 조건을 자동으로 만족시키는 장치다. exp로 양수화하고 전체 합으로 나누는 두 단계로 압축된다. 평행이동 불변성 덕분에 최댓값 빼기만으로 수치 안정까지 공짜로 따라온다.
  • Cross-entropy는 "정답 확률에 log-\log를 씌운 값의 평균"이다. one-hot 표기와 정수 라벨 표기는 같은 식을 다르게 쓴 것일 뿐이다. 자신 있게 틀린 답을 강하게 벌주는 성질이 log의 기울기 1/x1/x에서 나온다.
  • (PY)(P - Y)라는 오차 벡터가 모든 것을 말해준다. 소프트맥스 + cross-entropy 조합은 BCE + 시그모이드의 KK-클래스 확장이고 기울기가 "예측 분포 - 정답 분포"로 동일하게 떨어진다. 이 구조가 분류 신경망 출력층의 기본 형태다.