deep daiv.
← 제9회 오픈 세미나

Session 1 / B룸 / Deep Learning

소설 쓰고 앉아 있네…

KoGPT2 기반 대화형 소설 완성 프로그램

자연산연어
발표
B룸 · 6층 강의장
팀원
한종민, 이예나, 박진우, 우연지
형식
Application
소설 쓰고 앉아 있네… thumbnail

목차

주제 선정

NLP 모델을 사용하고 싶었고, 문장 생성 (text generation) 에 관한 주제를 하고 싶었습니다.

사용자와 AI는 서로 한 문장씩 혹은 여러 문장씩 주고 받으며 하나의 큰 소설을 써 내려갑니다.


AI 🤖

“왕자님은 공주님을 사랑했지만, 공주님의 마음은 왕자님을 향해있지 않았지.”

“왕자님은 사력을 다해 도둑들을 물리쳤고, 공주님을 구하셨지.”

사용자 😃 “옛날 옛적에 왕자님과 공주님이 살고있었어.”

“그러던 어느날, 왕자님과 공주님이 살고 있던 성에 도둑이 들었어.”

“(계속)”


그리고 사용자와 AI가 Interactive하게 소통할 수 있으면 재미있겠다고 생각했습니다.

😃🤖 최종적으로, 이러한 대화를 통해 소설이 완성됩니다:


AI 🤖

“왕자님은 공주님을 사랑했지만, 공주님의 마음은 왕자님을 향해있지 않았습니다.”

“도둑들은 공주님을 살해했고, 왕자님은 공주를 잃은 슬픔에 절망했습니다.”

“잠에서 깬 왕자는 정신을 차려보니 차들이 다니는 왕복 8차선 도로 한 가운데였습니다. 당황한 왕자는 일단 도로를 빠져 나왔습니다.”

사용자 😃 “옛날 옛적에 왕자님과 공주님이 살고있었어.” 서사구조: 발단 감정: 슬픔

“그러던 어느날, 왕자님과 공주님이 살고 있던 성에 도둑이 들었어.” 서사구조: 전개 감정: 절망

“그러던 어느날,” 서사구조: 위기 감정: 당황

“(계속)”


모델: KoGPT2?

그렇다면, 이러한 테스크에는 어떤 모델이 적합할까요 ?

#문장 생성 #한국어 #오픈소스

💬 KoGPT2 저희는 문장 생성에 적합한 GPT 모델 중 한국어에 특화된 모델인 KoGPT2를 선택했습니다.

그렇다면 어떤 원리로 문장 생성을 하는지 모델 구조를 살펴보겠습니다。

소설 쓰고 앉아 있네… image 1
소설 쓰고 앉아 있네… image 1

기존의 encoder-decoder 기반의 모델에서 decoder만 가져온 모델로서 여러 층의 decoder block으로 구성되어있습니다.

Decoder block은 크게 Masked self-attention과 Feed Forward Neural Network로 나뉩니다.

여기서 Masked self-attention이 모델을 이해하는데 가장 중요한 포인트입니다. “나는 한강에 런닝을 하러 갔다”라는 문장을 예시로 들어서 설명하겠습니다.

토큰화를 통해 ‘나는’ 、‘한강‘、’에‘、’런닝‘、’을‘、 ’하러‘、 ’갔다‘ 로 토큰이 생성되었다고 가정하겠습니다。 처음에 입력 토큰으로 ’나는‘ 이 들어간다고 했을 때 정답 시퀀스는 ’한강‘에 해당합니다。이 때 ’한강‘이후에 모든 단어 즉、 ’한강‘~’갔다‘ 까지의 단어들은 참고할 수 없도록 마스킹 처리됩니다。모델은 ’한강‘이라는 단어의 출력 확률을 높이는 방향으로 가중치를 업데이트합니다. 이후에 입력 토큰으로 ‘한강’이 들어가면 정답 시퀀스는 ‘에’에 해당합니다。이때는 ‘한강’까지의 토큰 즉、‘나는’과 ‘한강’을 참고해서 다음 단어를 맞추게 됩니다。확률이 출력되면 이를 정답 label과 비교하여 가중치를 업데이트합니다.

정리하자면 입력 토큰을 기준으로 입력 토큰 전까지의 토큰을 참고하여 다음 토큰을 예측하고 입력 토큰 이후의 단어들은 마스킹처리하여 다음 토큰을 예측하는데 참고할 수 없도록 만듭니다.

데이터

사전 학습된 KoGPT2를 파인튜닝하기 위해서는 다양하고 많은 양의 데이터가 필요했습니다. 또한, 감정과 서사구조 등이 레이블링 되어있는 데이터가 필요했습니다.

