Doc2Vec & PyTorch Doc2Vec & Optuna 정리

syeom·2026년 6월 14일

멀티캠퍼스 데이터분석 6월 11일 수업 내용 — Doc2Vec 이론 및 실습, PyTorch로 Doc2Vec 직접 구현, Optuna 하이퍼파라미터 최적화


📌 목차

Doc2Vec
1. Doc2Vec 이론 & 매개변수
2. Doc2Vec 실습 — 문서 유사도 & 시각화 & 분류

PyTorch Doc2Vec

  1. PyTorch로 Doc2Vec 직접 구현

Optuna

  1. Optuna — 확률 기반 하이퍼파라미터 최적화

Doc2Vec

1. Doc2Vec 이론 & 매개변수

  • Word2Vec의 확장판 — 단어 벡터뿐만 아니라 문서(문장) 전체의 벡터를 학습
  • 기본 매개변수, 속성, 메서드는 Word2Vec과 동일하며 추가 기능이 있음

추가 매개변수

매개변수기본값설명
dm1학습 알고리즘 (1: PV-DM / 0: PV-DBOW)
dm_mean0문장 벡터 계산 방식 (0: 합산 / 1: 평균)
dm_concat0문서 벡터와 단어 벡터를 결합할지 여부 (결합 시 차원 급증)
dbow_words0PV-DBOW 사용 시 단어 벡터도 동시에 학습할지 여부
alpha0.025초기 학습률 (경사하강법 보폭)
min_alpha0.0001최소 학습률
negative5잘못된 단어 배치(Negative Sampling)로 학습에 활용

dm 옵션 비교

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)새로운 문장을 학습된 모델로 벡터화

💡 파인 튜닝(증분 학습)을 사용하는 이유

  • 데이터 양이 증가하면 성능이 향상될 수 있음
  • 분할 학습으로 시간을 나눠서 사용 가능
  • 기존 학습 데이터와 유사한 새 데이터를 추가 학습하여 성능 향상

2. Doc2Vec 실습 — 문서 유사도 & 시각화 & 분류

데이터 준비 & 전처리

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]

TaggedDocument — 문서에 ID 부여

# Doc2Vec은 각 문장에 고유 태그(ID)를 부여해야 함
tagged = [
    TaggedDocument(words=token, tags=[f"DOC_{idx}"])
    for idx, token in enumerate(tokenize_docs)
]

Doc2Vec 학습

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)

t-SNE로 문서 벡터 2D 시각화

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))

PyTorch Doc2Vec

3. PyTorch로 Doc2Vec 직접 구현

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]

PV-DM 학습 샘플 생성

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)

PVDMDataset & collate_fn

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으로 가변 길이 데이터를 직접 묶어줍니다.

Doc2VecPVDM 모델 정의

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()

Xavier 초기화

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 vs PyTorch Doc2Vec 비교

gensim Doc2VecPyTorch Doc2Vec
구현 난이도낮음높음
커스터마이징제한적자유로움
infer_vector()내장직접 구현
GPU 활용제한적가능
주요 사용빠른 실험딥러닝 파이프라인 통합

Optuna

4. Optuna — 확률 기반 하이퍼파라미터 최적화

GridSearchCV vs Optuna

GridSearchCVOptuna
탐색 방식모든 조합 시도확률 기반 — 유망한 조합 집중 탐색
속도느림빠름
조합 제어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)

objective 함수 정의

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_* 함수 정리

함수설명예시
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 생성 & 최적화

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 를 사용하는 이유
Cgamma 같은 파라미터는 0.001 에서 10.0 처럼 값의 범위가 매우 넓습니다.
로그 스케일로 탐색하면 작은 값과 큰 값을 고르게 탐색할 수 있습니다.


📎 핵심 개념 요약

개념설명
Doc2VecWord2Vec 확장 — 단어 + 문서 벡터 동시 학습
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로 변환
profile
공부 기록

0개의 댓글