ansir 님의 블로그

[ SK 네트웍스 Family AI 캠프 수업 내용 복습 ] 자연어 딥러닝 기초 2025-04-25 본문

SK 네트웍스 family AI 캠프/수업 내용 복습

[ SK 네트웍스 Family AI 캠프 수업 내용 복습 ] 자연어 딥러닝 기초 2025-04-25

ansir 2025. 4. 25. 19:04

시퀀스 데이터와 임베딩 벡터

시퀀스 데이터: 순서가 중요한 데이터

시계열: 시간에 따라서 변하는 데이터
텍스트: 단어 순서가 의미를 결정

임베딩 벡터의 필요성

고정된 길이의 숫자 배열
비슷한 의미의 단어는 비슷한 벡터를 가짐
동작: 임베딩 레이어가 단어 번호를 벡터로 변환

Word2Vec vs. Embedding Layer

Word2Vec: 사전학습모델( 대량의 데이터 ), 단어를 고정된 벡터로 제공, 사전 학습된 의미를 바로 사용 가능, 단점은 새로운 단어는 처리가 어려움

Embedding Layer: 모델 내부에서 학습되는 벡터, 데이터에 특화됨, 초기 벡터는 무작위라서 학습시간이 오래 걸림

시퀀스 데이터란?

순서가 중요한 데이터를 의미합니다.
이런 데이터에서는 각 요소의 순서가 의미에 영향을 줍니다.

예시:

  시계열 데이터: 시간 순서에 따라 변화하는 데이터
  👉 예: 주가, 기온, 센서 데이터 등

  텍스트 데이터: 문장 내에서 단어의 순서가 문장의 의미를 결정
  👉 예:
    "나는 밥을 먹었다."

    "밥을 나는 먹었다."

  **두 문장은 단어는 같지만, 순서에 따라 강조나 의미가 달라짐**

임베딩 벡터의 필요성

딥러닝 모델은 텍스트를 직접 이해하지 못합니다.
그래서 텍스트를 숫자(벡터)로 변환해서 처리할 수 있어야 합니다.
이를 위해 사용하는 것이 임베딩(embedding) 입니다.

임베딩 벡터란?

고정된 길이의 숫자 배열(vector)

같은 의미나 비슷한 맥락에서 사용되는 단어는 비슷한 벡터값을 가지도록 학습됩니다.

예를 들어:

  • "강아지"와 "개" → 비슷한 임베딩 벡터
  • "사과"와 "의자" → 서로 다른 의미 → 벡터도 멀어짐

임베딩 동작 방식

단어 → 숫자 벡터로 변환

텍스트 데이터는 일반적으로 먼저 단어 번호(index)로 바뀌고,
그 번호가 임베딩 레이어(Embedding Layer)를 거쳐 벡터로 변환됩니다.

입력: [4, 6, 2]
↓ 임베딩 레이어
출력: [[0.1, 0.3, ...], [0.4, 0.8, ...], [0.9, 0.2, ...]]

Word2Vec vs. Embedding Layer

구분 Word2Vec Embedding Layer
설명 사전 학습된 임베딩 모델 모델 내부에서 직접 학습
학습 데이터 대규모 일반 텍스트 (뉴스, 위키 등) 우리가 사용하는 특정 데이터셋
특징 이미 의미가 반영된 벡터 제공 모델이 학습하면서 의미를 반영
장점 빠르게 적용 가능, 일반적인 의미 반영 현재 작업에 특화된 의미 반영 가능
단점 새로운 단어 처리 어려움 처음엔 무작위 벡터 → 학습 시간이 필요

요약

  • 시퀀스 데이터는 순서가 중요하며, 텍스트와 시계열 데이터가 대표적.
  • 딥러닝은 텍스트를 이해 못 하므로, 숫자 벡터로 변환(임베딩)해야 함.
  • Word2Vec은 사전 학습된 임베딩, Embedding Layer는 모델 내부에서 직접 학습되는 임베딩.

시퀀스 데이터 임베딩 실습 (Keras & PyTorch 비교)


import numpy as np
import tensorflow as tf
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences
import torch
import torch.nn as nn

# 데이터
sample_sequence = [
    "영화가 정말 재밌어요",
    "이 영화 별로에요",
    "스토리가 최고입니다.",
    "배우 연기가 좋아요.",
    "너무 지루한 영화"
]

# 토큰화 및 정수 인코딩
# Out-Of_Vocabulary: 데이터에 없는 단어가 테스트나 추론에 등장할 수 있어서 특수 토큰을 매핑해준다.
tokenizer = Tokenizer(oov_token="<OOV>")
tokenizer.fit_on_texts(sample_sequence)
sequences = tokenizer.texts_to_sequences(sample_sequence)
print(f"sequences: {sequences}")
# 단어 크기 확인
print(f"tokenizer.word_index: {tokenizer.word_index}")
vocab_size = len(tokenizer.word_index) + 1 # 패딩을 위해서 자리 하나 확보
print(f"vocab_size: {vocab_size}")

# 패딩( 시퀀스의 길이 통일 )
max_length = 5
padded_sequences = pad_sequences(sequences, maxlen=max_length, padding="post", truncating="post")
print(f"padded_sequences: \n{padded_sequences}")

# Embedding 레이어 사용
embedding_dim = 10
keras_model = tf.keras.Sequential([
    tf.keras.layers.Embedding(input_dim=vocab_size, output_dim=embedding_dim),
    # tf.keras.layers.Flatten(),
    # tf.keras.layers.Dense(1, activation="sigmoid")
])

# 임베딩 출력 확인
embedding_output = keras_model.predict(padded_sequences)
print(f"embedding_output: {embedding_output.shape}")

