멀티캠퍼스 데이터분석 6월 11일 수업 내용 — Doc2Vec 이론 및 실습, PyTorch로 Doc2Vec 직접 구현, Optuna 하이퍼파라미터 최적화
Doc2Vec
1. Doc2Vec 이론 & 매개변수
2. Doc2Vec 실습 — 문서 유사도 & 시각화 & 분류
PyTorch Doc2Vec
Optuna
| 매개변수 | 기본값 | 설명 |
|---|---|---|
dm | 1 | 학습 알고리즘 (1: PV-DM / 0: PV-DBOW) |
dm_mean | 0 | 문장 벡터 계산 방식 (0: 합산 / 1: 평균) |
dm_concat | 0 | 문서 벡터와 단어 벡터를 결합할지 여부 (결합 시 차원 급증) |
dbow_words | 0 | PV-DBOW 사용 시 단어 벡터도 동시에 학습할지 여부 |
alpha | 0.025 | 초기 학습률 (경사하강법 보폭) |
min_alpha | 0.0001 | 최소 학습률 |
negative | 5 | 잘못된 단어 배치(Negative Sampling)로 학습에 활용 |
PV-DM (dm=1) | PV-DBOW (dm=0) | |
|---|---|---|
| 방식 | 주변 단어 + 문서 벡터로 중심 단어 예측 | 문서 벡터로 단어들을 예측 |
| 특징 | 단어 순서 고려 | 빠르고 단순 |
| 속성 | 설명 |
|---|---|
model.wv | 학습된 단어 벡터 저장소 |
model.dv | 학습된 문서 벡터 저장소 |
model.dv['DOC_0'] | 특정 문서 ID의 벡터 반환 |
model.dv.index_to_key | 문서 ID 리스트 |
| 메서드 | 설명 |
|---|---|
build_vocab() | 단어/문서 사전 구성 (update=True 로 갱신 가능) |
train() | 사전 구축 후 직접 학습 수행 (추가 학습도 가능) |
infer_vector(words) | 새로운 문장을 학습된 모델로 벡터화 |
💡 파인 튜닝(증분 학습)을 사용하는 이유
- 데이터 양이 증가하면 성능이 향상될 수 있음
- 분할 학습으로 시간을 나눠서 사용 가능
- 기존 학습 데이터와 유사한 새 데이터를 추가 학습하여 성능 향상
from konlpy.tag import Komoran
from gensim.models.doc2vec import Doc2Vec, TaggedDocument
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
import re
docs = [
'#나는 커피를 정말 좋아한다',
'%오늘 아침에 에스프레소 두 잔을 마셨다',
'카페라떼가 제일 맛있다고 생각한다',
'나는 차를 더 자주 마신다',
'녹차를 마시면 기분이 편안해진다',
'홍차는 향이 깊고 고급스러운 느낌이다',
'카페에서 책을 읽는 시간이 너무 좋다',
'허브티도 몸에 좋은거 같다'
]
komoran = Komoran()
allow_pos = ['NNP', 'NNG', 'VV', 'VA', 'MAG', 'SL']
stop_word = ['하다', '되다', '이다', '것', '수', '거']
def nomalize(text):
"""특수문자 제거 & 공백 정규화"""
text = re.sub(r"[^가-힣0-9a-zA-Z\s\.]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def tokenize(text):
text = nomalize(text)
tokens = []
for word, pos in komoran.pos(text):
if pos in allow_pos:
if pos in ['VV', 'VA']:
word += '다' # 동사/형용사에 '다' 추가 → 기본형으로 통일
if word not in stop_word and len(word) > 1:
tokens.append(word)
return tokens
tokenize_docs = [tokenize(doc) for doc in docs]
# Doc2Vec은 각 문장에 고유 태그(ID)를 부여해야 함
tagged = [
TaggedDocument(words=token, tags=[f"DOC_{idx}"])
for idx, token in enumerate(tokenize_docs)
]
model = Doc2Vec(
documents = tagged,
vector_size = 100,
window = 3,
min_count = 1,
dm = 1, # PV-DM 방식
dm_mean = 1, # 평균 벡터 사용
epochs = 100,
seed = 42,
negative = 5
)
print('문서 벡터 수:', len(model.dv))
print('단어 벡터 수:', len(model.wv))
def print_similities(doc_id):
dic_vec = model.dv[f"DOC_{doc_id}"]
mat = np.array([model.dv[f"DOC_{i}"] for i in range(len(docs))])
sims = cosine_similarity([dic_vec], mat)[0]
order = sims.argsort()[::-1]
print(f'기준 문장 [{doc_id}]: {docs[doc_id]}')
for idx in order:
print(f' → [{idx}] {docs[idx]} | cos = {round(sims[idx], 4)}')
print_similities(2)
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt
import platform
if platform.system() == 'Darwin':
plt.rc('font', family='AppleGothic')
else:
plt.rc('font', family='Malgun Gothic')
plt.rcParams['axes.unicode_minus'] = False
X = np.array([model.dv[f"DOC_{idx}"] for idx in range(len(docs))])
tsne = TSNE(n_components=2, perplexity=5, random_state=42, max_iter=2000)
X_2d = tsne.fit_transform(X)
plt.figure(figsize=(12, 8))
plt.scatter(X_2d[:, 0], X_2d[:, 1])
for idx, text in enumerate(docs):
plt.annotate(
text,
xy = (X_2d[idx, 0], X_2d[idx, 1]),
xytext = (3, 3),
textcoords = 'offset points' # 각 점으로부터 3px 떨어진 위치에 텍스트
)
plt.show()
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report
# 커피 관련(1) / 비커피(0) 레이블
y = np.array([1, 1, 1, 0, 0, 0, 1, 0])
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y
)
clf = LogisticRegression(random_state=42, max_iter=2000)
clf.fit(X_train, y_train)
pred = clf.predict(X_test)
print(classification_report(pred, y_test))
gensim 없이 PyTorch로 PV-DM 방식의 Doc2Vec을 직접 구현합니다.
import re, collections, random
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from konlpy.tag import Komoran
# 빈도수 계산
freq = collections.Counter(
word for doc in tokenized_docs for word in doc
)
# 특수 토큰 포함 단어 사전 생성
# <PAD> : 패딩 (길이 통일용)
# <UNK> : OOV 단어 대체
min_count = 1
vocab = ['<PAD>', '<UNK>'] + [word for word, cnt in freq.items() if cnt >= min_count]
stoi = {word: idx for idx, word in enumerate(vocab)}
def encode(words):
"""단어 → 인덱스 변환 (미등록 단어는 <UNK>)"""
return [stoi.get(word, stoi['<UNK>']) for word in words]
encoded_docs = [encode(doc) for doc in tokenized_docs]
def build_pvdm_samples(encode_data, window=2):
"""(문서 ID, 주변 단어 목록, 중심 단어) 샘플 생성"""
samples = []
for d_id, doc in enumerate(encode_data):
if len(doc) < 2:
continue
for center in range(len(doc)):
left = max(0, center - window)
right = min(len(doc), center + window + 1)
ctx = [doc[i] for i in range(left, right) if i != center]
if ctx:
samples.append((d_id, ctx, doc[center]))
return samples
samples = build_pvdm_samples(encoded_docs)
class PVDMDataset(Dataset):
def __init__(self, samples):
self.samples = samples
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
d_id, ctx, target = self.samples[idx]
return d_id, torch.tensor(ctx, dtype=torch.long), torch.tensor(target, dtype=torch.long)
def collate_fn(batch):
"""문맥 길이가 다르므로 패딩 없이 리스트로 전달"""
doc_ids, ctx_lists, targets = zip(*batch)
return torch.tensor(doc_ids, dtype=torch.long), list(ctx_lists), torch.stack(targets)
dataset = PVDMDataset(samples)
loader = DataLoader(dataset, batch_size=64, shuffle=True, collate_fn=collate_fn)
💡
collate_fn이 필요한 이유
문맥(context)의 단어 수가 문장마다 다르기 때문에 기본 DataLoader의 자동 배치가 불가능합니다.
collate_fn으로 가변 길이 데이터를 직접 묶어줍니다.
class Doc2VecPVDM(nn.Module):
def __init__(self, vocab_size, doc_size, emb_dim=100):
super().__init__()
self.word_emb = nn.Embedding(vocab_size, emb_dim) # 단어 임베딩
self.doc_emb = nn.Embedding(doc_size, emb_dim) # 문서 임베딩
self.proj = nn.Linear(emb_dim, vocab_size, bias=False) # 예측 레이어
self._init_weight()
def _init_weight(self):
"""Xavier 초기화 — 초기 임베딩을 적절한 분포로 설정"""
nn.init.xavier_uniform_(self.word_emb.weight)
nn.init.xavier_uniform_(self.doc_emb.weight)
nn.init.xavier_uniform_(self.proj.weight)
def forward(self, doc_ids, ctx_lists):
device = self.word_emb.weight.device
# 문서 벡터
dvec = self.doc_emb(doc_ids.to(device))
# 문맥 단어 벡터의 평균 (dm_mean=1 방식)
ctx_mean = []
for ctx in ctx_lists:
we = self.word_emb(ctx.to(device))
ctx_mean.append(we.mean(dim=0))
ctx_mean = torch.stack(ctx_mean, dim=0)
# 문서 벡터 + 문맥 벡터 평균 → 중심 단어 예측
mix = (dvec + ctx_mean) / 2.0
logits = self.proj(mix)
return logits
def infer_vertor(self, word_ids, epochs=100, lr=0.05):
"""새로운 문장을 벡터화 (추가 학습 아닌 추론)"""
device = self.word_emb.weight.device
# 새 문서 벡터를 정규분포 난수로 초기화
dvec = nn.Parameter(torch.randn(self.doc_emb.embedding_dim, device=device))
optimizer = optim.SGD([dvec], lr=lr)
loss_fn = nn.CrossEntropyLoss()
# 임시 윈도우 샘플 생성
window = 2
triples = []
if len(word_ids) < 2:
word_ids = word_ids * 2
for center in range(len(word_ids)):
left = max(0, center - window)
right = min(len(word_ids), center + window + 1)
ctx = [word_ids[i] for i in range(left, right) if i != center]
if ctx:
triples.append((ctx, word_ids[center]))
# 문서 벡터만 업데이트 (단어 임베딩은 고정)
for _ in range(epochs):
random.shuffle(triples)
for ctx, target in triples:
we = self.word_emb(torch.tensor(ctx, device=device))
ctx_mean = we.mean(dim=0)
mix = (dvec + ctx_mean) / 2.0
logits = self.proj(mix)
loss = loss_fn(logits.unsqueeze(0), torch.tensor([target], device=device))
optimizer.zero_grad()
loss.backward()
optimizer.step()
return dvec.detach().cpu()
emb = nn.Embedding(3, 5)
print('초기화 전:', emb.weight)
nn.init.xavier_uniform_(emb.weight)
print('Xavier 초기화 후:', emb.weight)
💡 Xavier 초기화란?
임베딩 행렬의 초기 가중치를 입출력 크기에 맞는 균등 분포로 설정하여
학습 초반부터 안정적으로 수렴하도록 돕는 초기화 방법입니다.
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = Doc2VecPVDM(vocab_size=len(vocab), doc_size=len(docs), emb_dim=120).to(device)
optimizer = optim.Adam(model.parameters(), lr=3e-3)
loss_fn = nn.CrossEntropyLoss()
for epoch in range(50):
model.train()
total_loss = 0.0
for doc_ids, ctx_lists, targets in loader:
optimizer.zero_grad()
logits = model(doc_ids.to(device), ctx_lists)
loss = loss_fn(logits, targets.to(device))
loss.backward()
optimizer.step()
total_loss += loss.item()
if (epoch + 1) % 10 == 0:
print(f'{epoch+1}회차 loss: {round(total_loss / len(loader), 4)}')
# 문서 벡터 추출
@torch.no_grad()
def doc_vector(doc_id):
return model.doc_emb.weight[doc_id].detach().cpu()
# 코사인 유사도
def cos_sims(a, b):
return float(nn.functional.cosine_similarity(a.unsqueeze(0), b.unsqueeze(0), dim=1))
# 새 문장 추론
new_sentence = '오늘 카페에서 에스프레소를 마시며 책을 읽었다'
new_tokens = tokenize(new_sentence)
new_ids = encode(new_tokens)
new_vec = model.infer_vertor(new_ids)
for idx, text in enumerate(docs):
sims = cos_sims(new_vec, doc_vector(idx))
print(f'{idx} - {text} : 코사인 유사도 ({round(sims, 4)})')
| gensim Doc2Vec | PyTorch Doc2Vec | |
|---|---|---|
| 구현 난이도 | 낮음 | 높음 |
| 커스터마이징 | 제한적 | 자유로움 |
infer_vector() | 내장 | 직접 구현 |
| GPU 활용 | 제한적 | 가능 |
| 주요 사용 | 빠른 실험 | 딥러닝 파이프라인 통합 |
| GridSearchCV | Optuna | |
|---|---|---|
| 탐색 방식 | 모든 조합 시도 | 확률 기반 — 유망한 조합 집중 탐색 |
| 속도 | 느림 | 빠름 |
| 조합 제어 | params 로 제어 | n_trials 로 제어 |
| 장점 | 완전 탐색 | 대규모 파라미터 공간에서 유리 |
# !pip install optuna
import optuna
from sklearn.datasets import load_iris
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.svm import SVC
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import f1_score, make_scorer
X, y = load_iris(return_X_y=True)
def objective(trial):
# 파라미터 조합 생성
C = trial.suggest_float('C', 1e-3, 10.0, log=True) # 로그 스케일
gamma = trial.suggest_float('gamma', 1e-4, 1.0, log=True)
kernel = trial.suggest_categorical('kernel', ['linear', 'rbf'])
model = SVC(C=C, gamma=gamma, kernel=kernel)
pipe = Pipeline([
('std', StandardScaler()),
('clf', model)
])
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(
pipe, X, y, cv=cv,
scoring=make_scorer(f1_score, average='macro')
)
return scores.mean()
| 함수 | 설명 | 예시 |
|---|---|---|
suggest_int(name, low, high) | 정수 범위에서 탐색 | suggest_int('n', 1, 10) |
suggest_float(name, low, high) | 실수 범위에서 탐색 | suggest_float('C', 0.1, 10.0) |
suggest_float(..., log=True) | 로그 스케일로 탐색 (실수에서 주로 사용) | suggest_float('lr', 1e-5, 1e-1, log=True) |
suggest_categorical(name, choices) | 범주 중에서 선택 | suggest_categorical('kernel', ['rbf', 'linear']) |
study = optuna.create_study(
direction = 'maximize', # 목표: 점수 최대화 (최소화는 'minimize')
study_name = 'class_ml_tuning'
)
study.optimize(
objective,
n_trials = 30, # 총 30번 시도
show_progress_bar = True
)
print('최적 파라미터:', study.best_params)
print('최적 Score :', study.best_value)
💡
log=True를 사용하는 이유
C나gamma같은 파라미터는0.001에서10.0처럼 값의 범위가 매우 넓습니다.
로그 스케일로 탐색하면 작은 값과 큰 값을 고르게 탐색할 수 있습니다.
| 개념 | 설명 |
|---|---|
| Doc2Vec | Word2Vec 확장 — 단어 + 문서 벡터 동시 학습 |
TaggedDocument | (단어 리스트, 태그 리스트) — Doc2Vec 입력 형식 |
model.dv | 학습된 문서 벡터 저장소 |
infer_vector() | 새로운 문장을 기존 모델로 벡터화 |
PV-DM (dm=1) | 주변 단어 + 문서 벡터로 중심 단어 예측 |
PV-DBOW (dm=0) | 문서 벡터로 단어 예측 (빠름) |
dm_mean=1 | 문장 벡터를 합산이 아닌 평균으로 계산 |
textcoords='offset points' | 점으로부터 픽셀 단위 떨어진 위치에 텍스트 표시 |
<PAD> / <UNK> | 패딩 / 미등록 단어 대체 특수 토큰 |
collections.Counter() | 단어 등장 빈도 계산 |
collate_fn | 가변 길이 데이터를 배치로 묶는 커스텀 함수 |
| Xavier 초기화 | 임베딩 가중치를 균등 분포로 초기화하여 학습 안정화 |
nn.init.xavier_uniform_() | Xavier 균등 초기화 적용 |
nn.Parameter() | 학습 가능한 텐서 생성 (역전파 대상) |
detach().cpu() | 연산 그래프 분리 후 CPU로 이동 |
nn.functional.cosine_similarity() | PyTorch 코사인 유사도 계산 |
| Optuna | 확률 기반 하이퍼파라미터 탐색 라이브러리 |
create_study(direction='maximize') | 최적화 방향 설정 (최대화/최소화) |
study.optimize(fn, n_trials=30) | n번 시도하며 최적 파라미터 탐색 |
suggest_float(..., log=True) | 로그 스케일로 실수 파라미터 탐색 |
study.best_params | 최적 파라미터 딕셔너리 |
study.best_value | 최적 점수 |
make_scorer(f1_score, average='macro') | 커스텀 평가 지표를 scorer로 변환 |