감성 분석(Sentiment Analysis)
가장 대표적인 자연어 처리(NLP) 과제입니다. 고전적인 텍스트 분류(text classification)에서 알아야 할 대부분의 내용이 이 한 가지 작업에 모두 나타납니다.
유형: Build
언어: Python
선수 강의: Phase 5 · 02 (BoW + TF-IDF), Phase 2 · 14 (Naive Bayes)
예상 시간: 약 75분
학습 목표
- 감성 분석이 왜 고전적 NLP의 표준 실험장인지 설명합니다.
- 다항 나이브 베이즈(Multinomial Naive Bayes)와 로지스틱 회귀(Logistic Regression) 기반 베이스라인(baseline)을 구현합니다.
- 부정어 처리(Negation Handling), n그램(n-gram), 불용어(stopword) 정책이 결과에 미치는 영향을 이해합니다.
- 클래스 불균형(Imbalanced Data) 상황에서 정확도(accuracy) 대신 정밀도(precision), 재현율(recall), F1, 혼동 행렬(Confusion Matrix)을 해석합니다.
문제
The food was not great. 이 문장은 긍정일까요, 부정일까요?
감성 분석은 단순해 보입니다. 리뷰어가 좋았다고 말했는지, 싫었다고 말했는지를 레이블(label)로 붙이면 됩니다. 이 작업이 NLP의 표준 과제로 자리잡은 이유는, 쉬워 보이는 사례마다 어려운 예외가 숨어 있기 때문입니다. 부정어는 의미를 뒤집습니다. 풍자(Sarcasm)는 의미를 정반대로 만듭니다. Not bad at all은 부정적 단어가 두 개 들어 있지만 긍정 문장입니다. 이모지(emoji)가 주변 텍스트보다 더 큰 신호를 담기도 합니다. 도메인 어휘(domain vocabulary)도 중요합니다. 음악 리뷰의 tight와 패션 리뷰의 tight는 전혀 다르게 읽힙니다.
감성 분석은 고전적인 NLP를 실험하기 좋은 작업입니다. 단순한 베이스라인이 왜 특정한 실패 양상(failure mode)을 갖는지 이해하면, 더 풍부한 모델이 왜 발명되었는지도 자연스럽게 이해하게 됩니다. 이 강의에서는 나이브 베이즈 베이스라인을 바닥부터 직접 만들고, 로지스틱 회귀를 추가한 뒤, 운영 환경(production)의 감성 분석이 왜 컴플라이언스(compliance) 수준의 문제로 커질 수 있는지 확인합니다.
개념

