Supplementary Materials
- 인공지능 09_텍스트 전처리 & 워드 임베딩
- LG AI연구원 8기 02_NLP & RNN
요약
- 텍스트를 (한국어는 형태소 단위로) 토큰화
- → 불필요한 요소 제거
- → 벡터화(One-Hot → BoW → TF-IDF) 순으로,
- 점점 더 단어의 중요도를 잘 반영하는 방향으로 발전한다.
1. 자연어처리 개요
- 자연어처리(NLP; Natural Language Processing)
- 컴퓨터가 인간처럼 텍스트를 이해할 수 있도록 하는 기술
-
cf. NLP의 두 가지 영역
- NLU (Natural Language Understanding): 자연어 이해
- NLG (Natural Language Generation): 자연어 생성
-
대표적인 NLP 태스크
- 기계 번역(Machine Translation)
- 대화 시스템(Dialog System)
- 문서 요약(Summarization)
- 스토리 생성(Story Generation) 등
1.1. NLP 시스템 구축 단계
- NLP 시스템은 다음 세 단계로 구성된다.
- 전처리 (Preprocessing): 입력 텍스트를 토큰 단위로 분할 (토큰화)
- 임베딩 (Embedding): 단어를 수치 벡터 공간으로 표현 (N x D 벡터)
- 모델링 (Modeling): 대규모 텍스트 코퍼스로 학습하여 예측·분류 수행
1.2. NLP의 기본 단위
- NLP의 기본 단위들을 작은 것부터 큰 것까지 정리하면 이렇다.
- 토큰(Token): 가장 작은 단위로, 문맥에 따라 단어·형태소·서브워드가 될 수 있다.
- 문장: 마침표·느낌표·물음표로 구분되는 최소 독립 단위
- 단락: 문장 집합
- 문서: 단락 집합
- 말뭉치(corpus): 수집된 텍스트 데이터 전체
2. 텍스트 전처리
2.1. 토큰
- 토큰 (Token)
-
문장이나 문서를 구성하는 가장 작은 단위의 텍스트 조각
-
토큰이 무엇인지 정의하는 기준은 분석 목적이나 알고리즘에 따라 다르다.
-
단어가 될 수도 있고, 형태소, 글자(문자), 혹은 단어의 일부(서브워드, Subword)가 될 수도 있다.
-
예: “안녕하세요”라는 단어가 있을 때, 토큰은 다음과 같이 나뉠 수 있다.
- 단어 단위 토큰:
["안녕하세요"] - 글자 단위 토큰:
["안", "녕", "하", "세", "요"] - 서브워드 단위 토큰:
["안녕", "##하세요"](알고리즘에 따라 다름)
- 단어 단위 토큰:
-
2.2. 토큰화
-
토큰화 (Tokenization)
- 입력된 가공되지 않은 텍스트(Raw Text; corpus)를
- 의미 있는 최소 단위인 토큰 단위로 잘게 쪼개는 과정(Process)
-
컴퓨터는 텍스트를 인간처럼 곧바로 이해하지 못한다.
- 따라서 텍스트를 숫자로 변환(임베딩)하기 전에, 컴퓨터가 처리하기 가장 적절한 크기의 조각으로 나누어 주는 이 토큰화 과정이 반드시 필요하다.
(1) 문장 토큰화
-
문장 토큰화
- 문서를 문장 단위로 분리
-
마침표가 항상 문장 경계는 아니라는 점이 어려움이다.
- “애들아 A. I. 수업 이해했다고 해줘.”에서
A.와I.의 마침표를 문장 끝으로 잘못 처리하면 안 된다. - 결국 언어와 코퍼스 특성에 맞는 규칙을 직접 정의해야 하므로 전처리는 까다로운 작업이다.
- “애들아 A. I. 수업 이해했다고 해줘.”에서
(2) 어절 기반
- 단어 토큰화
- 공백/구두점 기준으로 단어 분리
- →
"나는 밥을 먹었다"→["나는", "밥을", "먹었다"]
- 구두점이나 특수문자를 단순히 제외해서는 안 된다.
- 단어 토큰화에서 띄어쓰기와 구두점 제거만으로 자르면 문제가 생기는 경우가 많다.
- 예를 들어
- “Ph.D”, “A.I.”처럼 의미 있는 약어가 깨지고,
$45.55(금액),01/02/06(날짜),123,456,789(콤마),New York(띄어쓰기 합성어) 같은 경우는 잘못 분리될 수 있다.
-
영어는 띄어쓰기 기반 단어 토큰화가 잘 통하지만,
- 한국어는 어절(띄어쓰기) 기반 토큰화를 지양한다.
- 한국어는 교착어(첨가어)라서 조사·어미가 단어에 그대로 붙기 때문이다.
-
성호는 = Sung is, 성호와 = with Sung, 성호에게 = to Sung
- → 같은 단어(성호)가 조사 때문에 매번 다른 단어로 인식되는 문제 발생
(3) 형태소 기반
한국어는 교착어라 어절 기반 토큰화로는 부족하고, 형태소(morpheme) 기반으로 토큰화한다.
-
형태소는 의미(어휘+문법)를 가진 가장 작은 단위로 두 종류가 있다.
- 자립 형태소
- 혼자 쓰일 수 있는 형태소
- (명사·대명사·수사, 관형사·부사, 감탄사 등)
- 의존 형태소
- 다른 형태소와 결합해야 쓰이는 형태소
- (접사, 어미, 조사, 어간)
-
-
- 동사·형용사의 어간+어미(먹-다/먹-고/먹-어서),
- 접두사(햇과일, 맨손),
- 접미사(나무꾼, 선생님) 등도 의존 형태소에 속한다.
-
- 자립 형태소
-
예시 “성호가 자연어처리 수업을 한다.”를 분석하면:
- 자립: 성호, 자연어, 처리, 수업
- 의존: -가(조사), 을-(조사), 하-(어간), -ㄴ다(어미)
(4) 실습: Okt 유무에 따른 vocab 차이
위 예문을 그대로 코드로 — 어절 기반과 형태소 기반의 차이를 vocab으로 확인한다.
from konlpy.tag import Okt # KoNLPy — Java 필요
from sklearn.feature_extraction.text import CountVectorizer
okt = Okt()
sample = "성호가 자연어처리 수업을 한다."
# Case A: raw 문장 그대로 → 어절(공백) 단위로 잘림
vec_A = CountVectorizer()
vec_A.fit([sample])
print("Case A (Okt 없이):", sorted(vec_A.get_feature_names_out()))
# → ['성호가', '수업을', '자연어처리', '한다'] — 조사가 붙은 채 한 단어 취급
# Case B: Okt 형태소 분석 후 → 조사가 분리됨
tokens = okt.morphs(sample)
print("Okt 토큰:", tokens) # ['성호', '가', '자연어', '처리', '수업', '을', '한다', '.']
vec_B = CountVectorizer()
vec_B.fit([' '.join(tokens)])
print("Case B (Okt 사용):", sorted(vec_B.get_feature_names_out()))- ‘성호가’(조사마다 다른 단어 취급) vs ‘성호’+‘가’(분리)
- → 교착어인 한국어에서
okt.morphs()가 필요한 이유가 그대로 드러난다.
- → 교착어인 한국어에서
cf. 서브워드 토큰화
- 단어를 더 작은 조각으로 (BPE, WordPiece, SentencePiece)
- → 신경망/LLM에서 표준
2.3. 토크나이저
- 토크나이저 (Tokenizer)
- 토큰화 과정을 실제로 수행하는 도구 또는 알고리즘
| 언어 | 도구 | 특징 |
|---|---|---|
| 영어 | NLTK | word_tokenize, sent_tokenize 등 교육·기본용 |
| 영어 | spaCy | 빠르고 정확, 실무용 파이프라인 |
| 한국어 | KoNLPy (Okt, Mecab, Komoran, Hannanum) | 형태소 분석 기반 |
| 한국어 | Mecab | 속도·정확도 우수 (설치 약간 번거로움) |
| 공통 | Hugging Face tokenizers, SentencePiece | 서브워드, LLM/딥러닝용 |
2.4. 불용어 제거
-
불용어 제거 (Stopword Removal)
- 분석에 큰 의미가 없는 흔한 단어(불용어)를 제거해
- 노이즈를 줄이고 핵심 단어에 집중하는 작업이다.
-
불용어 예시
- 영어:
the, a, is, in, of, and ... - 한국어:
이, 그, 저, 은, 는, 을, 를, 하다, 있다 ...(조사·접속사 등)
- 영어:
-
방법
- 사전 기반: NLTK
stopwords, spaCy 내장 불용어 목록 사용 - 커스텀 목록: 도메인에 맞게 직접 추가/삭제 (한국어는 표준 사전이 없어 직접 구축하는 경우가 많음)
- 사전 기반: NLTK
-
cf. 주의할 점
- 불용어 제거가 항상 좋은 건 아님: 감정분석(“not good”)이나 LLM 기반 작업에서는 의미가 사라질 수 있어 생략하기도 한다.
- 최신 딥러닝/LLM에서는 보통 서브워드 토크나이저만 쓰고 불용어 제거는 잘 하지 않는다.
- 불용어 제거는 주로 고전적 NLP(TF-IDF, 키워드 추출, 토픽 모델링)에서 유용하다.
3. 워드 임베딩
-
워드 임베딩 (Word Embedding)
- 단어를 실수 벡터로 매핑하는 표현 방식이다.
-
텍스트 벡터화로 넘어가는 이유
- 컴퓨터가 단어의 의미를 파악하려면 텍스트를 숫자(벡터)로 바꿔야 한다.
- 이미지가 RGB 3-channel 행렬(0~255 숫자)로 표현되듯,
- 텍스트도 벡터로 표현해야 코멘트(극성) 분석, 문서 분류, 문서 요약, 스팸 필터링 같은 작업이 가능해진다.
- 사람의 말을 컴퓨터에게 이해시키는 것이 목표이다.
3.1. 정수 인코딩
- 가장 단순한 방법은 각 단어에 ID를 부여하는 정수 인코딩이다.
예시 성호:0, 자연어:1, 처리:2, 수업:3
3.2. One-Hot Encoding
- 이를 One-Hot 벡터로 바꾸면 해당 위치만 1이고 나머지는 0인 벡터가 된다.
예시
- 하지만 두 가지 큰 문제가 있다.
- 고차원 문제: 어휘가 백만 개면 벡터 차원도 백만이 된다.
- 유사도(의미) 표현 불가 문제: 고양이와 강아지처럼 의미가 비슷한 단어라도 서로 다른 위치에 1이 찍히므로, 유클리디안 거리나 코사인 유사도로 계산하면 유사성을 전혀 잡아내지 못한다.
- 유사도 측정에는 두 방식이 쓰인다.
- 유클리디안 거리: 두 점 사이 직선 거리 √Σ(xᵢ−yᵢ)²
- 코사인 유사도: 두 벡터의 각도(cosθ) 기반, −1(반대)~0(무관)~1(동일)
3.3. Bag of Words (BoW)
-
Bag of Words (BoW)
- 원-핫 인코딩처럼 단어를 단순한 벡터로 표현하지만,
- 의미적 유사성을 반영하지 못한다.
-
BoW 가정은
- 단어 순서를 무시하고 각 문서 내 단어 빈도만 고려해 벡터를 만드는 방식이다. (Term Frequency 기반 벡터화)
- 윤동주 “별 헤는 밤” 예시처럼 단어별 등장 횟수를 세어 문서×단어 행렬(Document-Term Matrix)을 만들고, 이 행렬에 코사인 유사도를 적용하면 논문 검색이나 도서·영화(넷플릭스) 추천 시스템에 활용할 수 있다.
-
한계:
- 단순 빈도라 모든 단어에 동일한 중요도를 준다.
- 즉 의미 없는 조사 ‘이/가’가 10번 나오는 것과 핵심 형용사 ‘재미있는/훌륭한’이 10번 나오는 것을 똑같이 취급하는 문제가 있다.
(1) 예시
- 두 문장을 BoW로 벡터화해 보자.
- 전체 단어 사전 =
[성호, 수업, 한다, 듣는다]라고 하면, 각 문서를 단어별 등장 횟수로 표현한다.
- 문서1:
"성호가 수업을 한다"→ 문서1 = - 문서2:
"성호가 수업을 듣는다"→ 문서2 =
- 전체 단어 사전 =
| 단어 | 성호 | 수업 | 한다 | 듣는다 |
|---|---|---|---|---|
| 문서1 | 1 | 1 | 1 | 0 |
| 문서2 | 1 | 1 | 0 | 1 |
- 순서는 무시하고 빈도만 세므로, “성호”와 “수업”을 공유하는 두 문서는 코사인 유사도가 높게 나온다.
3.4. TF-IDF (가중치 부여)
-
빈도만으로는 부족한 점을 보완하기 위해 단어에 가중치를 준다.
- TF (Term Frequency): 특정 단어 t가 문서 d에 등장한 횟수
- DF (Document Frequency): 단어 t가 등장한 문서의 수
- IDF = log(N/DF): 전체 문서 수 N을 DF로 나눈 값에 로그 (N이 커질수록 값이 기하급수적으로 커지기 때문에 로그로 완화)
- TF, IDF 둘을 곱한 것이 TF-IDF = TF × log(N/DF) 이다.
-
핵심 아이디어는
- 모든 문서에 두루 등장하는 단어는 중요도를 낮게, 특정 문서에만 자주 등장하는 단어는 중요도를 높게 판단하는 것이다.
- 즉 빈도(TF)와 희소성(IDF)을 모두 고려한다.
- 셰익스피어 작품 예시에서 단순 빈도표가 TF-IDF 가중치 표로 바뀌면서 흔한 단어의 값은 줄고 희귀한 단어의 값이 부각된다.
- 정보 검색에서 가장 널리 알려진 가중치 기법이다.
(1) 예시
| 단어 | ”이”(조사) | “자연어” |
|---|---|---|
| 전체 문서가 3개 → | 전체 문서가 3개 → | |
| 어떤 문서에서 5번 등장 → | 어떤 문서에서 5번 등장 → | |
| 3개 문서 모두에 등장 → | 이 문서에만 등장 → | |
- → 결과
- 모든 문서에 흔한 “이”는 가중치가 0으로 소멸하고,
- 특정 문서에만 나오는 “자연어”는 높은 가중치를 받는다.
- 빈도는 같아도 희소성으로 중요도가 갈린다.
(2) sklearn 구현: CountVectorizer vs TfidfVectorizer
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
tf_vec = CountVectorizer(max_features=2000) # TF (단순 빈도) = BoW
tfidf_vec = TfidfVectorizer(max_features=2000) # TF × IDF (희소성 가중치)
# ★ fit_transform은 train에만! test는 transform만 ★ (거꾸로 쓰면 data leakage)
# (X_train_text/X_test_text 정의는 §4.3 실습 참고)
X_train_tf = tf_vec.fit_transform(X_train_text).toarray() # vocab 학습 + 변환
X_test_tf = tf_vec.transform(X_test_text).toarray() # 변환만!- 결과 행렬 shape = (문서 수 × vocab 크기) — §3.3의 Document-Term Matrix 그 자체
4. 실습: 네이버 영화 리뷰 감성 분류
1장의 NLP 3단계(전처리 → 임베딩 → 모델링)를 끝까지 실행하는 종합 실습. 벡터화까지 끝나면 07_Classification·08_Image Data & CNN과 완전히 동일한 골격(CrossEntropyLoss + argmax)이다.
4.1. (참고) ngram_range에 따른 vocab 변화
sample_tokens = ['성호 가 자연어 처리 재미있다']
for ng in [(1, 1), (1, 2), (1, 3)]:
vec = CountVectorizer(ngram_range=ng)
vec.fit(sample_tokens)
print(f"ngram_range={ng}: {sorted(vec.get_feature_names_out())}")
# (1,2)면 1-gram + 2-gram('성호 가', '자연어 처리' 등)이 모두 vocab에 포함된다4.2. 전처리: 형태소 토큰화
import pandas as pd
from tqdm.auto import tqdm
SEED = 42
df = pd.read_table('naver-review.txt', on_bad_lines='skip')
df = df.dropna(how='any').reset_index(drop=True)
# 클래스별 균형 샘플 (수업에서는 클래스당 10,000건)
N_PER_CLASS = 2_500
pos = df[df['label'] == 1].sample(n=N_PER_CLASS, random_state=SEED)
neg = df[df['label'] == 0].sample(n=N_PER_CLASS, random_state=SEED)
df_s = pd.concat([pos, neg]).sample(frac=1, random_state=SEED).reset_index(drop=True)
# 토큰화: 형태소 분리 → 공백으로 다시 연결 (sklearn 입력 형식)
def preprocess(text):
if not isinstance(text, str) or not text.strip():
return []
return okt.morphs(text)
tqdm.pandas(desc='Okt')
df_s['tokens'] = df_s['document'].progress_apply(preprocess)
df_s['joined'] = df_s['tokens'].apply(' '.join)
df_s = df_s[df_s['tokens'].str.len() > 0].reset_index(drop=True)4.3. 임베딩: split → 벡터화
from sklearn.model_selection import train_test_split
X_train_text, X_test_text, y_train, y_test = train_test_split(
df_s['joined'].values, df_s['label'].values,
test_size=0.2, random_state=SEED, stratify=df_s['label']
)
X_train_tf = tf_vec.fit_transform(X_train_text).toarray() # §3.4 (2)와 동일
X_test_tf = tf_vec.transform(X_test_text).toarray()
X_train_tfidf = tfidf_vec.fit_transform(X_train_text).toarray()
X_test_tfidf = tfidf_vec.transform(X_test_text).toarray()4.4. 모델링: MLP 분류
import torch, torch.nn as nn, torch.optim as optim
from torch.utils.data import TensorDataset, DataLoader
from sklearn.metrics import accuracy_score
class TextMLP(nn.Module):
"""텍스트 벡터(2000차원)용 MLP — 구조는 ch08 SimpleMLP와 동일"""
def __init__(self, input_dim, hidden1=64, hidden2=32, num_classes=2):
super().__init__()
self.net = nn.Sequential(
nn.Linear(input_dim, hidden1), nn.ReLU(),
nn.Linear(hidden1, hidden2), nn.ReLU(),
nn.Linear(hidden2, num_classes), # raw logits
)
def forward(self, x):
return self.net(x)
def run_text_experiment(X_tr, X_te, y_tr, y_te, label):
"""벡터화 방식(TF/TF-IDF)만 바꿔 동일 골격으로 학습+평가"""
X_tr_t = torch.tensor(X_tr, dtype=torch.float32)
y_tr_t = torch.tensor(y_tr, dtype=torch.long) # CrossEntropy → long!
X_te_t = torch.tensor(X_te, dtype=torch.float32)
loader = DataLoader(TensorDataset(X_tr_t, y_tr_t), batch_size=64, shuffle=True)
model = TextMLP(input_dim=X_tr.shape[1])
train_model(model, loader, nn.CrossEntropyLoss(), # train_model: [[07_Classification]] §8.4
optim.Adam(model.parameters(), lr=0.001), num_epochs=10)
model.eval()
with torch.no_grad():
preds = model(X_te_t.to(DEVICE)).argmax(dim=1).cpu()
acc = accuracy_score(y_te, preds)
print(f">>> [{label}] Test Accuracy: {acc:.4f}")
return acc
acc_tf = run_text_experiment(X_train_tf, X_test_tf, y_train, y_test, 'TF')
acc_tfidf = run_text_experiment(X_train_tfidf, X_test_tfidf, y_train, y_test, 'TF-IDF')- 핵심 비교 포인트
- 입구(전처리·벡터화)만 텍스트용으로 바뀌었을 뿐, 학습·평가 골격은 ch07·08과 동일하다.
- TF vs TF-IDF: 같은 모델에서 벡터화 방식만 바꿔 성능을 비교하는 실험 설계.