# 임베딩 레이어만 이용한 모델
class EmbeddingModel(nn.Module):
  def __init__(self, vocab_size, embedding_dim):
    super(EmbeddingModel, self).__init__()
    self.embedding = nn.Embedding(vocab_size, embedding_dim)

  def forward(self, x):
    return self.embedding(x)

# 모델 생성
model = EmbeddingModel(vocab_size, embedding_dim)
# 출력
output = model(torch.tensor(padded_sequences))
print(f"output: {output.shape}") # ( 샘플 수, max_len, embedding_dim )

# embedding_dim 벡터 공간에 얼마나 많은 정보를 표현하는지 설정 100~300 선호 커지면 표현력이 높아지고 학습 오래 걸리고, 과적합
# 적은 차원에서 늘려가면서 성능을 비교하는게 좋다.

위 코드는 자연어 문장을 숫자 시퀀스로 바꾸고, 임베딩 벡터로 변환하는 과정을 Keras와 PyTorch 양쪽에서 연습하는 예제입니다.

1. 데이터 준비

sample_sequence = [
    "영화가 정말 재밌어요",
    "이 영화 별로에요",
    "스토리가 최고입니다.",
    "배우 연기가 좋아요.",
    "너무 지루한 영화"
]

간단한 한글 문장 5개를 준비합니다.

2. 텍스트 토큰화 & 정수 인코딩

tokenizer = Tokenizer(oov_token="<OOV>")
tokenizer.fit_on_texts(sample_sequence)
sequences = tokenizer.texts_to_sequences(sample_sequence)
Tokenizer: 단어를 숫자로 바꾸는 도구
  • fit_on_texts: 단어 사전을 만들고 각 단어에 번호를 매김
  • texts_to_sequences: 실제 문장을 숫자 리스트로 변환

💡 oov_token?

"Out-of-Vocabulary"

  • 학습 시 없던 단어가 추론 시 등장할 경우를 대비해 특수 토큰 으로 대체
    실제 배포 모델에서는 꼭 넣는 게 좋습니다.

3. 단어 사전과 시퀀스 확인

print(tokenizer.word_index)
print(sequences)
word_index: 각 단어와 번호가 매핑된 딕셔너리

vocab_size: 단어 개수 + 1
👉 Keras의 Embedding은 0번 인덱스를 패딩 용도로 사용하기 때문!

4. 시퀀스 길이 맞추기 (패딩)

padded_sequences = pad_sequences(sequences, maxlen=5, padding="post", truncating="post")

문장마다 단어 수가 다르다 → 신경망은 고정된 입력만 받음

그래서 모든 문장의 길이를 맞춰줘야 함 → 패딩

padding="post": 뒤에 0을 붙임

truncating="post": 너무 긴 문장은 뒤에서 잘림

5. Keras 임베딩 레이어 사용

keras_model = tf.keras.Sequential([
    tf.keras.layers.Embedding(input_dim=vocab_size, output_dim=embedding_dim),
])

input_dim: 단어 사전 크기 (vocab_size)

output_dim: 벡터의 차원 (embedding_dim)

embedding_output = keras_model.predict(padded_sequences)
print(embedding_output.shape)
출력 형태: (샘플 수, 시퀀스 길이, 임베딩 차원)
→ (5, 5, 10)

6. PyTorch 임베딩 모델 정의

class EmbeddingModel(nn.Module):
  def __init__(self, vocab_size, embedding_dim):
    super(EmbeddingModel, self).__init__()
    self.embedding = nn.Embedding(vocab_size, embedding_dim)

  def forward(self, x):
    return self.embedding(x)

nn.Embedding: Keras와 동일한 역할

텐서 x를 넣으면 해당하는 임베딩 벡터들을 반환

output = model(torch.tensor(padded_sequences))
print(output.shape)

7. 임베딩 차원 설정 팁

"embedding_dim 벡터 공간에 얼마나 많은 정보를 표현하는지 설정 100~300 선호"

임베딩 차원 수가 클수록 표현력은 높아짐
👉 더 미세한 의미 차이까지 표현 가능

그러나 너무 크면:

학습 시간 증가

과적합 가능성 증가

그래서 적절한 차원을 찾기 위해 작은 값부터 늘려가며 실험하는 게 좋습니다.

요약

단계 설명
텍스트 → 숫자 Tokenizer로 단어를 숫자로 변환
시퀀스 정리 pad_sequences로 길이 맞추기
임베딩 단어 번호 → 고정 길이 벡터로 변환
Keras vs PyTorch 임베딩 구현 방식만 다르고 동작은 유사

RNN, LSTM, GRU — 순환 신경망(Recurrent Neural Networks)

RNN: 이전 단계를 기억하면서 다음 단계를 처리한다.

구조: 각 시간단계( timestemp )에서 입력( 단어, 숫자 )을 받고 은닉상태( hidden state )를 업데이트 -> 과거 정보를 요약
Recurrent: 같은 가중치를 모든 시간대 단계에 공유해서 시퀀스를 순차적으로 처리

LSTM / GRU: 장기 의존성 문제

RNN은 긴 시퀀스에서 초기 정보가 점점 희석되는 기울기 소실 기억을 저장하는 gate를 추가해 중요한 정보는 오래 기억
입력 게이트: 새로운 정보를 추가
망각 게이트: 불필요한 정보 삭제
출력 게이트: 현재 출력 결정

GRU: LSTM에서 게이트 수를 줄임

RNN (Recurrent Neural Network)

이전의 정보를 기억하면서 다음 입력을 처리하는 구조

핵심 아이디어

문장처럼 순서가 중요한 데이터는 단순한 신경망으로 처리하기 어려움

