NumPy로 Softmax · Cross Entropy: 점수를 확률로, 확률을 손실로 바꾸는 법
신경망이 마지막에 뱉는 임의의 숫자(점수)를 확률처럼 보이게 만드는 softmax와, 그 확률이 정답과 얼마나 다른지 재는 cross entropy를 NumPy로 직접 구현한다. 큰 값을 넣으면 계산이 터지는 문제(overflow)를 직접 재현하고 최댓값을 빼는 트릭으로 고쳐본다. 이 두 함수는 이후 모든 분류 모델, 그리고 LLM의 다음 토큰 예측 학습에
오늘의 목표
지금까지 만든 신경망(혹은 앞으로 만들 신경망)은 마지막 층에서 클래스마다 하나씩 실수 점수(score)를 뱉는다. 이 점수 자체는 "고양이일 확률 70%" 같은 게 아니라 그냥 크고 작은 숫자 뭉치다. 오늘은 이 점수 뭉치를 ① 합이 1이 되는 확률 분포로 바꾸는 softmax와, ② 그 확률이 정답 라벨과 얼마나 어긋났는지 숫자 하나로 재는 cross entropy(교차 엔트로피, 두 확률분포가 얼마나 다른지 재는 손실 함수)를 직접 구현하는 것이 목표다.
개념과 직관
softmax: 점수를 확률로
모델이 이미지를 보고 "고양이: 2.0, 개: 1.0, 새: 0.1" 같은 점수를 냈다고 하자. 이 숫자들은 크기 비교는 되지만 "몇 퍼센트 확신하는지"는 말해주지 않는다. softmax는 이걸 확률로 바꿔준다.
방법은 직관적이다: 각 점수를 지수함수(exp)로 눌러서 전부 양수로 만든 다음, 전체 합으로 나눠서 비율을 만든다. 점수가 클수록 exp를 거치면 훨씬 더 커지기 때문에, 원래 큰 점수가 확률에서도 압도적으로 커진다 — "승자 독식"에 가까운 방식으로 순위를 확률로 옮기는 셈이다.
텍스트 수식으로 쓰면:
softmax(x_i) = exp(x_i) / sum(exp(x_j) for all j)비유: 5명이 케이크를 나눠 먹는데, 각자 "내가 얼마나 배고픈지" 점수를 냈다. 배고픔 점수를 그대로 비율로 쓰면 음수인 사람은 케이크를 못 받는 이상한 일이 생긴다. 그래서 점수를 exp로 눌러 전부 양수로 만들고, 전체 배고픔 합 대비 내 배고픔 비율만큼 케이크(확률)를 받는다.
수치 안정화: 큰 값 빼기
문제는 exp(1000) 같은 계산이다. 컴퓨터가 표현할 수 있는 실수 범위를 넘어가면 inf(무한대)가 되고, inf / inf는 nan(정의되지 않은 값)이 되어버린다. 이걸 오버플로(overflow, 숫자가 표현 가능한 범위를 넘어가는 것)라고 한다.
수학적으로 softmax는 모든 점수에서 같은 값을 빼도 결과가 똑같다는 성질이 있다 (분자 분모에 같은 배수가 곱해지는 것과 비슷한 효과). 그래서 계산 전에 점수들 중 최댓값을 모든 점수에서 빼주면, 가장 큰 값이 0이 되고 나머지는 음수가 되어 exp 결과가 항상 0~1 사이로 안전해진다. 결과값은 수학적으로 완전히 동일하다.
softmax(x_i) = exp(x_i - max(x)) / sum(exp(x_j - max(x)))cross entropy: 확률이 정답과 얼마나 다른가
정답 라벨을 원-핫(one-hot, 정답 클래스만 1이고 나머지는 전부 0인 벡터)으로 표현한다. 예를 들어 클래스가 3개(고양이, 개, 새)이고 정답이 "개"면 [0, 1, 0]이다.
cross entropy는 "정답 클래스에 모델이 부여한 확률이 낮을수록 손실이 커지는" 함수다. 정답 클래스의 확률이 1에 가까우면 손실은 0에 가깝고, 확률이 0에 가까우면 손실은 무한대로 폭발한다 — "정답을 확신 없이 틀리게 예측하면 크게 혼난다"는 뜻이다.
cross_entropy = -sum(y_true_i * log(y_pred_i) for all i)원-핫이라 정답 클래스 외에는 y_true_i가 0이므로, 실제로는 정답 클래스에 해당하는 log(확률) 하나만 남아서 -log(정답 클래스 확률)이 된다. 확률이 1이면 -log(1)=0, 확률이 0에 가까우면 -log(작은 값)은 매우 커진다.
코드로 직접 해보기
1단계: 순진한 softmax (문제가 있는 버전)
import numpy as np
def softmax_naive(x):
exp_x = np.exp(x)
return exp_x / np.sum(exp_x)
scores = np.array([1.0, 2.0, 3.0])
print(softmax_naive(scores))2단계: 안정화된 softmax
def softmax(x):
# x: (batch_size, num_classes) 형태를 가정
x = np.atleast_2d(x)
x_max = np.max(x, axis=1, keepdims=True) # 행마다 최댓값
exp_x = np.exp(x - x_max) # 최댓값 빼고 exp
return exp_x / np.sum(exp_x, axis=1, keepdims=True)
batch_scores = np.array([
[1.0, 2.0, 3.0],
[1000.0, 1000.0, 1000.0], # 큰 값도 안전하게 처리되는지 확인
])
print(softmax(batch_scores))axis=1, keepdims=True가 처음 보면 헷갈릴 수 있다. axis=1은 "각 행(row, 샘플 하나)에 대해 계산"하라는 뜻이고, keepdims=True는 결과 차원을 안 뭉개서 원래 배열과 브로드캐스팅(broadcasting, 크기가 다른 배열끼리 자동으로 맞춰서 연산하는 NumPy 규칙)이 되게 해준다.
3단계: cross entropy
def cross_entropy(y_pred, y_true, eps=1e-12):
# y_pred: (batch_size, num_classes) softmax 출력
# y_true: (batch_size, num_classes) one-hot
y_pred = np.clip(y_pred, eps, 1.0) # log(0) 방지용 클리핑
losses = -np.sum(y_true * np.log(y_pred), axis=1)
return np.mean(losses) # 배치 평균 손실
y_true = np.array([
[0, 0, 1], # 정답: 클래스 2
[1, 0, 0], # 정답: 클래스 0
])
y_pred = softmax(np.array([
[1.0, 2.0, 5.0],
[0.1, 3.0, 0.2],
]))
print("예측 확률:\n", y_pred)
print("손실:", cross_entropy(y_pred, y_true))eps=1e-12를 더해서 클리핑하는 이유: log(0)은 -inf라서 손실이 무한대로 터진다. 이론상 softmax 출력이 정확히 0이 되는 일은 드물지만, 부동소수점 오차나 극단적인 입력에서는 생길 수 있어서 안전장치를 둔다.
오늘의 실험
softmax_naive에np.array([1000.0, 1000.0, 1000.0])를 넣어보고 결과가nan이 뜨는 걸 직접 확인한다. (RuntimeWarning: overflow도 같이 뜬다)- 같은 입력을 안정화된
softmax함수에 넣어서[0.333, 0.333, 0.333]처럼 정상적인 값이 나오는지 비교한다. - 입력값을 100, 500, 800, 1000, 10000으로 바꿔가며 두 버전의 결과를 표로 기록한다: 입력 크기 / naive 결과 / 안정화 결과 / naive에서 언제부터 nan이 뜨는지.
- (선택)
np.exp(x - max(x))대신x에서 최솟값을 빼거나 평균을 빼면 어떻게 되는지도 테스트해서, "왜 하필 최댓값인지" 스스로 확인해본다.
기록 형식 예시:
| 입력 최대값 | naive softmax | 안정화 softmax |
|---|---|---|
| 100 | 정상 | 정상 |
| 800 | nan 발생 | 정상 |
| 1000 | nan 발생 | 정상 |
왜 이걸 배우나 (LLM / Post-training 연결)
LLM이 다음 토큰을 예측할 때 정확히 이 구조를 쓴다: 마지막 층에서 어휘 사전 크기(수만~수십만 개)만큼의 점수를 뱉고, softmax로 확률 분포를 만들고, 실제 다음 토큰(정답 라벨)과 cross entropy로 손실을 계산해서 학습한다. 어휘 크기가 커서 이 계산이 훨씬 크고 비싸지만 원리는 오늘 만든 것과 동일하다. RLHF나 DPO 같은 post-training 기법에서도 "모델이 특정 토큰/응답에 부여하는 확률"을 다루는 로그 확률(log probability) 계산이 핵심 재료로 계속 등장하므로, 오늘 만든 두 함수가 앞으로 몇 달간 계속 형태를 바꿔가며 나타난다.
초보자가 막히는 지점
- axis 방향 헷갈림:
np.sum(x, axis=1)이 행 방향인지 열 방향인지 매번 헷갈린다.axis=1은 "각 행 안에서 열들을 따라 계산"이라고 외우기보다, 직접(2,3)배열을 만들어axis=0과axis=1결과 shape을 찍어보고 눈으로 확인하는 게 빠르다. - keepdims 빼먹기:
keepdims=True를 빼면 결과가 1차원으로 줄어들어서 나눗셈할 때 브로드캐스팅 에러가 나거나, 에러 없이 조용히 잘못된 계산이 될 수 있다. 에러가 안 나는 쪽이 더 위험하다. - cross entropy에 softmax 출력이 아닌 원본 점수를 넣는 실수: cross entropy 공식은 "확률"을 입력으로 기대한다. softmax를 거치지 않은 원본 점수(음수 포함 가능)를 넣으면 log에 음수가 들어가서
nan이 나온다. - log(0) 문제를 그냥 무시: eps 클리핑 없이 짜면 어쩌다 한 번
nan이 튀는 버그가 생기는데, 원인 파악이 안 돼서 오래 헤맨다. 처음부터 클리핑을 습관화하는 게 낫다. - "왜 최댓값을 빼도 결과가 같은가"를 그냥 외우고 넘어가기: 종이에 분자 exp(x-c)를 exp(x)*exp(-c)로 풀어서 분모도 똑같이 exp(-c)가 곱해지니 약분된다는 걸 직접 손으로 한 번 전개해보면 이해가 확실해진다.
알고리즘 (병행 1시간)
오늘은 누적합(prefix sum, 배열 앞부분까지의 합을 미리 계산해두는 기법)과 정렬을 다루는 문제로 감을 잡는다.
- Easy 1개: 누적합 관련 문제 (예: 배열이 주어졌을 때 각 인덱스까지의 합, 혹은 특정 구간 합 구하기 유형). LeetCode에서 "Running Sum of 1d Array" 또는 "Range Sum Query" 계열 중 Easy 난이도를 하나 골라 푼다.
- Medium 1개: 정렬을 활용하는 문제 (예: 구간 병합, 혹은 정렬 후 투 포인터로 푸는 유형). "Merge Intervals" 계열 중 하나를 고른다.
풀 때 기록할 것:
- 접근: 처음에 어떤 방법이 떠올랐는지, 브루트포스로 먼저 풀어봤는지
- 시간복잡도: 내 풀이가 O(n), O(n log n), O(n^2) 중 무엇인지, 왜 그런지 한 줄로 설명
- 틀린 이유: 틀렸다면 엣지 케이스(빈 배열, 원소 1개, 중복값) 때문인지, 인덱스 off-by-one 때문인지, 정렬 기준을 잘못 잡았는지 구체적으로 적기
누적합은 나중에 "특정 구간의 손실 합/확률 합"을 빠르게 구해야 할 때, 정렬은 데이터 전처리나 평가 결과 랭킹을 매길 때 계속 재사용되는 감각이니 오늘 낯설어도 억지로 외우지 말고 "왜 이 트릭이 빠른가"를 이해하는 데 집중한다.