고전적인 감성 분석 파이프라인(pipeline)은 두 단계로 이루어집니다.
- 표현(Represent). 텍스트를 특성 벡터(feature vector)로 바꿉니다. BoW, TF-IDF, n그램을 사용할 수 있습니다.
- 분류(Classify). 레이블이 붙은 예시(labeled example) 위에서 선형 모델(Naive Bayes, Logistic Regression, SVM)을 학습합니다.
나이브 베이즈는 동작하는 가장 단순한 모델입니다. 레이블이 주어졌을 때 모든 특성이 서로 독립이라고 가정합니다. 단어 빈도(count)에서 P(word | positive)와 P(word | negative)를 추정한 다음, 추론 시 확률을 곱합니다. 이 "나이브한" 독립 가정은 명백히 틀렸지만, 결과는 놀랄 만큼 강력합니다. 그 이유는 희소한(sparse) 텍스트 특성과 적당한 양의 데이터에서는, 분류기가 각 단어의 양적인 빈도보다 그 단어가 어느 레이블 쪽으로 더 기울어지는지를 더 중요하게 활용하기 때문입니다.
로지스틱 회귀는 이 독립 가정을 완화합니다. 특성마다 가중치(weight)를 학습하며, 음의 가중치도 가질 수 있습니다. not good을 바이그램(bigram) 특성으로 넣으면 음수 가중치를 배울 수 있습니다. 나이브 베이즈는 레이블이 충분히 붙지 않은 바이그램에 대해서는 이런 방식으로 대응하기 어렵습니다.
직접 만들기
Step 1: 작은 실제 데이터셋(dataset)
POSITIVE = [
"absolutely loved this movie",
"beautiful cinematography and a great story",
"one of the best films of the year",
"brilliant acting from the lead",
"heartwarming and funny",
]
NEGATIVE = [
"boring and far too long",
"not worth your time",
"the plot made no sense",
"terrible acting, awful script",
"i want my two hours back",
]
작게 시작하는 것은 의도적입니다. 실제 작업에서는 IMDb, SST-2, Yelp polarity처럼 수만 개의 예시를 사용하지만, 수학적인 구조는 동일합니다.
Step 2: 다항 나이브 베이즈 직접 구현
import math
from collections import Counter
def train_nb(docs_by_class, vocab, alpha=1.0):
class_priors = {}
class_word_probs = {}
total_docs = sum(len(d) for d in docs_by_class.values())
for cls, docs in docs_by_class.items():
class_priors[cls] = len(docs) / total_docs
counts = Counter()
for doc in docs:
for token in doc:
counts[token] += 1
total = sum(counts.values()) + alpha * len(vocab)
class_word_probs[cls] = {
w: (counts[w] + alpha) / total for w in vocab
}
return class_priors, class_word_probs
def predict_nb(doc, class_priors, class_word_probs):
scores = {}
for cls in class_priors:
s = math.log(class_priors[cls])
for token in doc:
if token in class_word_probs[cls]:
s += math.log(class_word_probs[cls][token])
scores[cls] = s
return max(scores, key=scores.get)
가산 평활화(additive smoothing), 즉 라플라스 평활화(Laplace smoothing)는 중요합니다. 평활화가 없으면 특정 클래스(class)에서 한 번도 보지 못한 단어의 확률이 0이 되어 로그(log) 계산이 발산해 버립니다. alpha=1.0은 교육용 기본값이며, 실무에서는 alpha=0.01도 자주 사용합니다.
Step 3: 로지스틱 회귀 직접 구현
import numpy as np
def sigmoid(x):
return 1.0 / (1.0 + np.exp(-np.clip(x, -20, 20)))
def train_lr(X, y, epochs=500, lr=0.05, l2=0.01):
n_features = X.shape[1]
w = np.zeros(n_features)
b = 0.0
for _ in range(epochs):
logits = X @ w + b
preds = sigmoid(logits)
err = preds - y
grad_w = X.T @ err / len(y) + l2 * w
grad_b = err.mean()
w -= lr * grad_w
b -= lr * grad_b
return w, b
def predict_lr(X, w, b):
return (sigmoid(X @ w + b) >= 0.5).astype(int)
텍스트 특성은 희소(sparse)하기 때문에 L2 정규화(L2 regularization)가 중요합니다. L2가 없으면 모델이 학습 예시(training example)를 쉽게 암기해 버립니다. 초기값으로 0.01에서 시작한 뒤 조정합니다.
Step 4: 부정어 처리(실패 양상)
not good과 not bad를 생각해 봅시다. BoW 기반 분류기는 {not, good}과 {not, bad}만 보기 때문에, 학습 데이터에서 더 많이 본 쪽으로 학습이 쏠립니다. 바이그램 분류기는 not_good과 not_bad를 각각 별도의 특성으로 인식하기 때문에 서로 다른 가중치를 학습할 수 있고, 보통은 이 정도로도 충분합니다.
바이그램이 없을 때 동작하는 더 거친 방법은 부정어 범위 지정(Negation Scoping) 입니다. 부정어 뒤에 등장하는 토큰(token)에 다음 구두점(punctuation)이 나올 때까지 NOT_ 접두사를 붙이는 방식입니다.
NEGATION_WORDS = {"not", "no", "never", "nor", "none", "nothing", "neither"}
NEGATION_TERMINATORS = {".", "!", "?", ",", ";"}
def apply_negation(tokens):
out = []
negate = False
for token in tokens:
if token in NEGATION_TERMINATORS:
negate = False
out.append(token)
continue
if token in NEGATION_WORDS:
negate = True
out.append(token)
continue
out.append(f"NOT_{token}" if negate else token)
return out
>>> apply_negation(["not", "good", "at", "all", ".", "but", "funny"])
['not', 'NOT_good', 'NOT_at', 'NOT_all', '.', 'but', 'funny']
이제 good과 NOT_good은 서로 다른 특성이 됩니다. 분류기는 둘에 반대 방향의 가중치를 부여할 수 있습니다. 세 줄 정도의 전처리(preprocessing)에 불과하지만, 감성 분석 벤치마크(benchmark)에서는 측정 가능한 수준의 정확도 향상을 만들어 냅니다.
Step 5: 중요한 평가 지표
클래스 불균형이 있으면 정확도(accuracy)만으로는 부족합니다. 실제 감성 분석 말뭉치(corpus)는 70~80%가 긍정이거나 부정인 경우가 흔하며, 다수 클래스(majority class)만 찍어도 정확도 80%가 나오기 때문에 그 숫자만으로는 가치가 없습니다. 다음 항목을 모두 보고해야 합니다.
- 클래스별 정밀도와 재현율. 클래스마다 하나씩 짝을 만들어 보고하고, 매크로 평균(macro-average)을 계산해 클래스 균형을 반영한 단일 수치를 얻습니다.
- 매크로 F1(Macro-F1, 클래스 불균형 상황의 주요 지표). 클래스별 F1의 평균을 동일 가중치로 계산한 값입니다. 클래스 불균형이 있을 때 정확도 대신 사용합니다.
- 가중 F1(Weighted-F1, 대안 지표). 매크로 F1과 동일한 방식이지만 클래스 빈도(frequency)로 가중합니다. 불균형 자체가 비즈니스적으로 의미가 있을 때 매크로 F1과 나란히 보고합니다.
- 혼동 행렬(Confusion Matrix). 원시 빈도(count) 그대로 봅니다. 어떤 단일 수치를 믿기 전에 반드시 먼저 살펴보아야 하며, 모델이 어떤 클래스 쌍을 혼동하는지 드러내 줍니다.
- 클래스별 오류 샘플. 클래스마다 잘못 예측한 샘플 5개씩을 직접 읽어 봅니다. 실제 오류를 읽어 보는 것을 대체할 수 있는 지표는 없습니다.
극단적으로 불균형한 데이터(95:5 이상)에서는 정확도 대신 AUROC와 AUPRC를 보고합니다. AUPRC는 소수 클래스(minority class)에 더 민감하므로, 스팸(spam), 사기(fraud), 드문 감정처럼 보통 더 중요한 쪽이 소수 클래스인 문제에서 특히 유용합니다.
피해야 할 흔한 버그. 불균형 데이터에서 매크로 F1 대신 마이크로 F1(micro-F1)을 보고하면, 다수 클래스가 점수를 지배하기 때문에 수치가 실제보다 높아 보입니다. 매크로 F1을 사용하면 소수 클래스의 성능을 반드시 확인하게 됩니다.
def evaluate(y_true, y_pred):
tp = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 1)
fp = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 1)
fn = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 0)
tn = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 0)
precision = tp / (tp + fp) if tp + fp else 0
recall = tp / (tp + fn) if tp + fn else 0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0
return {"tp": tp, "fp": fp, "tn": tn, "fn": fn, "precision": precision, "recall": recall, "f1": f1}
사용하기
scikit-learn은 같은 작업을 단 여섯 줄로, 그리고 정확하게 처리합니다.
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
pipe = Pipeline([
("tfidf", TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True, stop_words=None)),
("clf", LogisticRegression(C=1.0, max_iter=1000)),
])
pipe.fit(X_train, y_train)
print(pipe.score(X_test, y_test))
눈여겨볼 부분이 세 가지 있습니다. stop_words=None은 부정어를 그대로 살려 둡니다. ngram_range=(1, 2)는 바이그램을 추가해 not_good 같은 표현이 하나의 특성이 되도록 만듭니다. sublinear_tf=True는 반복되는 단어의 영향력을 완화합니다. 이 세 가지 설정 차이가 SST-2에서 75% 정확도 베이스라인과 85% 정확도 베이스라인을 가르는 결정적인 차이입니다.
- 풍자 탐지. 고전 모델은 여기서 거의 실패합니다. 단정적으로 그렇습니다.
- 문서 중간에 감성이 바뀌는 긴 리뷰.
- 측면 기반 감성 분석(Aspect-based Sentiment Analysis; ABSA).
Camera was great but battery was terrible. 처럼 각 측면(aspect)에 감성을 따로 귀속시켜야 하는 경우입니다. 트랜스포머나 구조화된 출력 모델이 필요합니다.
- 비영어 또는 자원이 부족한(low-resource) 언어. 다국어 BERT(Multilingual BERT)가 별다른 학습 없이 제로샷(zero-shot) 베이스라인을 무료로 제공합니다.
위 조건에 해당한다면 Phase 7(Transformer Deep Dive)로 건너뛰는 것이 좋습니다. 그렇지 않다면, TF-IDF에 바이그램과 부정어 처리를 더한 나이브 베이즈 또는 로지스틱 회귀가 여전히 2026년 운영 환경의 베이스라인입니다.
재현성의 함정(다시)
감성 모델을 다시 학습시키는 일은 흔하지만, 다시 평가해 보는 일은 그렇게 흔하지 않습니다. 논문에 보고된 정확도 수치는 특정한 데이터 분할(split), 특정한 전처리, 특정한 토크나이저(tokenizer) 위에서 얻은 숫자입니다. 동일한 파이프라인을 사용하지 않은 채 새 모델과 베이스라인을 비교하면, 그 사이의 차이(delta)가 잘못된 의미로 해석됩니다. 논문의 숫자를 그대로 가져오지 말고, 항상 자신의 파이프라인에서 베이스라인을 다시 생성해야 합니다.
산출물 만들기
outputs/prompt-sentiment-baseline.md로 저장합니다.
---
name: sentiment-baseline
description: Design a sentiment analysis baseline for a new dataset.
phase: 5
lesson: 05
---
Given a dataset description (domain, language, size, label granularity, latency budget), you output:
Guide the student in Korean.
1. Feature extraction recipe. Specify tokenizer, n-gram range, stopword policy (usually keep), negation handling (scoped prefix or bigrams).
2. Classifier. Naive Bayes for baseline, logistic regression for production, transformer only if the domain needs sarcasm / aspects / cross-lingual.
3. Evaluation plan. Report precision, recall, F1, confusion matrix, and per-class error samples (not just scalars).
4. One failure mode to monitor post-deployment. Domain drift and sarcasm are the top two.
Refuse to recommend dropping stopwords for sentiment tasks. Refuse to report accuracy as the sole metric when classes are imbalanced (e.g., 90% positive). Flag subword-rich languages as needing FastText or transformer embeddings over word-level TF-IDF.
연습문제
- 쉬움. scikit-learn 파이프라인에
apply_negation을 전처리 단계로 추가하고, 작은 감성 데이터셋에서 F1 변화량(delta)을 측정합니다.
- 중간. 클래스 가중 로지스틱 회귀(class-weighted logistic regression)를 구현합니다. scikit-learn에서는
class_weight="balanced"를 전달하거나, 직접 그래디언트(gradient)를 유도해 보세요. 90:10의 합성 클래스 불균형 상황에서 효과를 측정합니다.
- 어려움. 감성 모델의 잔차(residual) 위에 두 번째 분류기를 학습시켜 풍자 탐지기(sarcasm detector)를 만들어 봅니다. 실험 설정을 문서화하고, 정확도가 우연 수준(chance, 2-클래스 풍자 문제에서는 약 50%) 아래일 때는 독자에게 명확하게 경고하세요. 첫 시도는 보통 이 우연 수준 근처에 머무르게 됩니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 극성(Polarity) | 긍정 또는 부정 | 보통 이진 레이블이며, 중립(neutral)이나 5점 척도처럼 세분화된 형태로 확장되기도 합니다. |
| 측면 기반 감성 분석(Aspect-based Sentiment Analysis) | 측면별 극성 | 텍스트에서 언급된 특정 개체(entity)나 속성(attribute)에 감성을 귀속시키는 작업입니다. |
| 부정어 범위 지정(Negation Scoping) | 주변 토큰을 뒤집기 | not 뒤의 토큰에 구두점이 나올 때까지 NOT_ 접두사를 붙이는 방식입니다. |
| 라플라스 평활화(Laplace Smoothing) | 카운트에 1 더하기 | 나이브 베이즈에서 확률이 0이 되는 특성이 생기는 것을 막아 줍니다. |
| L2 정규화(L2 Regularization) | 가중치 줄이기 | 손실 함수에 lambda * sum(w^2)를 더합니다. 희소한 텍스트 특성에서는 필수적입니다. |
더 읽을거리