그래서 이전 단계의 정보를 기억해서 다음 단계를 처리하는 모델이 RNN

구조 설명

입력: 시퀀스의 각 요소 (예: 단어 또는 숫자)

매 단계에서:

현재 입력 + 이전의 은닉 상태(hidden state) 를 이용해

새로운 은닉 상태를 계산하고 업데이트

이 은닉 상태는 과거 정보 요약본처럼 작용

Recurrent(반복되는) 구조

모든 시간 단계에서 같은 가중치를 사용

즉, 하나의 RNN 셀을 복사해서 여러 시간 단계에 반복 사용

➡️ 장점: 파라미터 수가 일정해서 효율적
➡️ 단점: 긴 시퀀스에서는 과거 정보가 점점 희석됨

RNN의 한계:

장기 의존성 문제

긴 문장에서 앞부분의 정보가 뒤로 갈수록 기억이 약해지는 현상

기울기 소실(Gradient Vanishing)

학습 과정에서 역전파할 때, 초반 입력의 영향이 뒤로 갈수록 점점 작아짐

결국 초기 정보가 사라져버리는 문제

LSTM (Long Short-Term Memory)

기억을 잘 유지하고 잊을 수 있는 능력이 있는 RNN의 확장 버전

해결 방법: "게이트(Gate)" 구조 추가

게이트 종류 역할
입력 게이트 새로운 정보를 기억할지 결정
망각(Forget) 게이트 이전 기억 중 어떤 걸 잊을지 결정
출력 게이트 어떤 정보를 다음 단계로 출력할지 결정

게이트들은 모두 0~1 사이의 값을 가진 시그모이드 함수로 구성됨
→ 각 게이트가 정보 흐름을 조절하는 필터 역할을 함

➡️ 중요한 정보는 오래 기억, 덜 중요한 건 잊음

GRU (Gated Recurrent Unit)

LSTM보다 간단한 구조로 게이트 수를 줄인 버전

GRU의 특징

입력 게이트와 망각 게이트를 하나로 통합 → 업데이트 게이트

구조가 더 단순해서 학습 속도가 빠르고 성능도 비슷

LSTM GRU
게이트 3개 (입력, 망각, 출력) 게이트 2개 (업데이트, 리셋)
더 세밀한 제어 가능 계산량 적고 빠름
긴 시퀀스에 강함 일반적인 상황에서 효율적

요약

모델 특징 장단점
RNN 순서대로 기억 & 처리 긴 시퀀스에서 정보가 사라짐 (기울기 소실)
LSTM 게이트로 기억 제어 오래된 정보도 잘 유지, 계산 복잡
GRU 간소화된 LSTM 빠르고 간단하지만 LSTM만큼 정밀하진 않음

[1, 2, 3] → 4 예측 LSTM 실습

숫자 시퀀스 데이터를 기반으로 다음 숫자를 예측하는 모델 학습

예시: [1, 2, 3] → 4를 예측

모델: PyTorch 기반 LSTM 모델

# ex [1,2,3] - 4 예측
import torch
import torch.nn as nn
import matplotlib.pyplot as plt
from torch.utils.data import Dataset, DataLoader

# 랜덤한 시퀀스 생성하는 데이터셋
class SequenceDataset(Dataset):
  def __init__ (self, sq_length=3, data_size=1000):
    self.sq_length = sq_length
    self.data = []
    for _ in range(data_size):
      start = np.random.randint(1, 100)
      seq = list(range(start, start + sq_length+1))
      self.data.append(seq)

  def __len__(self):
    return len(self.data)

  def __getitem__(self, idx):
    seq = self.data[idx]
    input_seq = seq[:-1]
    target = seq[-1]
    return torch.FloatTensor(input_seq).unsqueeze(-1), torch.FloatTensor([target]).unsqueeze(-1)

# 데이터셋과 데이터로더
dataset = SequenceDataset()
dataloader = DataLoader(dataset, batch_size=16, shuffle=True)

# 데이터셋 형태 확인
# input,target = next(iter(dataset))
# print(f"input: {input}, target: {target}")

# LSTM 모델 정의
class LSTMModel(nn.Module):
  def __init__(self, input_size, hidden_size, output_size):
    super().__init__()
    self.hidden_size = hidden_size
    self.lstm = nn.LSTM(input_size, hidden_size, batch_first=True)
    self.fc = nn.Linear(hidden_size, output_size)
  def forward(self, x ):
    out, (hn, cn) = self.lstm(x)
    out = self.fc(out[:, -1:, :]) # 마지막 타임스텝의 출력( batch, output_size )
    return out

# 모델 생성
input_size = 1 # 입력 차원( 숫자 )
hidden_size = 16
output_size = 1
model = LSTMModel(input_size, hidden_size, output_size)

# 학습 설정
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model.to(device)
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
num_epochs = 200

# 학습 및 손실기록
losses = []
for epoch in range(num_epochs):
  epoch_loss = 0
  for inputs,targets in dataloader:
    inputs, targets = inputs.to(device), targets.to(device)
    optimizer.zero_grad()
    outputs = model(inputs)
    loss = criterion(outputs, targets)
    loss.backward()
    optimizer.step()
    epoch_loss += loss.item()
  losses.append(epoch_loss / len(dataloader))
  if (epoch+1) % 10 == 0:
    print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {epoch_loss / len(dataloader):.4f}')

# 예측 테스트 및 시각화
model.eval()
# 1, 2, 3 -> 4
test_seq = torch.FloatTensor([[1, 2, 3]]).unsqueeze(-1).to(device)
with torch.no_grad():
    prediction = model(test_seq)
print(f'prediction : {prediction.item()}')

