본문 바로가기

AI/NLP

[11/6~11/11] 아이펠 리서치 15기 TIL | DLthon: 감정 분류 해커톤 (1)

반응형

아이펠 과정에서 진행하는 세 번의 해커톤 중 하나인 DLthon을 11월 6일부터 11일까지 총 6일 동안 진행했다.

나를 포함한 네 명의 팀을 구성해서 캐글 competition으로 참가했는데, 주제는 아래와 같다.


Overview

대화의 성격을 위협 세부 클래스 4개 또는 일반 대화 중 하나로 예측하는 과제

  • 학습 데이터는 '협박', '갈취', '직장 내 괴롭힘', '기타 괴롭힘' 등 4개 클래스 각 1,000개 내외로 구성
  • 테스트 데이터는 '협박', '갈취', '직장 내 괴롭힘', '기타 괴롭힘', '일반 대화' 등 5개 클래스 각 100개로 구성
    • train data에는 없지만, test data는 일반 대화 클래스가 존재합니다. 5개 문장을 분류할 수 있게 train data에 일반 대화 데이터셋을 추가합니다.
    • 4개의 위협 세부 클래스는 Augmentation만 가능(새로운 데이터 추가/생성 불가)
    • 일반대화 클래스 합성데이터로 구성
      • 다양한 프롬프트로 문장을 생성하고 학습에 활용
      • 최종 결과물로 제출할 수 있는 건 합성데이터 기반의 성능뿐입니다.
        • 합성데이터 생성 및 활용(필수)
        • 기 확보된 데이터 활용(AI hub 등 활용, 추가실험)
    • 학습 결과를 확인하며 성능을 높이는데 영향을 미치는 요소는 무엇이 있는지, 어떻게 수정해야하는지 고민합니다.
      • 위 기준에서 벗어나지 않는 범위 내에서 데이터셋의 구성은 자유입니다. 성능을 비교/기록해보세요 :)
        • 실험 결과를 Ablation study형식으로 기록합니다
# 일반대화 예시
{
    "id": {
        "text": "이거 들어봐 와 이 노래 진짜 좋다 그치 요즘 이 것만 들어 진짜 너무 좋다 내가 요즘 듣는 것도 들어봐 음 난 좀 별론데 좋을 줄 알았는데 아쉽네 내 취향은 아닌 듯 배고프다 밥이나 먹으러 가자 그래"
    }
}

 


 

아래 링크는 이번 프로젝트에서 사용했던 Github 레포지토리이다.

https://github.com/choiwonjini/DLthon_pepero_day.git

 

GitHub - choiwonjini/DLthon_pepero_day: This is a repository for Aiffel DLthon

This is a repository for Aiffel DLthon. Contribute to choiwonjini/DLthon_pepero_day development by creating an account on GitHub.

github.com

 

이 해커톤을 끝내고 느낀 점부터 말하자면,

 

데이터:

  1. GIGO (Garbage In Garbage Out)를 절실히 느꼈다.
  2. LLM이 생성하는 데이터는 한계가 명확하다.
  3. EDA는 필수다.

협업:

  1. 협업을 위한 Github 관리 능력의 중요성을 깨달았다.
  2. soft skill도 hard skill 만큼 중요하다.

그 이유들은 아래에서 하나하나 적어보겠다.

 


팀 plan

  1. 데이터는
    1. 위협 대화 데이터는 역번역 진행하고
    2. 일반 대화 데이터는 Gemini CLI를 이용해 생성하자
  2. 전처리, 토큰화 코드는 통일해서 Github에 올려두고 각자 사용하고,
  3. 네 명의 팀원이 서로 다른 모델을 만들고,
  4. Ablation study를 통해 loss가 가장 낮은 모델을 선별해서 제출하자.

사용한 깃헙 구조도는 아래와 같다.

