에듀테크 프로젝트 대시보드
나이브 베이즈 스팸 필터
"무료 쿠폰 지금 클릭!" 이 메일은 스팸일까? 나이브 베이즈는 메일에 등장한 단어들만 보고, 학습해 둔 통계로 스팸일 확률을 계산한다. 20년 넘게 실제 메일함을 지켜 온, 놀랄 만큼 단순한 확률 분류기다.
알고 싶은 것: 메일 내용(단어들)이 주어졌을 때 스팸일 확률 P(스팸 | 단어들). 하지만 우리가 데이터에서 쉽게 세는 건 그 반대 방향 — P(단어 | 스팸)이다. 베이즈 정리가 방향을 뒤집어 준다.
P(w₁, w₂, …, wₙ | 스팸)을 정직하게 세려면 그 정확한 단어 조합이 나온 스팸이 필요하다 — 거의 없다. 그래서 과감하게 가정한다: 스팸이라는 조건 아래에서 각 단어의 등장은 서로 독립이다.
"무료" 다음에 "클릭"이 잘 온다는 순서·상관관계를 통째로 무시하는 것 — 그래서 "순진한(naive)" 분류기다. 현실에서 단어는 당연히 독립이 아니지만, 분류 정답만 맞으면 되므로 실전 성능은 훌륭하다.
아래가 학습 데이터다. 스팸 5통, 햄(정상) 5통. 각 클래스에서 단어별 등장 횟수를 센다.
| 단어 | 스팸 횟수 | 햄 횟수 | P(w | 스팸) | P(w | 햄) | 스팸 : 햄 우도비 |
|---|
메일을 골라 보거나 직접 입력해 보라. 각 단어가 판정을 스팸 쪽(분홍) 또는 햄 쪽(파랑)으로 얼마나 끌어당기는지 막대로 보여준다.
단어가 30개면 0.001 같은 값을 30번 곱한다 → 10⁻⁹⁰. 컴퓨터의 실수 표현으로는 그냥 0이 되어버린다(언더플로). 해결책: 곱을 로그로 바꿔 덧셈으로 만든다.
log는 단조 증가 함수라 대소 관계가 그대로 보존된다 → 어느 클래스가 더 큰지 비교하는 데 아무 문제 없다. 위 4번 데모의 계산식도 전부 로그 합으로 돌아간다. 확률로 되돌리고 싶으면 두 로그 점수에 softmax를 씌운다.
import math, re from collections import Counter, defaultdict def train(docs): # docs = [(text, label), ...] wc = defaultdict(Counter) # wc[label][word] = 횟수 total = defaultdict(int) # 클래스별 총 단어 수 n_docs = Counter() for text, label in docs: n_docs[label] += 1 for w in re.findall(r"\w+", text.lower()): wc[label][w] += 1; total[label] += 1 vocab = {w for c in wc for w in wc[c]} return wc, total, n_docs, vocab def predict(text, model, alpha=1.0): wc, total, n_docs, vocab = model V = len(vocab); N = sum(n_docs.values()) words = [w for w in re.findall(r"\w+", text.lower()) if w in vocab] best, scores = None, {} for c in n_docs: logp = math.log(n_docs[c] / N) # log 사전확률 for w in words: logp += math.log((wc[c][w] + alpha) / (total[c] + alpha * V)) # log 우도, 합산 scores[c] = logp return max(scores, key=scores.get), scores