# 시각화
plt.plot([0, 1, 2], [1, 2, 3], label='input sequence', marker='o')
plt.plot([2, 3], [3, prediction.item()], label='prediction', marker='x', linestyle='--')
plt.xlabel('Time step')
plt.ylabel('Value')
plt.title('Sequence Prediction')
plt.legend()
plt.grid(True)
plt.show()

1. 데이터 생성

class SequenceDataset(Dataset):

init: 랜덤한 시작 숫자에서 [start, start+1, ..., start+3] 형태의 시퀀스를 만듦

  예: [42, 43, 44, 45]

  앞의 3개는 입력

  마지막 숫자는 정답(label)

getitem:

입력 시퀀스: [42, 43, 44] → (3, 1) 텐서로 반환

타깃: [45] → (1, 1) 텐서

2. LSTM 모델 구조

class LSTMModel(nn.Module):

구성:
nn.LSTM(input_size, hidden_size)
→ 숫자 한 개씩 들어오고 (input_size = 1), 은닉 상태는 16차원

out[:, -1:, :]:
→ 시퀀스 마지막 단계 출력만 사용 (미래 예측에 필요)

nn.Linear(hidden_size, output_size)
→ 예측 결과는 숫자 하나 (output_size = 1)

3. 학습 과정

for epoch in range(num_epochs):

옵티마이저: Adam

손실함수: MSELoss (평균제곱오차, 회귀 문제에 적합)

에폭마다 손실 기록 후 출력

4. 예측

test_seq = torch.FloatTensor([[1,2,3]])

[1, 2, 3] 시퀀스를 넣고 4가 나오는지 확인

model.eval() 모드로 예측

예측 결과 출력 및 시각화

plt.plot([0, 1, 2], [1, 2, 3], label='input sequence', marker='o')
plt.plot([2, 3], [3, prediction.item()], label='prediction', marker='x', linestyle='--')

파란 선: 입력 시퀀스

주황 선: 마지막 숫자부터 예측한 값까지 연결

요약

구성 요소 설명
데이터 연속 숫자 시퀀스: [n, n+1, n+2, n+3]
모델 LSTM → 마지막 hidden → 선형 → 숫자 하나 예측
목적 입력 [a, b, c] → 다음 숫자 d 예측
평가 MSELoss, 예측 결과 시각화

추가 설명

  • LSTM은 시퀀스 데이터에서 과거 정보를 잘 유지하면서 미래를 예측하는 데에 탁월합니다.
  • 이 예제는 숫자지만, 텍스트(단어 시퀀스)로 확장하면 언어모델, 문장 생성기가 될 수 있습니다.
  • input.unsqueeze(-1): 시퀀스를 (batch, seq_len, input_size) 모양으로 만들기 위함
  • 실제 자연어에서는 임베딩 레이어로 단어 → 벡터로 바꾸고 그 벡터를 LSTM에 넣음

텍스트 분류 모델( LSTM 기반 )

감상분류, 카테고리 분류, 스펨여부
1. 시퀀스 -> 벡터 -> 클래스 분류 구조
텍스트 분류모델은 다음단계를 거친다
  - 시퀀스 입력: 문장을 단어 시퀀스로 변환
  - 임베딩: 각 단어를 벡터로 변환
  - LSTM 처리: 처리
  - 분류: 최종 벡터를 Dense 레이어로 변환
2. 그 밖의 레이어
  - Dropout, Dense, Activation

텍스트 분류란 문장을 분석해서 감정, 주제, 스팸 여부 같은 클래스를 예측하는 작업입니다.

예를 들어:

감상 분류: "너무 재미있어요" → 긍정

카테고리 분류: "이 영화는 스릴러다" → 장르: 스릴러

스팸 여부: "무료 문자입니다" → 스팸
  • 이런 작업을 하기 위해 텍스트를 숫자로 변환하고 LSTM 모델에 넣는 구조입니다.

1. 전체 구조 흐름

"시퀀스 → 벡터 → 클래스 분류" 순으로 진행됩니다.

단계별 설명:

시퀀스 입력 (텍스트 → 시퀀스)

문장을 단어 단위로 자르고 정수로 인코딩

→ 예: "너무 재밌어요" → [3, 15, 29]

임베딩 (Embedding Layer)

정수로 표현된 단어를 고정된 크기의 벡터로 변환

→ [3, 15, 29] → [[0.1, -0.3, ...], [...], [...]]
  • 임베딩은 단어 간 의미 유사성을 벡터로 학습하는 과정

LSTM 처리

단어 시퀀스를 순서대로 처리하며 문장의 의미 요약

시간 순서에 따라 정보를 기억하고, 문맥을 이해함

출력은 전체 문장의 정보를 담은 벡터

분류 (Dense Layer)

LSTM이 생성한 벡터를 Dense 레이어에 통과시켜 클래스 확률로 변환

출력 예: [0.1, 0.9] → 부정 10%, 긍정 90%

2. 그 밖에 사용되는 레이어들

Dropout

과적합을 막기 위해 일부 뉴런을 랜덤하게 끄는 레이어

학습할 때만 작동하며, 일반화 성능 향상

Dense (전결합층)

LSTM의 출력을 받아 분류 결과를 만들어내는 마지막 단계

출력 노드 수 = 클래스 수

Activation (활성화 함수)

예측 결과를 확률 형태로 만들기 위해 사용

이진 분류: sigmoid

다중 분류: softmax

전체 흐름 요약 다이어그램

[문장]  
  ↓  
[토큰화 → 시퀀스]  
  ↓  
[Embedding Layer]  
  ↓  
[LSTM Layer]  
  ↓  
[Dropout (선택)]  
  ↓  
[Dense + Activation (분류 결과)]

한국어 영화 리뷰 데이터를 이용한 감성 분류(LSTM 기반)