DLthon_pepero_day/
├── configs/
├── Data/
│   └── aiffel-dl-thon-dktc-online-15
│       └── ~.csv
├── Images/
├── models/
│   ├── 1D_CNN.ipynb
│   ├── bi_GRU.ipynb
│   ├── boemikbert_base.ipynb
│   └── decoder_only.ipynb
├── dataset.py
├── preprocessing.py
├── README.md
├── tokenization.py
├── eda.py
└── utils.py

 

  • folders
    • Data : Kaggle에서 다운받은 원본 데이터셋
    • configs : 모델 설정, 데이터 경로 등 프로젝트의 주요 설정 값들을 저장하는 파일을 담는 디렉토리
    • Images : 결과 리포트나 발표 자료에 사용될 이미지 파일을 담는 디렉토리
    • modles : 다양한 모델 아키텍처 실험 및 관리를 위한 디렉토리
  • files
    • dataset.py : 데이터셋을 불러오고, 모델 입력으로 사용할 수 있는 형태로 변환
    • preprocessing.py : 원본 데이터에 대한 전처리
    • tokenization.py : 텍스트 데이터에 대한 토큰화 진행
    • eda.py : 데이터셋의 분포 확인
    • utils.py : 여러 파일에서 공통적으로 사용되는 유용한 함수를 모아놓은 모듈
 

1. preprocessing.py

전처리는 간단히 세 가지만 수행했다.

  1. 자모/한글, 영어, 숫자, 공백, 구두점, 'ㅠ', 'ㅜ' 이외의 문자는 모두 제거
  2. 연속 공백 or 구두점 -> 하나로 줄이기
  3. 앞뒤 공백 제거
import pandas as pd
import re

def preprocess_sentence(sentence):
    """
    간단한 전처리 함수
    """
    if pd.isna(sentence) or sentence is None:
        return ""
    
    sentence = str(sentence)
    sentence = re.sub(r'[^ㄱ-ㅎㅏ-ㅣ가-힣a-zA-Z0-9\s.,!?~ㅠㅜ]', ' ', sentence)
    sentence = re.sub(r'\s+', ' ', sentence)
    sentence = sentence.strip()
    sentence = re.sub(r'([!?.])\1+', r'\1', sentence)

    return sentence


def load_and_preprocess_data(train_path, test_path):
    """
    .csv 파일에서 데이터 로드하고 간단한 전처리를 진행하는 함수
    """
    print("=" * 50)
    print("데이터 로드 및 전처리 중...")
    print("=" * 50)

    # pandas로 CSV 파일 읽기
    train_df = pd.read_csv(train_path)
    test_df = pd.read_csv(test_path)
    print(f"Train 데이터: {len(train_df)} 개의 conversation")
    print(f"Test 데이터: {len(test_df)} 개의 conversation")

    # 레이블 매핑
    class_to_idx = {
        '협박 대화': 0,
        '갈취 대화': 1,
        '직장 내 괴롭힘 대화': 2,
        '기타 괴롭힘 대화': 3,
        '일반 대화': 4
    }

    # Train 데이터 전처리
    train_conversations = []
    train_labels = []

    for i, row in train_df.iterrows():
        conv = preprocess_sentence(row['conversation'])
        label = class_to_idx[row['class']]

        if conv:
            train_conversations.append(conv)
            train_labels.append(label)

    # Test 데이터 전처리
    test_conversations = []
    test_ids = []

    for i, row in test_df.iterrows():
        conv = preprocess_sentence(row['conversation'])
        test_id = row['idx']

        if conv:
            test_conversations.append(conv)
            test_ids.append(test_id)

    # 샘플 데이터 출력
    print("\n샘플 데이터:")
    for i in range(min(3, len(train_conversations))):
        print(f"Conversation: {train_conversations[i]}")
        print(f"Label: {train_labels[i]}\n")

    return train_conversations, train_labels, test_conversations, test_ids, class_to_idx

 


2. tokenization.py

토큰화는 SentencePiece를 사용했다. (SentencePiece는 이전 포스팅에서 다루었으므로 자세한 설명은 생략)

