긴 문서를 분류할 때 클래스 점수가 둘 다 0으로 나온다면, 단어 확률이 잘못됐다고 단정하기 전에 부동소수점 언더플로를 확인해야 합니다. 작은 확률의 곱을 직접 만들지 말고 로그값을 더해 비교한 뒤, 정규화가 필요할 때 가장 큰 로그값을 빼고 지수함수를 적용하는 것이 핵심입니다.
아래 작은 나이브베이즈 모델은 합성 문장 4개에서 실제로 토큰 빈도와 클래스 사전확률을 학습합니다. 긴 스트레스 입력에서는 원래 방식의 점수가 모두 0이어도 로그 점수로는 차이를 비교할 수 있고, 대칭 입력은 0.5·0.5로 돌아옵니다. Python 표준 라이브러리만 실행하며 LLM·외부 API·유료 서비스는 사용하지 않습니다.

스무딩과 언더플로는 해결하는 문제가 다릅니다
이 예제의 0번은 일반 안내, 1번은 오류 문의를 나타내는 합성 클래스입니다. 공백으로 나눈 단어를 세고, 학습 어휘 안의 각 단어에 alpha=1을 더합니다. 학습 문서에 어떤 단어가 한 클래스에서 한 번도 없었더라도 그 단어의 조건부 확률이 0이 되지 않게 하는 라플라스 스무딩입니다.
하지만 양수인 작은 값도 아주 많이 곱하면 float가 표현할 수 있는 범위보다 작아집니다. 스무딩을 적용했다고 이 계산 문제가 사라지지는 않습니다. 반대로 완전히 새 단어가 어휘에 없을 때 무엇을 할지는 별도 규칙입니다. 아래 코드는 조용히 새 어휘를 추가하거나 무시하는 대신 오류로 거절합니다.
비교할 때는 곱 대신 로그 합을 사용합니다
각 단어 w의 등장 횟수를 n_w라고 하면 클래스별 계산은 사전확률에 P(w|클래스)의 n_w제곱을 곱하는 형태입니다. 로그 공간에서는 log(사전확률)에 각 단어의 n_w×log P(w|클래스)를 더합니다. 로그는 양수 구간에서 순서를 보존하므로 같은 입력에 대한 클래스 순위를 비교할 수 있습니다.
코드의 raw는 클래스 비교용 비정규화 점수입니다. 다항분포의 입력별 공통 계수는 두 클래스에서 같아 생략했습니다. 이 숫자를 문서가 나타날 절대확률이나 모델의 정확도로 해석하지 않습니다. 분류 순위만 필요하면 로그 점수끼리 비교해도 되며, 매번 확률로 되돌릴 필요는 없습니다.