!pip install konlpy -q

import torch
import torch.nn as nn
import numpy as np
from konlpy.tag import Okt
from torch.utils.data import Dataset, DataLoader
import pandas as pd
from tensorflow.keras.preprocessing.sequence import pad_sequences
import re

!wget http://skt-lsl-nlp-model.s3.amazonaws.com/KoBERT/datasets/nsmc/ratings_train.txt
!wget http://skt-lsl-nlp-model.s3.amazonaws.com/KoBERT/datasets/nsmc/ratings_test.txt

# 학습에서 만든 vocab 재사용하고, 테스트셋에서 UNK 처리
def wordtoindex(wordlists, vocab):
    return [vocab.get(word, vocab['<UNK>']) for word in wordlists]

train_df = pd.read_csv('ratings_train.txt', sep='\t')[:10000]
train_df.dropna(inplace=True)
train_df.reset_index(drop=True, inplace=True)
# 전처리 함수
# 텍스트를 토큰화(형태소) 정수인코딩, 패딩수행
# 특수토큰  <PAD>,<UNK>  0, 1
# 리턴값 : 패딩이 완료된 데이터, 단어사전
okt = Okt()
#  한글과 공백만 추출
X = train_df['document'].apply(lambda x: re.sub(r'[^ㄱ-ㅎ가-힣\s]+', '', x))
X = X.apply(lambda x : okt.morphs(x, stem=True))
vocab = {word:i for i, word in   \
          enumerate(['<PAD>','<UNK>' ] +  list(set( [word for words in X.to_list() \
                                              for word in words]))
          )}
X = X.apply(lambda x: wordtoindex(x,vocab))
max_len = 10
X = X.apply(lambda x : pad_sequences([x],maxlen=max_len, padding='post',truncating='post').tolist()[0])

# 데이터셋
class NaverMovieDataset(Dataset):
  def __init__(self, X, y):
    self.X = X
    self.y = y
  def __len__(self):
    return len(self.X)
  def __getitem__(self, index):
    return torch.LongTensor( self.X[index]).clone().detach().squeeze(), \
    torch.LongTensor( [self.y[index]]).clone().detach().squeeze()

dataset = NaverMovieDataset(X, train_df['label'].to_list() )
dataloader = DataLoader(dataset,batch_size=32,shuffle=True)

# LSTM 모델 정의
class NaverLSTMModel(nn.Module):
  def __init__(self,vocab_size, ebedding_dim, hidden_size,output_size):
    super().__init__()
    self.embedding = nn.Embedding(vocab_size, ebedding_dim)
    self.lstm = nn.LSTM(ebedding_dim, hidden_size, batch_first=True)
    self.fc = nn.Linear(hidden_size,output_size)
  def forward(self,x):
    embedding = self.embedding(x) # batch, max_len, ebedding_dim
    out, (hn,cn) = self.lstm(embedding) # batch, max_len, hidden_state
    out = self.fc(out[:,-1,:])  # 마지막 타임 스탬프
    return out
embedding_dim = 100
hidden_size = 128
output_size = 2 # 긍정부정
vocab_size = len(vocab)
model = NaverLSTMModel(vocab_size,embedding_dim,hidden_size,output_size)

# 학습설정  손실함수, 옵티마이져, 학습루프
from tqdm import tqdm
device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = model.to(device)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
train_loss = []
for epoch in range(10):
  epoch_loss = 0
  iterator = tqdm(dataloader)
  for sequence, labels in iterator:
    sequence, labels = sequence.to(device), labels.to(device)
    outputs = model(sequence)
    loss = loss_fn(outputs, labels)
    loss.backward()
    optimizer.step()
    optimizer.zero_grad()
    epoch_loss += loss.item()
    iterator.set_description(f'epoch : {epoch+1}, loss : {epoch_loss:.4f}')
  train_loss.append(epoch_loss / len(dataloader) )
  print(f'epoch : {epoch+1}, epoch loss : {epoch_loss / len(dataloader):.4f}' )

# 평가
test_df = pd.read_csv('ratings_test.txt', sep='\t')[:10000]
test_df.dropna(inplace=True)
test_df.reset_index(drop=True, inplace=True)
# 테스트셋 전처리 (vocab은 학습 시 사용한 vocab 사용!)
X = test_df['document'].apply(lambda x: re.sub(r'[^ㄱ-ㅎ가-힣\s]+', '', x))
X = X.apply(lambda x : okt.morphs(x, stem=True))
X = X.apply(lambda x : wordtoindex(x, vocab))
X = X.apply(lambda x : pad_sequences([x], maxlen=max_len, padding='post', truncating='post').tolist()[0]) 

# 텐서 데이터로 변환
dataset = NaverMovieDataset(X, test_df['label'].to_list())
dataloader = DataLoader(dataset, batch_size=32, shuffle=False)

model.eval()
with torch.no_grad():
  correct = 0
  total = 0
  for sequence, labels in dataloader:
    sequence, labels = sequence.to(device), labels.to(device)
    outputs = model(sequence)
    correct += (torch.argmax(outputs,dim=1) == labels).sum().item()

correct / len(dataloader.dataset) 

1. 라이브러리 설치 및 임포트

!pip install konlpy -q
!wget http://skt-lsl-nlp-model.s3.amazonaws.com/KoBERT/datasets/nsmc/ratings_train.txt
!wget http://skt-lsl-nlp-model.s3.amazonaws.com/KoBERT/datasets/nsmc/ratings_test.txt
konlpy: 한국어 형태소 분석기 (이 코드에서는 Okt 사용)
  • wget: NSMC 데이터셋을 다운로드하는 데 사용됨 (네이버 영화 리뷰 감성 분류용)
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
import pandas as pd
import numpy as np
import re
from konlpy.tag import Okt
from tensorflow.keras.preprocessing.sequence import pad_sequences