그래서 저희는 AI-Hub 의 다양한 문화 콘텐츠 스토리 데이터를 활용했습니다. 이 데이터는 스토리 작품의 서사단위를 유닛으로 하여 줄거리를 작성하고 설정, 모티프, 인물, 서사단계, 감정, 장소 등의 스토리 창작 요소가 라벨링되어 있습니다.

데이터 예시

json
{
    "id": "03_1779",
    "type": "novel",
    "title": "돈이 필요해서 그만",
    "genre": [
        "드라마"
    ],
    "year": 2014,
    "theme": "도전",
    "concept": "돈이 없어 C001가 집을 구하기 위해 부잣집 강아지 C008를 몰래 훔친 후 돌려주고 사례금을 받을 계획을 세운다.",
    "structure": "스토리헬퍼 15단계",
    "motif": "동물과의 우정",
    "main_character": "엉뚱함",
    "conflict": "강아지 C008를 둘러싼 사람들이 각자의 이익을 위해 고군분투한다.",
    "characters": [
        "C001",
        "C002",
        "C003",
        "C004",
        "C005",
    ],
    "units": [
        {
            "id": "03_1779_01",
            "next_id": "03_1779_02",
            "characters": [
                "토끼",
                "C001",
                "사자",
                "개구리",
                "담임선생님"
            ],
            "stage": "Opening Salvo",
            "storyline": "C001가 학교에서 수업을 듣던 도중 수업 내용에 불만이 생긴다.",
            "causality": "First Action(시작)",
            "story_scripts": [
                {
                    "type": "narrative",
                    "location": "나레이션",
                    "character": [
                        "토끼"
                    ],
                    "act": "공개하다/드러내다",
                    "content": "화면 속에 토끼 그림이 나타난다."
                },
                {
                    "type": "script",
                    "location": "나레이션",
                    "character": [
                        "C001"
                    ],
                    "act": "말하다",
                    "emotion": "담담하다",
                    "content": "옛날 옛날에 토끼 형제가 세 마리 살았다."
                },
                {
                    "type": "narrative",
                    "location": "나레이션",
                    "character": [
                        "토끼"
                    ],
                    "act": "보다",
                    "emotion": "긴장되다",
                    "content": "토끼 세 마리가 풀숲에 숨어 망을 보고 있다."
                },
                {
                    "type": "script",
                    "location": "나레이션",
                    "character": [
                        "C001"
                    ],
                    "act": "말하다",
                    "emotion": "담담하다",
                    "content": "이 토끼들은 숨어서 망을 보는 게 인생의 전부였다."
                },
                {
                    "type": "narrative",
                    "location": "나레이션",
                    "character": [
                        "사자"
                    ],
                    "act": "공격하다",
                    "emotion": "비장하다",
                    "content": "사자가 튀어나와 토끼들을 겁준다."
                },

전처리

KoGPT2를 파인튜닝하기 전에, 데이터를 우리의 목적에 맞게 전처리할 필요성을 느꼈습니다.

서사구조와 감정 맵핑

원데이터에는 서사구조는 27종류, 감정은 90여 종류가 있었습니다. 사용자에게 제공하는 선택지를 줄이고자 서사구조와 감정을 맵핑하기로 했습니다.

서사구조는 6단계로, 감정은 6종류로 맵핑하였습니다.

  • 서사구조
소설 쓰고 앉아 있네… image 2
소설 쓰고 앉아 있네… image 2

영웅서사와 스토리헬퍼 단계를 참고하여 도입, 발단, 전개, 위기, 절정, 결말의 6단계로 맵핑하였습니다.

  • 감정

처음에 감정을 8개의 감정으로 레이블링하였습니다.

소설 쓰고 앉아 있네… image 3
소설 쓰고 앉아 있네… image 3

하지만, 감정 레이블 간의 불균형이 나타나 학습이 제대로 되지 않을 가능성이 있었기 때문에 새롭게 무덤덤, 동요, 관심, 슬픔, 분노, 행복의 6종류 감정 레이블을 만들어 이를 해소하였습니다.

소설 쓰고 앉아 있네… image 4
소설 쓰고 앉아 있네… image 4

예시) “혹시 제가 알아야 할 귀족 영지만의 풍습이 있나요? 원데이터 레이블: 궁금하다 레이블링 후: 관심

대사는 구분하자

모델이 소설 문장을 생성할 때, 대사와 평서문의 구분이 있어야 더욱 표현력이 높아지겠다고 생각했습니다. 따라서, scene_type이 script 인 문장에 대해 따옴표(””)를 추가하여 전처리를 진행했습니다.

소설 쓰고 앉아 있네… image 5
소설 쓰고 앉아 있네… image 5

3문장씩 묶기

사용자와 AI가 문장을 주고 받으며 소설을 완성하는 것이 목표이기 때문에, 너무 지루하지 않게 진행되려면 3문장 정도의 출력이 필요하다고 판단했습니다.

3문장씩 묶을 때,

  1. 소설의 순서를 유지하면서
  2. 서사구조가 같은

문장들을 3문장 이하로 묶어주었습니다.

소설 쓰고 앉아 있네… image 6
소설 쓰고 앉아 있네… image 6

파인 튜닝


AI 🤖

“왕자님은 공주님을 사랑했지만, 공주님의 마음은 왕자님을 향해있지 않았습니다.”

“도둑들은 공주님을 살해했고, 왕자님은 공주를 잃은 슬픔에 절망했습니다.”

“잠에서 깬 왕자는 정신을 차려보니 차들이 다니는 왕복 8차선 도로 한 가운데였습니다. 당황한 왕자는 일단 도로를 빠져 나왔습니다.”

사용자 😃 “옛날 옛적에 왕자님과 공주님이 살고있었어.” 서사구조: 발단 감정: 슬픔

“그러던 어느날, 왕자님과 공주님이 살고 있던 성에 도둑이 들었어.” 서사구조: 전개 감정: 절망

“그러던 어느날,” 서사구조: 위기 감정: 당황

“(계속)”


사용자와 GPT가 서로 대화를 주고 받으며 소설을 완성합니다. 이는 Q&A 혹은 Chatbot Task와 유사합니다.

그래서 input을 이렇게 정했습니다:

이전 문장 <구분> 감정 <구분> 서사 구조 <구분> 이후 문장

사용자 입력 문장 <구분> 감정 <구분> 서사 구조 <구분>

파인 튜닝하는 과정에서 저희의 목적에 맞도록 하이퍼 파라미터를 결정할 필요가 있었습니다.

하이퍼 파라미터 설정

max-length

한 번에 모델에 넣을 input의 길이를 의미합니다. max-length를 결정하기 위해 input 데이터의 문장을 토큰화하여 길이를 파악해보았습니다.

소설 쓰고 앉아 있네… image 7
소설 쓰고 앉아 있네… image 7

그 결과, 대부분 세 문장당 최대 토큰 길이가 100인 것을 확인할 수 있었습니다. Question 3문장, Answer 3문장을 입력으로 넣기 때문에 max-length는 256으로 결정하였습니다.

top-p, temperature, repetition penalty

문제점

사전 학습된 KoGPT2를 AI-hub의 데이터를 활용하여 파인 튜닝시키고 몇 차례 실험을 진행해봤습니다. 그 과정 속에서 저희의 의도와 맞지 않게 출력되지 않는 상황들이 있었습니다.

등장인물 인식

AI-hub의 다양한 문화 콘텐츠 스토리 데이터에는 등장인물의 이름이 코드로 되어있었습니다. 예를 들어, C001, E003처럼 되어있습니다. 이러한 형태의 데이터를 가지고 파인 튜닝을 시켰기 때문에, 저희의 모델은 사용자가 입력하는 등장인물의 이름을 인식하지 못하고 C001로 문장을 출력하는 문제가 있었습니다.


AI 🤖

“아니야.”

C003은 갑자기 멈춰 선다.

사용자 😃 어둠이 짙게 내려앉은 도시는 숨을 쉬고 있다. 마치 그 안에 숨겨진 비밀을 감추려는 듯. 서사구조: 발단 감정: 두려움

철수는 그냥 “아니야.”라는 말만 반복한다. 이에 당황하는 듯한 민수. 서사구조: 발단 감정: 동요


그래서 등장인물 변환기와 NER을 통해 등장인물의 인식 문제를 해결하고자 했습니다。

등장인물 변환기

기존의 데이터는 등장인물이 ‘홍길동’과 같이 이름으로 표기되어있지 않았습니다。

소설 쓰고 앉아 있네… image 8
소설 쓰고 앉아 있네… image 8

위의 그림에서 characters부분에 “C001”、“C002”、”C003“、“C004”、C005“와 같이 표기된 것을 볼 수 있습니다. 이는 파인튜닝 과정에서 등장인물이 실제 이름으로 학습되지 않았음을 의미합니다. 그래서 모델의 input으로 들어갈 때는 ”C001“와 같이 원본데이터의 형식을 유지하고 출력되어 사용자에게 보여질 때는 ”홍길동“과 같이 실제 이름으로 보이도록 등장인물 변환기를 만들었습니다.

소설 쓰고 앉아 있네… image 9
소설 쓰고 앉아 있네… image 9

NER

NER은 Named Entity Recognition으로 텍스트 문장에서 개체를 찾아서 미리 정의된 범주(사람 이름, 조직, 위치, 시간, 날짜 등)으로 분류하는 NLP 방법입니다. 예를 들어, “준수는 그저 법을 준수했을 뿐이다.” 라는 문장에서 주어에 해당하는 준수는 PERSON으로 인식하고 서술어에 해당하는 준수는 PERSON으로 인식하지 않는 것입니다.

python
text = """준수는 법을 잘 준수하는 착한 아이이다."""

tta_labels = ["ARTIFACTS", "ANIMAL", "CIVILIZATION", "DATE", "EVENT", "STUDY_FIELD", "LOCATION", "MATERIAL", "ORGANIZATION", "PERSON", "PLANT", "QUANTITY", "TIME", "TERM", "THEORY"]

entities = model.predict_entities(text, tta_labels)

print(entities)

# 데이터에서 'PERSON' 레이블을 가진 항목들만 추출
person_entities = [entity for entity in entities if entity['label'] == 'PERSON']

# 추출된 결과 출력
print(person_entities)
python
[{'start': 0, 'end': 3, 'text': '준수는', 'label': 'PERSON', 'score': 0.612339198589325}, {'start': 17, 'end': 21, 'text': '아이이다', 'label': 'CIVILIZATION', 'score': 0.9460038542747498}]
[{'start': 0, 'end': 3, 'text': '준수는', 'label': 'PERSON', 'score': 0.612339198589325}]

모델 평가는?

저희의 목적은 사용자와 AI가 interactive하게 소통하여 소설을 완성하는 것입니다. 그런데 소설을 잘 썼다는 것은 무엇일까요? 다양한 표현을 사용하는 것? 명확한 플롯을 가지는 것? Validation set을 만들어 loss값을 구하는 것으로 모델 성능을 측정할 수 있을까요? 그래서 저희는 생성된 결과를 사람이 평가하기로 했고, 팀 내에서 하이퍼 파라미터를 조정하며 최적의 모델을 선정했습니다.

결과

저희가 선정한 하이퍼 파라미터를 바탕으로 BEST CASE와 FAILURE CASE를 추려봤습니다.


  • BEST CASE

AI🤖

“산 능선 너머로 가는 거야.” 철수 은 숲 속을 걸어간 뒤 산으로 향한다는데 왜 그렇게 늦을까?

“나도 그래.” 철수 은 그런 모습을 보고 반가워한다.

“나도 그래.” 철수 은 눈을 질끈 감는 듯 흐뭇한 표정으로 본인을 바라본다고 말한 뒤 아무 말도 하지 않는 게 어이없다는 듯이 고개를 젓다가 갑자기 눈물이 뚝뚝 떨어진 것을 깨닫고는 놀란 표정을 짓는 것 같다.

사용자😃 봄은 동틀 무렵. 산 능선이 점점 하얗게 변하면서 조금씩 밝아지고, 그 위로 보랏빛이 가늘게 떠 있는 풍경이 멋있다. (감정 : 동요, 서사 구조 : 발단)

여름은 밤. 달이 뜨면 더할 나위 없이 좋고, 칠흑같이 어두운 밤에도 반딧불이가 반짝반짝 여기저기에서 날아다니는 광경이 근사하다. (감정 : 행복, 서사 구조 : 전개)

겨울은 새벽녘. 눈이 내리면 더없이 좋고, 서리가 하얗게 내린 것도 멋있다. (감정 : 슬픔, 서사 구조 : 절정)


  • FAILURE CASE

AI🤖

서준 은 철수 을 찾아온다. “어떻게 그런 마음을 먹고 바다를 떠나다니 말이야?” “나는 해변에 있어.”

민수의 부하들을 놀란 눈으로 바다에 뛰어내린다고 말하지만 아무도 의심은 없다. 무전기를 통해 섬에 들어간 사람들의 흔적을 찾을 수 있을 것이라 말한 후 도울이 풀릴 거라 확신한다.

윤서 을 찾아보겠다고 협박한데 어디로 갔냐고 물어본 적 없어요? 그건 모르겠을 뿐이야.” “무슨 일이에요를 두고 간 얘길이라도 있는 거지 뭐라고 했잖아. 저렇게까지 하면 죽는 걸 생각해야돼! 내심 기절시킨 사람이 얼마나 되나 싶어서 자살을 한 것처럼 몰아가 미안해할 수 밖에 없었어.”

사용자😃

부, 명성, 힘 한 때 이 세상의 모든 것을 손에 넣은 사나이. 해적왕 골드 로저. 그가 죽음을 앞두고 남긴 한마디는 전 세계 사람들을 바다로 향하게 만들었다.(감정 : 동요, 서사 구조 : 전개)

서준은 철수에게 화를 낸다. 서준은 철수가 무서운 바다로 나가는 것이 두렵기 때문이다.(감정 : 분노, 서사 구조: 절정)

“도울이 어떻게 풀려요.”(감정 : 슬픔, 서사구조 : 절정)