import sentencepiece as spm

def train_sentencepiece_model(conversations, 
                              model_prefix='./configs/spm_dktc', 
                              all_sentences_path='./configs/sentences.txt', 
                              vocab_size=1300):
    """
    주어진 conversations를 통해 SentencePiece 모델 학습
    
    Args:
        conversations
        model_prefix
        vocab_size: 개발자 지정 vocab의 크기. default=1200
    Return:
        model_file (str): 학습된 SentencePiece 모델 파일의 path
    """
    print("=" * 50)
    print("SentencePiece 모델 학습 중...")
    print("=" * 50)

    # 모든 문장을 하나의 텍스트 파일로 저장
    with open(all_sentences_path, 'w', encoding='utf-8') as f:
        f.write('\n'.join(conversations))

    # SentencePiece 학습 명령어 설정
    # [CLS] 토큰 추가 : 분류를 위한 시작 토큰 
    # (user_defined_symbols 명령어로 특수 토큰을 설정하면 자동으로 기본 특수 토큰 다음에 ID를 할당함)
    # --minloglevel=1: INFO 로그를 제외하고 WARNING, ERROR만 출력
    cmd = f'--input={all_sentences_path} \
           --model_prefix={model_prefix} \
           --vocab_size={vocab_size} \
           --model_type=unigram \
           --max_sentence_length=999999 \
           --pad_id=0 \
           --unk_id=1 \
           --bos_id=2 \
           --eos_id=3 \
           --user_defined_symbols=[CLS] \
           --minloglevel=1'

    # SentencePiece 모델 학습 실행
    spm.SentencePieceTrainer.Train(cmd)

    # 학습된 모델 파일 경로 생성
    model_file = f"{model_prefix}.model"
    print(f"\n모델 저장됨: {model_file}")
    print(f"Vocab 크기: {vocab_size}")
    return model_file


class SentencePieceVocab:
    """
    SentencePiece 모델을 쉽게 사용하기 위한 wrapper 클래스
    텍스트를 토큰 ID로 encoding하거나 토큰 ID를 다시 텍스트로 decoding하는 기능 제공
    """
    def __init__(self, sp_model_path):
        """
        Args:
            sp_model_path: 학습된 SentencePiece 모델 파일의 path
        """
        # SentencePiece 프로세서 초기화
        self.sp = spm.SentencePieceProcessor()
        # 학습된 모델 로드
        self.sp.Load(sp_model_path)

        # 특수 토큰 ID 정의
        self.PAD_ID = 0  # 패딩
        self.UNK_ID = 1  # 미등록 단어
        self.BOS_ID = 2  # 문장 시작 (BOS)
        self.EOS_ID = 3  # 문장 끝 (EOS)
        self.CLS_ID = 4  # Classification 토큰

        # 토큰 문자열 -> ID 매핑
        # <s>, </s> : 각각 BOS, EOS를 의미
        self.stoi = {'<pad>': 0, '<unk>': 1, '<s>': 2, '</s>': 3, '[CLS]': 4}

        # ID -> 토큰 문자열 매핑 (전체 어휘)
        self.itos = [self.sp.IdToPiece(i) for i in range(self.sp.GetPieceSize())]

    def encode(self, sentence):
        """
        문장을 토큰 ID 리스트로 인코딩
        
        Args:
            sentence: 인코딩할 문자열
        """
        return self.sp.EncodeAsIds(sentence)

    def decode(self, ids):
        """
        토큰 ID 리스트를 문장으로 디코딩(특수 토큰은 제외)

        Args:
            ids: 인코딩된 토큰 ID list
        """
        return self.sp.DecodeIds([i for i in ids if i not in [0, 2, 3, 4]])

    def __len__(self):
        """어휘 사전 크기 반환"""
        return self.sp.GetPieceSize()

3. Dataset.py

데이터셋을 불러오고, 위의 두 파일을 import해서 전처리, 토큰화를 진행한 다음 모델이 학습할 수 있게 Dataloader에 담는 과정을 담고 있다.