2. 학습 데이터 로드 및 전처리

train_df = pd.read_csv('ratings_train.txt', sep='\t')[:10000]
train_df.dropna(inplace=True)
train_df.reset_index(drop=True, inplace=True)
  • 학습 데이터셋을 불러오고, 결측치를 제거함.
  • 10,000개 샘플만 사용하여 간단한 실습 형태로 구성.

전처리 작업

okt = Okt()
X = train_df['document'].apply(lambda x: re.sub(r'[^ㄱ-ㅎ가-힣\s]+', '', x))
X = X.apply(lambda x : okt.morphs(x, stem=True))
  • 특수문자 제거 후, 형태소 단위로 분해 (e.g. "재밌다" → "재밌", "다")
  • 형태소 분석은 한국어의 문법 구조를 분석하기 위해 꼭 필요함.

단어 인덱싱

vocab = {word:i for i, word in enumerate(['<PAD>', '<UNK>'] + list(set(word for words in X for word in words)))}
def wordtoindex(wordlists, vocab):
    return [vocab.get(word, vocab['<UNK>']) for word in wordlists]
X = X.apply(lambda x: wordtoindex(x, vocab))
  • 단어 사전을 만들어 각 단어를 고유한 정수로 변환.
  • 사전에 없는 단어는 (Unknown)으로 처리.

패딩 처리

max_len = 10
X = X.apply(lambda x : pad_sequences([x], maxlen=max_len, padding='post', truncating='post').tolist()[0])
  • 각 문장의 길이를 동일하게 맞추기 위해 패딩 적용 (문장 길이 10 기준)
  • LSTM은 고정된 길이의 입력만 받으므로 필수 과정.

3. PyTorch Dataset 정의

class NaverMovieDataset(Dataset):
    def __init__(self, X, y):
        self.X = X
        self.y = y
    def __len__(self):
        return len(self.X)
    def __getitem__(self, index):
        return torch.LongTensor(self.X[index]), torch.LongTensor([self.y[index]]).squeeze()
  • PyTorch에서 데이터를 쉽게 불러오도록 custom Dataset 클래스를 정의.
  • 텍스트와 라벨을 텐서 형태로 변환.
dataset = NaverMovieDataset(X, train_df['label'].to_list())
dataloader = DataLoader(dataset, batch_size=32, shuffle=True)
  • DataLoader는 미니배치 단위로 데이터를 모델에 전달함.

4. LSTM 모델 정의

class NaverLSTMModel(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_size, output_size):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.lstm = nn.LSTM(embedding_dim, hidden_size, batch_first=True)
        self.fc = nn.Linear(hidden_size, output_size)

    def forward(self, x):
        embedding = self.embedding(x)
        out, _ = self.lstm(embedding)
        out = self.fc(out[:, -1, :])  # 마지막 시점의 hidden state만 사용
        return out
  • 임베딩 레이어: 정수 인덱스를 실수 벡터로 변환 (단어 의미 표현)
  • LSTM 레이어: 순서를 가진 시퀀스 데이터를 처리 (시간의 흐름 반영)
  • FC 레이어: 마지막 hidden state를 이용해 최종 출력 (긍정/부정 예측)
embedding_dim = 100
hidden_size = 128
output_size = 2
vocab_size = len(vocab)
model = NaverLSTMModel(vocab_size, embedding_dim, hidden_size, output_size)

5. 학습 루프

device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = model.to(device)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
train_loss = []
for epoch in range(10):
    epoch_loss = 0
    iterator = tqdm(dataloader)
    for sequence, labels in iterator:
        sequence, labels = sequence.to(device), labels.to(device)
        outputs = model(sequence)
        loss = loss_fn(outputs, labels)
        loss.backward()
        optimizer.step()
        optimizer.zero_grad()
        epoch_loss += loss.item()
        iterator.set_description(f'epoch : {epoch+1}, loss : {epoch_loss:.4f}')
    train_loss.append(epoch_loss / len(dataloader))
  • 손실 함수는 CrossEntropy (이진 분류용)
  • 에폭마다 평균 손실을 저장
  • tqdm으로 진행 상황 시각화

6. 평가 (Test Set)

test_df = pd.read_csv('ratings_test.txt', sep='\t')[:10000]
test_df.dropna(inplace=True)
test_df.reset_index(drop=True, inplace=True)

테스트 데이터 로딩

X = test_df['document'].apply(lambda x: re.sub(r'[^ㄱ-ㅎ가-힣\s]+', '', x))
X = X.apply(lambda x : okt.morphs(x, stem=True))
X = X.apply(lambda x : wordtoindex(x, vocab))
X = X.apply(lambda x : pad_sequences([x], maxlen=max_len, padding='post', truncating='post').tolist()[0])

학습과 동일한 방식으로 전처리 (vocab은 새로 만들지 않고 그대로 사용!)

dataset = NaverMovieDataset(X, test_df['label'].to_list())
dataloader = DataLoader(dataset, batch_size=32, shuffle=False)
model.eval()
with torch.no_grad():
    correct = 0
    for sequence, labels in dataloader:
        sequence, labels = sequence.to(device), labels.to(device)
        outputs = model(sequence)
        correct += (torch.argmax(outputs, dim=1) == labels).sum().item()

accuracy = correct / len(dataloader.dataset)
  • 학습된 모델을 이용해 정확도 평가 진행 (전체 샘플 중 맞춘 비율 계산)

전체 요약