학습부터 긴 입력까지 한 파일로 실행합니다
Python 3.14.7에서 실행했습니다. 코드를 nb_log_demo.py로 저장해 python3 nb_log_demo.py로 실행하세요. 클래스마다 문서 2개·토큰 5개이고 전체 어휘는 5개입니다. 학습된 오류·안내 단어의 확률은 해당 클래스에서 4/10, 반대 클래스에서 1/10입니다. short·long·tie는 정확도를 측정하는 테스트셋이 아니라 계산을 확인하는 합성 스트레스 입력입니다.
from collections import Counter
import math
# Synthetic, whitespace-tokenized training documents, not real user messages.
TRAIN = [(0, '안내 안내 문의'), (0, '안내 일반'),
(1, '오류 오류 문의'), (1, '오류 긴급')]
def fit(documents, alpha=1.0):
if alpha <= 0 or not math.isfinite(alpha):
raise ValueError('alpha must be finite and positive')
labels = sorted({label for label, _ in documents})
vocabulary = set(' '.join(text for _, text in documents).split())
if not labels or not vocabulary:
raise ValueError('empty training data')
priors, probabilities = {}, {}
for label in labels:
texts = [text for y, text in documents if y == label]
counts = Counter(' '.join(texts).split())
denominator = sum(counts.values()) + alpha * len(vocabulary)
priors[label] = len(texts) / len(documents)
probabilities[label] = {word: (counts[word] + alpha) / denominator
for word in vocabulary}
return labels, vocabulary, priors, probabilities
def score(model, text):
labels, vocabulary, priors, probabilities = model
counts = Counter(text.split())
if counts.keys() - vocabulary:
raise ValueError('out-of-vocabulary token')
raw, logs = {}, {}
for label in labels:
p = probabilities[label]
# Unnormalized class score; input-dependent multinomial coefficient omitted.
raw[label] = priors[label] * math.prod(p[w] ** n for w, n in counts.items())
logs[label] = math.log(priors[label]) + math.fsum(
n * math.log(p[w]) for w, n in counts.items())
maximum = max(logs.values())
shifted = {label: math.exp(logs[label] - maximum) for label in labels}
denominator = math.fsum(shifted.values())
normalized = {label: shifted[label] / denominator for label in labels}
return raw, logs, normalized
if __name__ == '__main__':
model = fit(TRAIN)
for name, text in [('short', '오류 문의'),
('long', ' '.join(['오류'] * 1000)),
('tie', ' '.join(['오류', '안내'] * 500))]:
raw, logs, normalized = score(model, text)
print(name, 'raw=', [round(raw[y], 6) for y in model[0]])
print(name, 'log=', [round(logs[y], 6) for y in model[0]])
print(name, 'normalized=', [round(normalized[y], 6) for y in model[0]])
assert all(math.isfinite(v) for v in logs.values())
assert math.isclose(sum(normalized.values()), 1.0)
if name == 'short':
total = sum(raw.values())
assert all(math.isclose(raw[y] / total, normalized[y]) for y in model[0])
else:
assert raw == {0: 0.0, 1: 0.0}
if name == 'tie':
assert normalized == {0: 0.5, 1: 0.5}
try:
score(model, '새단어')
except ValueError:
print('unknown token: REJECTED')
실제 출력: 두 개의 0만으로는 같은 점수인지 알 수 없습니다
short raw= [0.01, 0.04]
short log= [-4.60517, -3.218876]
short normalized= [0.2, 0.8]
long raw= [0.0, 0.0]
long log= [-2303.27824, -916.983879]
long normalized= [0.0, 1.0]
tie raw= [0.0, 0.0]
tie log= [-1610.13106, -1610.13106]
tie normalized= [0.5, 0.5]
unknown token: REJECTED
short의 raw 0.01과 0.04를 합으로 나누면 0.2와 0.8이며 로그 경로의 결과와 같습니다. long은 raw가 모두 0이라 0을 합으로 나누는 정규화가 불가능합니다. 그러나 로그 점수는 약 -2303.28과 -916.98로 다르며 더 큰 1번 점수를 고를 수 있습니다.
정규화 단계는 각 로그 점수에서 최댓값 maximum을 뺀 뒤 exp를 적용합니다. 그러면 최대 항의 지수값은 1이어서 큰 공통 스케일을 직접 지수화하지 않습니다. 이는 유한한 로그 점수에 적용한 log-sum-exp의 최대값 이동 방식입니다. 이 구현은 NaN·무한대나 임의의 비정상 외부 모델 점수까지 처리하는 일반 라이브러리는 아닙니다.

출력이 1.0이라고 100% 맞는다는 뜻은 아닙니다
long의 정규화 결과는 float에서 0.0과 1.0입니다. 아주 작은 쪽은 최댓값을 뺀 뒤에도 표현 범위를 벗어나 0이 될 수 있습니다. 모델 가정 아래 수학적인 작은 확률이 실제로 0이라는 뜻은 아닙니다. 로그 점수를 보존하면 이런 강한 차이를 확인할 수 있습니다.
tie는 두 클래스의 로그 점수가 정확히 같아 0.5·0.5입니다. 원래 계산의 raw가 모두 0이라는 이유로 무조건 동점 처리했다면 long의 다른 순위까지 놓칩니다. 화면에서 반올림한 값만으로 분류하거나, 동점을 처리하는 업무 규칙을 숨기지 마세요.
이 모델은 4개의 합성 문장으로 원리를 보여 줍니다. 한국어 형태소 처리, 실제 오류 문의 분류 성능, 확률 보정, 도메인 변화나 운영 임계값은 평가하지 않았습니다. 나이브베이즈의 정규화 출력도 실제 정답 확률이 잘 보정됐다는 보장은 없습니다.
다음 확인과 핵심 정리
계산이 안정적으로 돌아온 뒤에는 혼동행렬·분류 지표와 ROC 정리로 실제 분류 결과를 평가해야 합니다. 수치 안정성 확인과 예측 성능 확인은 다른 작업입니다.
작은 확률의 반복 곱은 스무딩 이후에도 0으로 떨어질 수 있습니다. 클래스 비교는 로그 점수로 하고, 정규화에는 최대값 이동을 사용하세요. 원래 0이던 값, 계산 범위 때문에 0이 된 값, 표시·정규화 과정의 0을 구분해 기록해야 합니다.
'개발 > 트러블슈팅' 카테고리의 다른 글
| Codex 스킬 실습: 실행 근거와 결과물 검증을 따로 남기기 (0) | 2026.10.30 |
|---|---|
| TCP recv 한 번에 메시지가 다 올까? 길이 접두사와 분할 수신 실습 (0) | 2026.10.29 |
| RAG 답변 캐시가 적중해도 다시 검사할 것: 인용 문서의 권한 회수 (0) | 2026.10.26 |
| 두 사람이 수정한 내용이 덮일 때: HTTP ETag·If-Match와 412 실습 (0) | 2026.10.26 |
| AI 도구 호출 JSON의 approved=true를 믿어도 될까? 실행 권한을 따로 검사하기 (0) | 2026.10.25 |
댓글