from preprocessing import load_and_preprocess_data
from tokenization import train_sentencepiece_model
from tokenization import SentencePieceVocab

import torch
from torch.utils.data import Dataset
from torch.utils.data import DataLoader, random_split, random_split

class DKTCDataset(Dataset):
    """
    Pytorch Dataset class를 상속받아, conversations, label을 모델 학습에 적합한 텐서 형태로 변환하는 클래스
    """

    def __init__(self, conversations, labels, vocab, max_length=400, is_test=False):
        """
        Args:
            conversations
            labels
            vocab: SentencePieceVocab 객체
            max_length: 시퀀스의 최대 길이(tokenization 이후의 길이). default=400
            is_test: 테스트 데이터셋 여부. default=False
        """
        self.vocab = vocab            # SentencePiece vocab 객체
        self.max_length = max_length  # 최대 시퀀스 길이 (잘림 방지)
        self.is_test = is_test        # 테스트 데이터 여부
        self.sequences = []
        self.labels = labels if not is_test else None

        # conversation -> sequence
        for conv in conversations:
            # [CLS] + conversation tokens + [EOS]
            sequence = [self.vocab.CLS_ID] \
                        + self.vocab.encode(conv) \
                        + [self.vocab.EOS_ID]

            # sequence의 길이 조절
            if len(sequence) > max_length:
                sequence = sequence[:max_length]
            else:
                pad_length = max_length - len(sequence)
                sequence = sequence + [self.vocab.PAD_ID] * pad_length

            self.sequences.append(sequence)

    def __len__(self):
        """데이터셋에 포함된 총 샘플의 개수 반환"""
        return len(self.sequences)

    def __getitem__(self, idx):
        """
        GPT-1 방식: Next-token prediction을 위한 shifted sequences
        + classification label도 함께 반환
        
        Returns:
            dict: {
                'input_ids': 모델 입력으로 사용될 텐서 (마지막 토큰 제외),
                'target_ids': 예측 대상이 되는 텐서 (첫 토큰 제외),
                'labels': 분류 레이블 (test data인 경우 제외)
            }
        """
        sequence = self.sequences[idx]
        tokens = torch.tensor(sequence, dtype=torch.long)
        input_ids = tokens[:-1]   # 마지막 토큰 제외
        target_ids = tokens[1:]   # 첫 토큰 제외
        
        result = {
            'input_ids': input_ids,
            'target_ids': target_ids
        }
        
        # Test가 아닌 경우 레이블 추가
        if not self.is_test:
            result["labels"] = torch.tensor(self.labels[idx], dtype=torch.long)

        return result

def collate_fn(batch, pad_idx=0):
    """
    DataLoader의 배치 생성 함수
    """
    input_batch = [item['input_ids'] for item in batch]
    target_batch = [item['target_ids'] for item in batch]

    result = {
        'input_ids': torch.stack(input_batch),
        'target_ids': torch.stack(target_batch)
    }

    # labels가 있는 경우(즉 Test가 아닌 경우) result에 labels 추가
    if 'labels' in batch[0]:
        label_batch = [item['labels'] for item in batch]
        result['labels'] = torch.stack(label_batch)

    return result