단계 설명
데이터 로딩 네이버 영화 리뷰 긍정/부정 데이터셋
전처리 형태소 분석, 정수 인코딩, 패딩
데이터 준비 PyTorch Dataset, DataLoader 구성
모델 정의 임베딩 + LSTM + Linear (이진 분류)
학습 루프 손실 계산, 역전파, 옵티마이저 적용
평가 테스트셋 정확도 계산

성능 개선

Bidirectional 구조
  양방향 LSTM( BiLSTM ): 시퀀스를 순방향과 역방향 동시에
Overfitting: Dropout
  정해진 확률로 임의의 뉴런을 무효화( 가중치를 0 )
Batch Normalization
  배치정규화: 각 레이어의 출력 평균 0 분산 1로 정규화
  학습을 안정적, 속도 증가

1. Bidirectional LSTM (양방향 LSTM)

✔ 개념

일반 LSTM은 시퀀스를 앞에서 뒤로 한 번만 처리함.

BiLSTM은 앞에서 뒤로 + 뒤에서 앞으로 두 번 처리하여 더 풍부한 문맥 정보를 학습함.

왜 좋은가?

특히 자연어처럼 앞뒤 문맥이 중요한 경우에 효과적.

예: "이 영화는 정말 재미없다." → "재미"를 보고 긍정으로 판단할 수 있지만, "없다"까지 봐야 부정임을 정확히 알 수 있음.

✔ 코드 적용 예

self.lstm = nn.LSTM(embedding_dim, hidden_size, batch_first=True, bidirectional=True)
self.fc = nn.Linear(hidden_size * 2, output_size)  # 두 방향을 합치므로 x2

2. Dropout

✔ 개념

학습 중 일부 뉴런을 무작위로 꺼서 (출력을 0으로 만들어) 과적합 방지.

학습 시에만 적용되고, 테스트 시에는 전체 뉴런 사용.

✔ 왜 좋은가?

특정 뉴런에만 의존하는 걸 방지해 일반화 능력 향상.

특히 작은 데이터셋이나 복잡한 모델에서 효과적.

✔ 코드 적용 예

self.dropout = nn.Dropout(p=0.5)
...
out = self.dropout(out)
  • 보통 LSTM의 출력이나 FC layer 앞에 붙여줌.

3. Batch Normalization (배치 정규화)

✔ 개념

각 미니배치의 출력값을 평균 0, 분산 1로 정규화.

학습을 빠르고 안정적으로 함 (그래디언트 폭발/소실 방지).

✔ 왜 좋은가?

학습 속도 향상 (더 큰 학습률 가능)

드랍아웃처럼 약간의 정규화 효과도 있음.

✔ 코드 적용 예

self.batchnorm = nn.BatchNorm1d(hidden_size * 2)
...
out = self.batchnorm(out)
  • LSTM은 시퀀스 데이터라 BatchNorm1d를 시퀀스 축을 평균 내거나 마지막 hidden state에 적용하는 식으로 사용.

🔧 통합 적용 예시

LSTM 블록을 아래처럼 개선할 수 있습니다.

요약 정리

기법 목적 적용 위치
BiLSTM 문맥 정보 강화 LSTM 레이어 정의 시 bidirectional=True, FC input 크기 x2
Dropout 과적합 방지 LSTM 출력 후나 FC 전
BatchNorm 학습 안정화 및 속도 향상 FC 입력 직전 혹은 LSTM 마지막 출력에 적용
def wordtoindex(wordlists,vocab):
  return [vocab.get(word,1) for word in wordlists]

def getDataLoader(filepath,vocab=None):
  df = pd.read_csv(filepath, sep='\t')[:10000]
  df.dropna(inplace=True)
  df.reset_index(drop=True, inplace=True)
  okt = Okt()
  #  한글과 공백만 추출
  X = df['document'].apply(lambda x: re.sub(r'[^ㄱ-ㅎ가-힣\s]+', '', x))
  X = X.apply(lambda x : okt.morphs(x, stem=True))
  if vocab == None:
    vocab = {word:i for i, word in   \
              enumerate(['<PAD>','<UNK>' ] +  list(set( [word for words in X.to_list() \
                                                  for word in words]))
              )}
  X = X.apply(lambda x: wordtoindex(x,vocab))
  max_len = 10
  X = X.apply(lambda x : pad_sequences([x],maxlen=max_len, padding='post',truncating='post'))
  dataset = NaverMovieDataset(X, df['label'].to_list() )
  return DataLoader(dataset,batch_size=32,shuffle=True), vocab

train_loader,vocab = getDataLoader('ratings_train.txt')
test_loader, _ =  getDataLoader('ratings_test.txt', vocab=vocab)

# LSTM 모델 정의
class NaverBiLSTMModel(nn.Module):
  def __init__(self,vocab_size, ebedding_dim, hidden_size,output_size):
    super().__init__()
    self.embedding = nn.Embedding(vocab_size, ebedding_dim)
    self.lstm = nn.LSTM(ebedding_dim, hidden_size, batch_first=True, bidirectional=True)
    self.fc = nn.Linear(hidden_size*2,output_size)
    self.dropout = nn.Dropout(0.3)
    self.batch_norm = nn.BatchNorm1d(hidden_size*2)  # BiLSTM은 hidden_size*2출력
  def forward(self,x):
    embedding = self.embedding(x) # batch, max_len, ebedding_dim
    out, (hn,cn) = self.lstm(embedding) # batch, max_len, hidden_state
    out = self.batch_norm(out[:,-1,:]  )# 마지막 타임 스탬프
    out = self.dropout(out)
    out = self.fc(out)
    return out