def create_dataloaders(train_path, test_path, vocab_size=1320, max_length=400, batch_size=64, validation_split=0.1):
    """
    데이터를 로드, 전처리, 토큰화하고 PyTorch train/validation/test DataLoader를 생성하는 메인 함수.
    """
    # 1. 데이터 로드 및 전처리
    train_conversations, \
    train_labels, \
    test_conversations, \
    test_ids, \
    class_to_idx = load_and_preprocess_data(train_path, test_path)

    # 2. SentencePiece 토크나이저 모델 학습
    model_prefix = './configs/sentences'
    sp_model_path = train_sentencepiece_model(
        train_conversations, model_prefix=model_prefix, vocab_size=vocab_size
    )

    # 3. SentencePiece Vocab 로드
    vocab = SentencePieceVocab(sp_model_path)

    # 4. 전체 학습 데이터셋 생성
    full_train_dataset = DKTCDataset(
        train_conversations,
        train_labels,
        vocab,
        max_length=max_length,
        is_test=False
    )

    # 5. Train / Validation 데이터셋으로 분리
    num_train = len(full_train_dataset)
    val_size = int(num_train * validation_split)
    train_size = num_train - val_size
    
    train_dataset, val_dataset = random_split(full_train_dataset, [train_size, val_size])

    # 6. 테스트 데이터셋 생성
    test_dataset = DKTCDataset(
        test_conversations,
        labels=None,  # 테스트 데이터에는 레이블이 없습니다.
        vocab=vocab,
        max_length=max_length,
        is_test=True
    )

    # 7. DataLoader 생성
    train_loader = DataLoader(
        train_dataset,
        batch_size=batch_size,
        shuffle=True,
        collate_fn=lambda batch: collate_fn(batch, pad_idx=vocab.PAD_ID),
    )

    val_loader = DataLoader(
        val_dataset,
        batch_size=batch_size,
        shuffle=False,
        collate_fn=lambda batch: collate_fn(batch, pad_idx=vocab.PAD_ID),
    )

    test_loader = DataLoader(
        test_dataset,
        batch_size=batch_size,
        shuffle=False,  # 테스트 데이터는 셔플 X
        collate_fn=lambda batch: collate_fn(batch, pad_idx=vocab.PAD_ID),
    )

    print(f"\nTrain DataLoader 준비 완료: 총 {len(train_dataset)}개 conversations")
    print(f"Validation DataLoader 준비 완료: 총 {len(val_dataset)}개 conversations")
    print(f"Test DataLoader 준비 완료: 총 {len(test_dataset)}개 conversations.")

    return train_loader, val_loader, test_loader, vocab

4. 1D CNN

필자는 예측 모델로 1D CNN을 선택했는데, 그 이유는 아래와 같다.

  • 1D CNN은 n-gram 방법을 통해 문장 내의 핵심 키워드를 잘 잡아낼 수 있는 특징이 있다.
  • 그래서 "돈 내놔", "죽어" 등 명확한 키워드가 존재하는 '협박', '갈취' 등 자극적이고 폭력적인 클래스를 분류하는 데 적합하다고 판단.

1D CNN의 장단점은 아래와 같다.

 

장점

  • n-gram으로 "돈 내놔", "죽어" 등 짧고 폭력성이  있어 보이는 위협 키워드를 잘 잡아내고, 
  • 다른 모델에 비해 학습 속도가 상대적으로 빠르다.

단점

  • Max Pooling 때문에 문장의 순서 정보가 없어지고, 위협 단어가 존재한다는 사실만 남기므로 위협 대화와 “공격적인 단어가 들어가있는 일반 대화”를 구분하기 어렵다.

아래는 모델 구현 코드이다.

class CNNClassifier(nn.Module):
    """
    1D CNN 기반 텍스트 분류 모델
    """
    def __init__(self,
                 vocab_size,      # 어휘 사전의 크기 (vocab 객체로부터 받음)
                 embed_dim,       # 임베딩 벡터의 차원
                 num_classes,     # 분류할 클래스의 개수 (5)
                 num_filters,     # 각 필터 크기별 컨볼루션 필터의 수
                 filter_sizes,    # 사용할 컨볼루션 필터의 크기
                 dropout_prob    # 드롭아웃 확률
                ):

        super(CNNClassifier, self).__init__()

        # 1. 임베딩 레이어
        # padding_idx=0: <PAD> 토큰은 0 벡터로 임베딩하고 학습하지 않음
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)

        # 2. 1D Convolution 레이어들 (다른 커널 크기를 사용)
        # filter_sizes 개수만큼의 Conv1d 레이어를 ModuleList로 생성
        # Conv1d는 (batch_size, in_channels, seq_len)을 입력으로 받음
        # 우리 임베딩은 (batch_size, seq_len, embed_dim)이므로, permute(0, 2, 1) 필요
        self.convs = nn.ModuleList([
            nn.Conv1d(in_channels=embed_dim,
                      out_channels=num_filters,
                      kernel_size=k) # n-gram 크기
            for k in filter_sizes
        ])

        # 3. 드롭아웃
        self.dropout = nn.Dropout(dropout_prob)

        # 4. FC 레이어 (분류기)
        # 각 필터에서 하나씩의 피처(max-pooling)가 나오므로,
        # 총 num_filters * len(filter_sizes) 개의 피처가 입력됨
        self.fc = nn.Sequential(
            nn.Linear(num_filters * len(filter_sizes), num_filters * len(filter_sizes)),
            nn.ReLU(),
            nn.Dropout(dropout_prob),
            nn.Linear(num_filters * len(filter_sizes), num_classes)
        )

    def forward(self, input_ids):
        """
        모델의 순전파 로직

        Args:
            input_ids (torch.Tensor): (batch_size, seq_len)
                                     dataset.py에 의해 seq_len은 max_length-1이 됨

        Returns:
            torch.Tensor: (batch_size, num_classes)
                          각 클래스에 대한 logits
        """

        # 1. 임베딩
        # input_ids: (batch_size, seq_len)
        # embedded: (batch_size, seq_len, embed_dim)
        embedded = self.embedding(input_ids)

        # 2. Conv1d 입력을 위해 차원 변경
        # embedded: (batch_size, embed_dim, seq_len)
        embedded = embedded.permute(0, 2, 1)

        # 3. 컨볼루션 + ReLU
        # conved: (batch_size, num_filters, new_seq_len)
        conved = [F.relu(conv(embedded)) for conv in self.convs]

        # 4. Max pooling
        # F.max_pool1d(conv, conv.shape[2])는 (batch_size, num_filters, 1)을 반환
        # .squeeze(2)를 통해 (batch_size, num_filters)로 만듦
        # pooled: [ (batch_size, num_filters), (batch_size, num_filters), ... ]
        pooled = [F.max_pool1d(conv, conv.shape[2]).squeeze(2) for conv in conved]

        # 5. 피처 결합 (Concatenate)
        # catted: (batch_size, num_filters * len(filter_sizes))
        catted = torch.cat(pooled, dim=1)

        # 6. 드롭아웃
        dropped = self.dropout(catted)

        # 7. 완전 연결 레이어 (분류)
        # logits: (batch_size, num_classes)
        logits = self.fc(dropped)

        return logits

1D CNN 아키텍처

 

위의 아키텍처는 추후 Ablation Study를 통해 최종적으로 선택된 하이퍼파라미터를 적용한 모델이다.

 

구조를 보면 컨볼루션 레이어를 여러 개 쌓지 않고 병렬로 연결했는데, 그 이유는 서로 다른 n-gram 특징들을 동시에 추출한 뒤, 이 특징들을 하나로 합치기 위함이다.

이게 무슨 소리냐면, 만약 2-gram으로 훑은 정보를 연산하고, 다음 레이어(3-gram)에 전달하면 이것은 각 사이즈의 필터들이 존재하는 의미가 없어진다.

 

왜??

"예전 포스팅에서 CNN을 다룰 때 나왔던 사실: 각 필터들은 데이터에서 서로 다른 특징들을 훑고 피처 맵을 만든다."

 

이 피처 맵들이 모여서 한 번에 다음 단계로 가는 것처럼,

한 필터가 가공한 정보를 다음 필터가 받는 것이 아닌, 모든 필터가 동시에 정보를 추출해야 텍스트의 다양한 특징들을 학습할 수 있다는 점 때문에 여러 개의 conv 레이어를 쌓지 않은 것이다.


분량 상 이번 포스팅은 여기서 마치고 다음 포스팅에서 이어가겠다.

다음 포스팅:

 

반응형