embedding_dim = 100
hidden_size = 128
output_size = 2 # 긍정부정
vocab_size = len(vocab)+1
print(vocab_size)
model = NaverBiLSTMModel(vocab_size,embedding_dim,hidden_size,output_size)
# 학습
# 학습설정  손실함수, 옵티마이져, 학습루프
from tqdm import tqdm
device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = model.to(device)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
train_loss = []
for epoch in range(10):
  epoch_loss = 0
  iterator = tqdm(train_loader)
  for sequence, labels in iterator:
    sequence, labels = sequence.to(device), labels.to(device)
    outputs = model(sequence)
    loss = loss_fn(outputs, labels)
    loss.backward()
    optimizer.step()
    optimizer.zero_grad()
    epoch_loss += loss.item()
    iterator.set_description(f'epoch : {epoch+1}, loss : {epoch_loss:.4f}')
  train_loss.append(epoch_loss / len(train_loader) )
  print(f'epoch : {epoch+1}, epoch loss : {epoch_loss / len(train_loader):.4f}' )

  # 평가용 데이터 로드
model.eval()
with torch.no_grad():
  correct = 0
  total = 0
  for sequence, labels in test_loader:
    sequence, labels = sequence.to(device), labels.to(device)
    outputs = model(sequence)
    correct += (torch.argmax(outputs,dim=1) == labels).sum().item()

correct / len(test_loader.dataset)

1. wordtoindex 함수

def wordtoindex(wordlists, vocab):
    return [vocab.get(word, 1) for word in wordlists]
  • 각 단어 리스트를 정수 인덱스 리스트로 변환해줘.
  • vocab.get(word, 1) → 사전에 없는 단어는 토큰의 인덱스 1로 처리함.
    • 왜?: 딥러닝 모델은 텍스트 자체가 아니라 숫자를 입력받아야 하므로 모든 단어를 숫자로 바꿔야 함.

2. getDataLoader 함수

def getDataLoader(filepath, vocab=None):
    ...
    X = df['document'].apply(lambda x: re.sub(r'[^ㄱ-ㅎ가-힣\s]+', '', x))
    X = X.apply(lambda x: okt.morphs(x, stem=True))
  • 데이터셋을 로드하고, 텍스트 전처리 진행.
  • [^ㄱ-ㅎ가-힣\s]+ → 한글과 공백만 남기고 제거.
  • okt.morphs(x, stem=True) → 형태소 단위로 분리 + 원형 복원 (예: "좋았어요" → "좋다")
if vocab is None:  
vocab = {word: i for i, word in enumerate(['<PAD>','<UNK>'] + list(set(...)))}  
  • 처음 실행 시 단어 사전(vocab)을 생성
  • : 패딩용(0), : 모르는 단어(1)
X = X.apply(lambda x: pad\_sequences(\[x\], maxlen=10, ...))  
dataset = NaverMovieDataset(X, df\['label'\].to\_list())  
  • 문장을 최대 길이 10으로 잘라내고(또는 0으로 채움)
  • 최종적으로 PyTorch Dataset 객체로 만듦
  • DataLoader는 이 Dataset에서 배치 단위로 데이터를 꺼내는 역할

3. NaverBiLSTMModel 클래스

class NaverBiLSTMModel(nn.Module):  
  • PyTorch 모델 클래스. BiLSTM 구조와 정규화, 드롭아웃이 포함되어 있어.

주요 구성 요소:

구성 요소 설명
nn.Embedding 단어 인덱스를 밀집된 벡터로 변환
nn.LSTM(..., bidirectional=True) 양방향 LSTM
nn.Linear(hidden*2, output_size) 2개의 방향 결과를 모두 사용
Dropout(0.3) 30% 확률로 뉴런 제거 (과적합 방지)
BatchNorm1d 출력 정규화로 학습 안정화
out, (hn, cn) = self.lstm(embedding)
out = self.batch_norm(out[:, -1, :])
out = self.dropout(out)
out = self.fc(out)
  • out[:, -1, :]: 마지막 타임스탬프의 출력
  • BatchNorm → Dropout → FC 순으로 통과시킴

4. 학습 루프

device = 'cuda' if torch.cuda.is\_available() else 'cpu'  
...  
for epoch in range(10):  
for sequence, labels in train\_loader:  
...  
outputs = model(sequence)  
loss = loss\_fn(outputs, labels)  
...  
  • GPU가 있으면 cuda, 없으면 CPU로 학습
  • CrossEntropyLoss: 분류 문제에서 자주 쓰이는 손실함수
  • tqdm: 진행률 보여주는 바

각 에폭마다 평균 손실을 출력하며 학습 진행

5. 평가 루프

model.eval()  
with torch.no\_grad():  
for sequence, labels in test\_loader:  
...  
correct += (torch.argmax(outputs, dim=1) == labels).sum().item()  
  • eval()은 드롭아웃/배치정규화를 평가 모드로 전환
  • torch.no_grad(): 평가 시엔 그래디언트 계산 생략 (속도↑, 메모리↓)
  • 정답 개수를 세서 정확도 계산

최종 출력

correct / len(test\_loader.dataset)  
  • 전체 데이터 중 정답 맞춘 비율 → 정확도

요약

  • Tokenizer / Okt 문장을 단어 단위로 자르는 도구
  • Padding 문장 길이를 맞추기 위해 0으로 채움
  • Embedding 단어를 숫자로 표현한 벡터
  • LSTM / BiLSTM 시퀀스를 처리하는 RNN 계열 모델
  • Dropout 일부 뉴런 끄기 → 과적합 방지
  • BatchNorm 출력값 정규화 → 학습 안정화
  • FC (Fully Connected) 마지막 분류기 역할
  • Softmax 출력값을 확률처럼 해주는 함수 (암묵적으로 CrossEntropyLoss에 포함됨)
반응형