부트캠프 파이널 프로젝트를 시작하며

4월 11일부터 나는 프로그래머스 인공지능 부트캠프의 마무리를 장식하는 파이널 프로젝트를 준비하고 있다. 사진을 초해상화(super-resolution)할 수 있는 GAN 모델인 ESRGAN을 웹 서비스 형태로 서빙하려고 한다. 파이널 프로젝트에 사용할 모델을 GAN 모델 중에서 ESRGAN을 선택한 이유는 단순히 GAN 모델이 창작물을 만들어내는 신기한 경험보다 일상생활에서 실용적으로 느낄 수 있는 서비스를 만들어보고 싶었기 때문이다. 다른 창작물들을 생성해내는 GAN 모델들의 경우 신기하긴 했지만 결과물이 썩 좋아보이지도 않았고 실제로 필요하다는 느낌이 오지 않았다. 하지만 ESRGAN의 경우 성능이 눈으로 바로 보이기도 하고, 일상생활에서도 인터넷에서 압축되어 화질이 떨어진 이미지들을 개선할 때 유용하게 사용할 수 있을 것 같았다. 그 동시에 내가 관심있는 GAN 프레임워크의 모델도 다뤄볼 수 있으니 이득이라고 생각했다.

처음엔 뮤직비디오 같이 저화질로 공개된 인터넷 영상들을 초해상화하는 서비스를 만들고 싶었지만, 동영상의 경우에는 inference에 걸리는 시간이 매우 오래걸리기 때문에 웹 서비스를 하는 것은 어려울 것 같다는 조언을 들어 이미지 쪽으로 방향을 바꿨다. 내가 구상한 구체적인 웹 서비스는 이미지를 업로드하면 모델의 연산을 거쳐 초해상화 된 이미지를 다운받을 수 있는 형태이다. 인터넷에 이미지 초해상화 관련 웹사이트들을 뒤져보니 역시나 비슷한 웹페이지들이 좀 있었다. 하지만 프로젝트를 할 때는 아이디어가 겹치는 다른 서비스들을 생각하지말고 우선 만들어보는 것이 중요하다고 어디선가 들었다(...) 실제로 계속 열어두고 서비스를 할 것도 아니니 괜찮겠지... 라는 생각으로 프로젝트 마무리까지 열심히 달려보기로 했다.

내가 생각한 프로젝트에 사용할 ESRGAN 모델은 사전학습되어 깃허브에 공개된 모델을 파인튜닝하여 사용하기로 했다. 아마추어 프로젝트이지만 그래도 성능이 좋은 모델을 만들고 싶었다. 성능이 안좋은 모델이 나오면 뭔가를 만들었다는 성취감보다는 아쉬운 마음이 남을 것 같았다. 사전학습 모델에 FFHQ 데이터 셋을 추가로 파인튜닝하여 인물 사진에 특화된 super-resolution 모델을 만들 계획이다. 고화질 인물 사진 데이터셋은 찾기 힘들 줄 알았는데 유명한 데이터셋이 있어서 의외로 금방 찾았다.

 

모델을 개선해야 해!

하지만 문제는 여기서 시작되었다. 명색이 파이널 프로젝트인데 그냥 사전 학습 모델을 파인튜닝하는 것만으로 괜찮을까? 라는 생각을 했다. 대체 내 능력으로 이 사전학습 모델을 그대로 사용하지 않고 개선하는 방법에는 무엇이 있는지 찾는 일은 너무 어려웠다. 고민고민하다가 모델의 inference time을 개선해보는 것은 어떨까하는 생각이 들었다. 사전학습 모델의 공개된 데모를 돌려보니 400 * 700 픽셀정도의 이미지를 넣으면 초해상화를 하는데 20초 정도의 시간이 걸렸다. 이 시간을 줄일 수 있다면 더 큰 이미지를 초해상화하는 데도 유용하게 쓰일 수 있겠다고 생각했다. 초해상화라는 것이 원래 적은 픽셀을 가진 저화질 이미지를 고화질 이미지로 바꾸는 것이긴 하지만 인터넷에서는 픽셀 수가 많아도 화질이 좋다고 할 수 없는 이미지들이 많다. 여러 웹사이트를 거치며 이미지가 압축되었거나 확대되었기 때문이다.

나는 솔직히 모델의 속도 개선에 대해서는 아직 시도해본 적이 없어 먼저 모델의 inference time을 줄이는 방법들을 찾아봐야 했다. 사전학습 모델을 사용할 것이므로 모델의 설계 단계에서 적용할 수 있는 방법들은 제외했다. 열심히 인터넷을 뒤져보니 모델의 inference time을 개선하는 방법은 크게 두가지 정도 있는 것 같다. Quantization(양자화)와 pruning(가지치기?)이다.

Quantizaton(양자화)

Quantization은 모델의 가중치 행렬의 값들을 더 낮은 바이트의 데이터 타입으로 변경하는 것이다. 모델의 가중치 행렬 속 값들의 데이터 타입을 보면 보통 float32 데이터 타입을 가질 것이다. Quantizaiton을 해주게되면 이 데이터 타입을 float16(half)나 int8 데이터 타입으로 변경한다. 이 과정을 거치면 모델의 성능이 떨어질 가능성도 있으나 전체적으로 메모리 사용량이 줄어들면서 모델의 inference time이 개선되게 된다. pytorch의 경우에 이 Quantization을 api 형태로 제공한다. 이 api를 사용할 경우 dynamic quantization이라고 해서 모든 가중치의 데이터타입을 바꾸는 것이 아닌 일부 가중치의 데이터 타입만 int8로 바꾸어 연산할 수 있다. 하지만 문제가 pytorch의 api를 사용하면 GPU가 아니라 CPU로 밖에 연산하지 못한다는 것이다. 속도를 빠르게 할 생각으로 GPU가 장착된 AWS EC2 P2 인스턴스를 제공받았는데 GPU를 못쓴다면 아무 의미가 없지않은가! 하긴 모델의 inference time을 줄이려는 시도는 원래 속도가 빠르던 GPU보단 CPU 쪽에서 더 수요가 많을테니...

future work라는데 언제 GPU에서 사용할 수 있게 될까요?

그래서 다른 방법으로 Quantization을 진행했다. 꼭 dynamic Quantization을 할 필요는 없다. pytorch 모델의 경우 model = model.half()와 같이 메소드를 사용하면 모델의 가중치를 float16으로 바꿔줄 수 있다. 이 경우에는 CPU에서 연산은 불가능해지고 GPU로만 연산이 가능했다. 또한 input tensor의 데이터 타입도 float16으로 바꿔줘야 한다.

하지만 나는 최악의 결과인 "효과 없음"을 보고 말았다. 정확히는 float32 모델로 연산시 21초 정도가 걸렸다면 float16을 적용한 후에는 20.xx초가 걸렸다. 사실상 큰 효과가 없었던 것이다. 정확한 이유는 알 수 없으나 내 추측에는 GPU의 문제인게 아니었을까 싶다. EC2 인스턴스에 장착되어 있던 GPU는 K80이었는데 엔비디아 홈페이지를 뒤져보니 K80은 float16 데이터 타입의 연산을 지원하지 않았다. 그럼에도 불구하고 나는 0.xx초가 줄어드는 효과를 봤기에 어떻게 잘하면 되는게 아닐까 어떤 부분의 코드를 잘못쓴게 아닐까하고 계속 시도해봤지만 역시나 똑같은 결과였다.

 

Pruning(가지치기)

Pruning의 경우는 모델의 가중치의 일부 값을 0으로 바꿔주는 방법이다. 무작위로 가중치를 골라 바꿔줄 수도 있고 모델의 영향을 별로 주지 않는 가중치만 0으로 바꿔줄 수도 있다. pruning을 하고나면 모델의 용량과 연산시 메모리 사용량이 작아지게 된다. pytorch는 pruning도 api로 제공한다. torch.nn.utils.prune 모듈을 사용하면 되며, 여기에 자세한 공식 튜토리얼이 소개되어 있다.

대충 이런모습, 근데 0으로 바꿔도 어차피 똑같이 텐서에 담겨 연산되는데 정말 속도가 빨라질까 의심되긴 했다.

환장하겠는건 pruning 조차 아무 효과가 없었다. 처음에는 pruning을 하면 모델의 성능이 많이 떨어지지 않을까 싶어서 소심하게 가중치 20% 정도를 pruning 해보았다. 그런데 inference time이 전혀 변화가 없었을 뿐더러 결과물에도 아무런 변화가 없어보였다. 나는 내가 너무 적게 pruning 했나 싶어 계속 pruning하는 가중치의 비율을 늘려보았지만 inference time은 변함없었고 결과물도 전혀 변화가 없었다. 시간이 변화가 없는건 그렇다쳐도 결과물까지 변화가 없는게 너무 이상해서 내가 어디에서 코드를 잘못친건가 계속 확인했는데 도저히 문제점을 찾을 수가 없었다. 서치해보니 pruning이 inference time에 별로 도움이 되지 않을수도 있다는 글을 보긴했지만 결과물까지 변함이 없는건 납득할 수 없었다. 아무리 찾아봐도 답이 나오지 않아 일단은 이 부분은 포기하기로 했다.

 

TensorRT

Quantization과 Pruning이 모델의 가중치를 변형시키는 방법이라고 하면, GPU 연산을 효율화하여 inference time을 줄이는 방법도 있다. 엔비디아의 경우 tensorRT 엔진이라고 하는 엔비디아 GPU 환경에서 사용할 수 있는 GPU 연산 최적화 SDK를 제공한다. tensorRT는 Quantization을 하거나 레이어의 연산같은 부분을 최적화시켜서 inference time을 줄일 수 있는데 pytorch와 tensorflow 등 다양한 프레임워크의 딥러닝 모델에서 사용할 수 있다. tensorRT 엔진은 엔비디아 도커르 사용해서 매우 쉽게 사용할 수 있었다. 다만 도커 이미지의 용량이 꽤 나갔다. tensorRT 엔진의 베이스 이미지 자체만 4GB 정도였다. 여기에 여러 필요한 라이브러리들을 설치하다보면 용량이 너무 쉽게 커졌다. 그래서 EC2 인스턴스의 스토리지를 20GB에서 50GB로 업그레이드하기도 했다.

그러나 이번에도 또 실패했다! 처음에는 tensorRT도 호환되는 GPU가 따로있어서 K80에선 돌려보지도 못하는 건가 싶었는데 구버전 tensorRT의 경우 K80도 지원한다고 쓰여있는 것을 발견해서 구버전 tensorRT로 도커 이미지를 만들고 torch2rt 라이브러리를 이용해서 pytorch 모델을 tensorRT 모델로 변경하려 했는데 이번에는 CUDA out of memory 에러와 마주하고 말았다;; K80은 이렇게 나약하단 말인가? 에러 난 부분을 보니 모델의 레이어를 전부 풀어서 input을 한번 통과시키는 과정이 있는 모양이었는데 그 부분에서 메모리가 터진 것으로 보였다. torch2rt의 깃허브 이슈에서 GPU 메모리가 터지면 torch.cuda.empty_cache()를 스크립트에 추가해서 GPU 캐시를 삭제해보라길래 vim으로 열어서 라이브러리 스크립트를 수정까지 해봤지만 GPU 사용량은 그대로였고 역시나 에러가 났다. 모델이 큰 것도 맞긴했는데 패러미터가 1천만개 정도로 BERT나 GPT 급으로 큰 모델도 아니었다. 대체 안에서 어떤 연산이 이뤄지길래 10GB 정도의 GPU 메모리를 전부 쓴단 말인가? 세번째로 시도한 방법을 실패하자 이쯤되니 좌절감이 장난아니었다.

다른 방법으로는 엔비디아의 trition inference server를 사용하는 방법도 있었다. 이 역시 엔비디아 도커를 통해 이용할 수 있었지만 GPU 호환이 맞지 않아 가볍게 컷됐다. triton inference server가 요구하는 GPU는 엔비디아 기준으로 6점 이상의 GPU인데 K80은 3점이다. 억지로 실행시킨다고 해도 오류를 뿜을 것이다. 

그 결말

이렇게 모델의 inference time을 줄이려는 내 노력은 실패로 돌아갔다. 이 방법들 말고는 input으로 주어지는 이미지의 크기를 제한해서 모델의 inference time을 줄이는 방법밖에는 떠오르지 않았다. 이 부분도 실험을 해보긴 해야겠지만 나름 큰 맘 먹고 다양한 방법들을 적용해서 모델의 inference time을 줄여보고 싶었는데 모두 실패해서 노력이 수포로 돌아가니 마음이 안좋았다. 최근 본 회사 면접때, 면전에 이제까지 한 프로젝트들이 시키는 것만 잘한 것 같다는 소리까지 들어서 조금 우울했는데 마음 다 잡고 이번에는 내가 스스로 생각해서 프로젝트에 기여해보리라는 생각에 다시 잘 해보려는데 이런 결과가 발생하다니. 사실 이 글을 남기는 것도 한 일들을 기록해서 내가 노력했다는 것을 알아주기라도 했으면 하는 마음에 적게 되었다. 

나는 결론적으로는 모델의 inference time을 줄여보려는 시도를 멈추고 다른 모델 개선방향을 찾아보고 있는 중이다. mlops 프레임워크를 도입해서 모델의 학습과 배포과정을 최적화해보면 어떨까 생각하고 있지만 내가 아직 사용해보지 않은 툴들이 많아서 잘 할 수 있을지는 모르겠다. 그래도 이왕 시작한 프로젝트 마무리 짓지 않으면 의미가 없다. 어떻게든 되게 하리라. 프로젝트가 마무리 될 쯤에는 꼭 내 노력으로 이뤄냈다는 성취감을 느껴보고 싶다.

ps. 여담이지만 머신러닝/딥러닝을 배우기로 결심한 이후로 나는 컴퓨터 앞에서 기도를 하는 일이 많아진 것 같다. 제발 한 번에 되게 해주세요, 제발 제 노력이 효과있게 해주세요 하고. 하지만 번번히 꼭 한 번씩은 실패한다. 딥러닝 모델에 blackbox가 있듯이 모델을 개발하는 과정에는 왠지 모르게 뭔가 노력으로 커버 되지 않는 영역이 있는 것 같다. 다른 사람들은 대체 이런 부분들을 어떻게 헤쳐나가는지 궁금하다 흑흑...

내가 현재 수강하고 있는 부트캠프에서 최종 프로젝트로 super resolution을 할 수 있는 모델을 GAN으로 구현해 웹 애플리케이션을 만들기로 했다. 나 같은 경우는 GAN도 직접 구현해본적이 없었고 super resolution 자체를 머신러닝으로 시도해본 적이 없었다. 그래서 학습을 위해 SRGAN을 직접 코드로 구현해보기로 했다.

SRGAN이란 무엇인가?

 SRGAN은 super resolution을 GAN 프레임워크를 빌려 구현한 딥러닝 모델 중 하나이다. 일단 GAN이란게 무엇이란 말인가... GAN은 Generative Adversarial Network의 약자로, 한국말론 적대적 생성 신경망이라는 뜻이다. GAN은 말 그대로 무언가를 '생성'하는데 특화되어 있다. 다양한 데이터나 이미지, 텍스트 등을 GAN을 통해 생성할 수 있다. 이런 특징을 이용해서 GAN은 예술, 학습데이터 증강, 이미지 보정 등 다양한 Task에 활용된다. GAN의 또 다른 특징은 두 종류의 모델을 동시에, 그리고 적대적으로 학습시킨다는 것이다. 이 두 종류의 모델을 주로 Generator와 Discriminator라고 부른다. Generator는 데이터를 생성하는 네트워크이고 Discriminator는 데이터가 원본 데이터인지 생성된 데이터인지 판별하는 네트워크이다. Adversarial loss라는 GAN만의 특별한 loss function을 통해, Generator는 Discriminator를 속이는 방향으로 학습하고 Discriminator는 원본을 더 잘 구별하는 방법으로 학습한다. Generator와 Discriminator가 균형을 잘 잡을 수 있도록 학습시키면 Generator는 사람이 보기에 정말 그럴듯한 데이터들을 생성할 수 있게 된다.

 SRGAN은 super resolution task를 GAN을 이용해서 풀어냈다. SRGAN이 등장하기 전에 저해상도의 이미지를 고해상도 이미지로 바꾸기 위한 state of arts 기법은 SRResNet이었다. SRResNet은 ResNet 구조를 이용한 super resolution 네트워크이다. 이 모델은 metric 스코어 측면에서 성능이 좋았지만 픽셀이 얼버무려지는 모습이 어색한 부분이 있어서 일반 사람이 보기에 아쉬운 정도의 수준이었다. SRGAN은 metric을 사용한 성능 측정을 벗어나서, 일반 사람들이 보기에도 자연스러운 super resolution 작업을 위해서 SRResNet에 GAN 구조를 도입한 모델이다. 

모델의 구조

이제까지 내가 구현해본 딥러닝 모델과 달리 서로 다른 모델 두개를 사용하는 모델이라서 조금 낯설긴 했다. 나로써는 SRGAN으로 GAN 구조를 처음 접해본 것이니 학습 과정도 신기했다. 나는 우선 논문 본문에 있는 모델 그림을 참고해서 모듈 클래스를 만들기로 했다. 처음엔 다른 코드를 참고하지 않고 내 힘으로 모델을 구현했는데 반복되는 레이어가 많아서 코드를 짜기가 꽤 번거로웠다. 특히 generator의 경우에는 residual layer가 많아서 구조가 복잡스러웠다. 근데 추후에 깃허브에 올라있던 코드들을 참고해보니 residual layer를 만드는 함수를 내부 메소드로 구현해두고 for문을 이용해 residual block을 만들고 있었다(...) 아무래도 이런게 모델 구현의 노하우라는 것인가 보다. 아래는 내가 직접 만든 조금은 부끄러운 코드이다. 다른 코드를 참고한 후 고쳐볼까 했으나 내가 직접 짠 코드를 남겨 놓는게 더 좋지 않을까 싶어 그냥 놔뒀다.

class Generator(nn.Module):
    def __init__(self):
        super(Generator, self).__init__()
        
        self.input_layer = nn.Sequential(nn.Conv2d(3, 64, kernel_size=9, padding=4),
                                   nn.PReLU()
                                  )
        
        self.resid_block1 = nn.Sequential(nn.Conv2d(64, 64, kernel_size=3, padding=1),
                                         nn.BatchNorm2d(64),
                                         nn.PReLU(),
                                         nn.Conv2d(64, 64, kernel_size=3, padding=1),
                                         nn.BatchNorm2d(64))
        
        self.resid_block2 = nn.Sequential(nn.Conv2d(64, 64, kernel_size=3, padding=1),
                                         nn.BatchNorm2d(64),
                                         nn.PReLU(),
                                         nn.Conv2d(64, 64, kernel_size=3, padding=1),
                                         nn.BatchNorm2d(64))
        
        self.resid_block3 = nn.Sequential(nn.Conv2d(64, 64, kernel_size=3, padding=1),
                                         nn.BatchNorm2d(64),
                                         nn.PReLU(),
                                         nn.Conv2d(64, 64, kernel_size=3, padding=1),
                                         nn.BatchNorm2d(64))
        
        self.resid_block4 = nn.Sequential(nn.Conv2d(64, 64, kernel_size=3, padding=1),
                                         nn.BatchNorm2d(64),
                                         nn.PReLU(),
                                         nn.Conv2d(64, 64, kernel_size=3, padding=1),
                                         nn.BatchNorm2d(64))
        
        self.resid_block5 = nn.Sequential(nn.Conv2d(64, 64, kernel_size=3, padding=1),
                                         nn.BatchNorm2d(64),
                                         nn.PReLU(),
                                         nn.Conv2d(64, 64, kernel_size=3, padding=1),
                                         nn.BatchNorm2d(64))
        
        self.output_layer1 = nn.Sequential(nn.Conv2d(64, 64, kernel_size=3, padding=1),
                                          nn.BatchNorm2d(64))
        
        self.output_layer2 = nn.Sequential(nn.Conv2d(64, 256, kernel_size=3, padding=1),
                                          nn.PixelShuffle(2),
                                          nn.PReLU(),
                                          nn.Conv2d(64, 256, kernel_size=3, padding=1),
                                          nn.PixelShuffle(2),
                                          nn.PReLU(),
                                          nn.Conv2d(64, 3, kernel_size=3, padding=1))

    
    def forward(self, x):
        x = self.input_layer(x)
        r1 = self.resid_block1(x) + x
        r2 = self.resid_block2(r1) + r1
        r3 = self.resid_block3(r2) + r2
        r4 = self.resid_block4(r3) + r3
        r5 = self.resid_block5(r4) + r4
        output = self.output_layer1(r5) + x
        output = self.output_layer2(output)
        
        return output
    
    
class Discriminator(nn.Module):
    def __init__(self):
        super(Discriminator, self).__init__()
        self.output_shape = (1, int(512 / (2 ** 4)), int(512 / (2 ** 4)))

        self.input_layer = nn.Sequential(nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1),
                                         nn.LeakyReLU(),
                                         nn.Conv2d(64, 64, kernel_size=3, stride=2, padding=1),
                                         nn.BatchNorm2d(64),
                                         nn.LeakyReLU())
        
        self.conv_block1 = nn.Sequential(nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1),
                                        nn.BatchNorm2d(128),
                                        nn.LeakyReLU())
        
        self.conv_block2 = nn.Sequential(nn.Conv2d(128, 128, kernel_size=3, stride=2, padding=1),
                                         nn.BatchNorm2d(128),
                                         nn.LeakyReLU())
        
        self.conv_block3 = nn.Sequential(nn.Conv2d(128, 256, kernel_size=3, stride=1, padding=1),
                                         nn.BatchNorm2d(256),
                                         nn.LeakyReLU())
        
        self.conv_block4 = nn.Sequential(nn.Conv2d(256, 256, kernel_size=3, stride=2, padding=1),
                                         nn.BatchNorm2d(256),
                                         nn.LeakyReLU())
        
        self.conv_block5 = nn.Sequential(nn.Conv2d(256, 512, kernel_size=3, stride=1, padding=1),
                                         nn.BatchNorm2d(512),
                                         nn.LeakyReLU())
        
        self.conv_block6 = nn.Sequential(nn.Conv2d(512, 512, kernel_size=3, stride=2, padding=1),
                                         nn.BatchNorm2d(512),
                                         nn.LeakyReLU())
        
        self.output_layer = nn.Sequential(nn.Conv2d(512, 1, kernel_size=3, stride=1, padding=1))
    
    def forward(self, x):
        
        x = self.input_layer(x)
        x = self.conv_block1(x)
        x = self.conv_block2(x)
        x = self.conv_block3(x)
        x = self.conv_block4(x)
        x = self.conv_block5(x)
        x = self.conv_block6(x)
        x = self.output_layer(x)
        
        return x

이 모델이 논문의 모델과 다른 점은, 논문 모델의 경우 discriminator 모델의 마지막 레이어는 fc layer인데 반해, 나는 마지막 레이어를 CNN layer로 처리했다. 이것은 patchGAN이라는 모델의 구조를 이용한 것으로, fc layer가 유발하는 패러미터 수 폭발을 피할 수 있다. patchGAN은 마지막에 출력되는 값들을 진짜 이미지일 경우 1로 채워진 텐서, 생성된 이미지일 경우 0으로 채워진 텐서와 MSE를 구하여 모델 패러미터를 업데이트 한다.

모델 학습

GAN 모델은 학습 과정도 좀 특이한데 optimizer 객체를 두 개 사용한다. 하나는 generator를 업데이트하는 optimizer, 하나는 discriminator를 업데이트하는 optimizer이다. 이들을 이용해 1 epoch당 generator와 discriminator를 한번씩 패러미터를 업데이트 하게 된다. 나중에 안 사실이지만 GAN 학습에는 다양한 방법이 있는 모양이다. 다른 레퍼런스들을 참고해보니 discriminator를 어느 정도 학습하고 나서 generator와 같이 학습이 들어가기도 하고 generator를 pre-trained 모델로 사용하기도 했다. 논문의 경우는 pre-trained SRResNet을 사용했다.  나는 그냥 1 epoch당 두 모델을 동시에 업데이트 하는 방향으로 학습시켰다.

def train_model(dataloader, generator, discriminator, feature_extractor, epochs, learning_rate=1e-4):
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    
    generator.to(device)
    discriminator.to(device)
    feature_extractor.to(device)
    feature_extractor.eval()
    
    optimizer_G = torch.optim.Adam(generator.parameters(), lr=learning_rate)
    optimizer_D = torch.optim.Adam(discriminator.parameters(), lr=learning_rate)

    content_criterion = nn.MSELoss()
    adv_criterion = nn.MSELoss()
    discriminator_criterion = nn.MSELoss()

    for epoch in range(epochs):
        for i, imgs in enumerate(dataloader):
            
            LR = imgs['LR'].to(device)
            HR = imgs['HR'].to(device)
            
            # discriminator labels
            real = torch.tensor(()).new_ones((LR.size()[0], *discriminator.output_shape), requires_grad=False).to(device)
            fake = torch.tensor(()).new_zeros((LR.size()[0], *discriminator.output_shape), requires_grad=False).to(device)

            
            # Generator 학습
            optimizer_G.zero_grad()
            generated_image = generator(LR)
            
            # content loss
            gen_features = feature_extractor(generated_image)
            real_features = feature_extractor(HR)
            content_loss = content_criterion(gen_features, real_features.detach())
            
            # Adversarial loss
            adv_loss = adv_criterion(discriminator(generated_image), real)
                        
            # Generator loss
            generator_loss = 1e-3 * content_loss + adv_loss
            generator_loss.backward()
            optimizer_G.step()
            
            # Discriminator 학습
            optimizer_D.zero_grad()
            
            real_loss = discriminator_criterion(discriminator(HR), real) 
            fake_loss = discriminator_criterion(discriminator(generated_image.detach()), fake)
            
            discriminator_loss = (real_loss + fake_loss) / 2
            discriminator_loss.backward()
            optimizer_D.step()
            
            # print loss

            print(f'EPOCH: {epoch + 1}, BATCH: {i + 1}, Discriminator loss: {discriminator_loss.item()}, Generator loss: {generator_loss.item()}')
            
            if (epoch+1) % 5 == 0:
                torch.save(generator.state_dict(), f'saved_model/generator_{epoch + 1}.pth')
                torch.save(discriminator.state_dict(), f'saved_model/discriminator_{epoch + 1}.pth')


    return generator, discriminator

중간에 나오는 feature extractor의 경우 SRGAN만의 특징인 VGG loss를 구하기 위한 VGG 모델이다. SRGAN은 VGG loss와 adversarial loss를 특정 비율로 반영해 패러미터를 업데이트 한다.

후기

처음으로 논문을 처음부터 끝까지 내 힘으로 구현해봤고 모듈 구조로 여러 파일로 나누어 작성해본 것도 처음이다. pytorch에 익숙해진지도 얼마 되지 않았는데 그래도 내 힘으로 이런 코드를 짜봤다는게 뿌듯하긴 하다. 앞으로 여러 논문들의 모델들을 직접 구현해보고 싶다.

이제 K-유튜브 제목을 생성해보자!

이제까지 수집한 유튜브 데이터들을 EDA해보며 K-유튜브의 특징들을 알아보았다. 이번 포스팅부터는 KoGPT2 모델을 fine-tuning해 새로운 유튜브 제목을 생성하는 모델을 구현해본다. 우선 GPT 모델에 대해서 알아볼 필요가 있다. GPT는 Gerative pre-training의 약자이다. GPT는 Transformer 모델의 영향을 받아 만들어진 모델이다. GPT의 가장 큰 특징은 대량의 라벨링 되지 않은 데이터를 이용해 사전학습을 할 수 있다는 점이다. 머신러닝에서 가장 큰 난관은 라벨링 된 질 좋은 데이터를 확보하는 것이다. 그렇기 때문에 머신러닝 프로젝트에서는 필연적으로 데이터를 전처리하고 라벨링하는 작업이 가장 긴 시간을 차지한다. 그런 난관을 해결하기 위해 등장한 것이 전이 학습이다. 전이 학습은 미리 대량의 데이터를 학습시켜 놓은 모델을 소수의 라벨링 된 데이터로 fine-tuning하여 다양한 Task에 활용하는 방법이다.
GPT 모델은 전이학습 모델 중에서도 텍스트 생성에 특화된 모델이다. 기본적으로 GPT는 전이학습용 모델이므로 분류나 Quenstion-Answer 문제같은 다양한 Task에 사용가능한 모델이지만 특히 그럴듯한 텍스트를 생성하는 모델로 유명하다. 때문에 소설 텍스트를 학습해서 창작 소설을 쓰거나 챗봇을 만드는 데 많이 사용된다. KoGPT는 그런 GPT 모델을 한국어 처리 Task에 사용할 수 있도록 대량의 한국어 텍스트로 사전학습 시키는 것이다. 이 프로젝트에서 우리는 SKT에서 공개한 KoGPT2 모델을 사용했다.Github 성능이 더 좋다고 알려진 카카오브레인에서 공개한 KoGPT 모델도 존재하지만 용량이 너무 커서 Colab 환경에서 불러올 수 없었고, 또 카카오브레인의 KoGPT 모델의 성능이 오히려 과장되었다는 의견도 존재하므로기사 그냥 사용하기도 쉽고 성능도 별 차이 없을 것이라고 예상되는 KoGPT2 모델을 사용하기로 했다.

KoGPT2 모델 사용하기

우리는 Colab 환경에서 GPU를 사용해 모델을 학습시키기로 했다. KoGPT2 모델은 허깅페이스에 공개되어 Transformers 라이브러리를 사용하면 사전학습된 모델을 불러올 수 있다. Transformers 라이브러리는 허깅페이스에 공개된 모델을 불러와 사용할 수 있게 해주는 라이브러리다. 주로 GPT를 비롯한 Transformer 기반으로 만들어진 모델들이 많이 공개되어 있다. 먼저 Colab 환경에는 Transformers 라이브러리가 기본적으로 설치되어 있지 않으므로, 추가적으로 설치해주어야 한다.

!pip install transformers

!는 Colab 셀에서 터미널 명령을 실행시킬 수 있게 해주는 코드다. 여기서 pip를 사용하면 환경에 설치되어 있지 않은 파이썬 라이브러리들을 설치할 수 있다. KoGPT2 깃허브의 튜토리얼을 참고하면 KoGPT2의 사전학습된 모델과 토크나이저를 불러오는 방법을 쉽게 알 수 있다. KoGPT2의 토크나이저는 약 5만개의 단어에 대해 학습되어 있으며 이모티콘, 이모지등도 사전에 있기 때문에 토크나이즈할 수 있다. 사전학습된 모델과 토크나이저를 불러오는 코드는 다음과 같다.

from transformers import PreTrainedTokenizerFast, GPT2LMHeadModel

MODEL_PATH = 'skt/kogpt2-v2-base'

model = GPT2LMHeadModel.from_pretrained(MODEL_PATH)
tokenizer = PreTrainedTokenizerFast.from_pretrained(MODEL_PATH,
                                                    bos_token='<s>',
                                                    eos_token='</s>',
                                                    pad_token='<pad>',
                                                    unk_token='<unk>',
                                                    mask_token='<mask>')

이 코드들은 KoGPT2 깃허브에서 찾아볼 수 있는 예시 코드이다. bos_token, eos_token은 문장의 시작과 끝을 알려주는 토큰이다. GPT 모델은 기본적으로 eos token이 나올때까지 토큰을 생성해낸다. 따라서 학습시킬 텍스트에는 반드시 문장의 시작과 끝에 bos token, eos token을 붙여줘야 알맞은 길이의 문장을 생성해낸다.pad token은 학습시킬 문장 간의 길이가 다를 때, batch를 생성하기 위해서 벡터의 빈 부분을 채워주는 패딩용 토큰이다. unk token은 사전에 없는 모르는 단어를 매핑하는 token이다.
다음은 K-유튜브 제목 데이터를 불러올 차례이다. fine-tuning을 하기 위해 Pytorch를 사용할 것이므로 pytorch의 dataset 객체로 데이터셋을 만들어 줄 것이다. dataset 객체는 torch의 dataset 객체를 상속시키고 데이터셋의 길이를 반환하는 __len__과 특정 인덱스의 데이터를 반환하는 __getitem__을 정의함으로써 만들 수 있다.

 import pandas as pd
 import torch
 from torch.utils.data import Dataset

 class TitleDataset(Dataset):
     def __init__(self, file_path, tokenizer, max_length):
        df = pd.read_csv(file_path)
        self.docs = df['vid_name']
        self.data = []

        for sent in docs:
            sent = tokenizer.bos_token + sent + tokenizer.eos_token
            encoding_dict = tokenizer(sent, max_length=max_length, padding='max_length')
            self.data.append(torch.tensor(encoding_dict['input_ids']))

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

    def __getitem__(self, index):
        return self.data[index]

객체를 선언할 때, file_path, tokenizer, max_length를 인자로 받는다. file_path에서 csv 파일의 경로를 받아 pandas를 이용해 데이터를 불러온다. 그 후 문장에 bos token과 eos token을 붙여 인자로 받은 tokenizer로 벡터화한다. 토크나이즈를 진행할 때, 입력받은 max_length에 맞춰 padding을 해준다. padding은 앞서 설명한 padding token으로 채워진다. 그리고 토큰으로 벡터화한 문장을 data 리스트에 tensor로 변형하여 추가한다. __len__과 __getitem__에는 data의 길이와 인덱스에 맞는 원소를 반환하는 함수를 정의한다.
dataset 객체는 torch의 DataLoader를 이용해 batch화 할 수 있다. DataLoader는 batch size와 shuffle 여부를 인자로 받아 batch로 묶인 데이터로더를 반환한다. DataLoader는 iterator의 역할을 해서 매 iter마다 batch화 된 텐서를 반환한다. 이번 프로젝트에서는 Colab 환경의 GPU 용량에 맞춰 batch size를 32로 정했다. batch size가 클 수록 학습은 빨라지지만 사용되는 메모리의 양은 커진다. 최종적으로 모델을 fine-tuning을 시키는 코드는 다음과 같다.

 from torch.utils.data import DataLoader

 device = 'cuda' if torch.cuda.is_available() else 'cpu'
 EPOCHS = 4
 BATCH_SIZE = 32

 # Dataloader 생성
 train_dataloader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=0)

 # Optimizer 생성
 optimizer = torch.optim.Adam(model.parameters(), lr=3e-5)
 model = model.to(device)
 model.train()

 for epoch in range(EPOCHS):
     for data in train_dataloader:
        data = data.to(device)
        optimizer.zero_grad()

        outputs = model(data, labels=data)
        loss = outputs.loss
        loss.backward()
        optimizer.step()

    # print loss & save checkpoint
    print(f'epoch: {epoch}, loss: {loss.detach().item()}')
    model.save_pretrained(f'model_file/model_{epoch}.bin')

model에 data를 넣고 Adam으로 모델의 파라미터를 업데이트한다. Pytorch는 backward 메소드를 이용해 역전파를 한 번에 편리하게 구할 수 있다. 그후 optimizer의 step 메소드를 사용하여 모델을 업데이트한다. 그리고 매 epoch마다 loss를 프린트하고 중간 모델을 세이브한다. GPT 모델의 fine-tunnig은 2~4 epoch의 학습을 권장하므로 이번 프로젝트에선 4 epochs로 모델을 튜닝했다.

학습된 모델의 결과

최종적으로 학습된 모델은 생각보다 좋은 출력물을 보여줬다. 시작할 단어를 입력하면 정말 그럴듯한 유튜브 제목을 생성해냈다. 생성 결과는 다음과 같다.

batch_size=32, epochs=4, learning_rate=3e-5로 fine-tuning 되어 저장된 모델을 불러와 사용했다. temperature는 모델의 output을 보정해서 좀 더 나올 확률이 낮은 단어도 출력하게하는 인자다. temperature 값이 높아질수록 좀 더 창의적인 문장이 나올 가능성이 높아진다. 생성 모델에서 temperature를 사용하지않고 계속 확률이 가장 높은 토큰만을 출력하게 만들면 동일한 단어가 계속 출력되는 모델이 될 수 있다.

출력된 제목들을 보면 매우 흥미롭다. 위 사진에서는 원래 데이터에서 매우 자주쓰인 단어인 '한국'을 input으로 주고 10개의 문장을 생성한 것인데, 대부분의 문장이 자연스러울 뿐만 아니라 체크해본 결과 원본 데이터의 제목을 그대로 출력하지도 않았다. 또한 대부분의 문장이 '~한 이유' 라는 식으로 문장을 끝맺고 있는 것도 재밌다. 크롤링하면서 직접 본 유튜브 제목들의 특징과 일치한다.

우리는 앞으로 이 모델을 web으로 서빙하는 과정까지 구현하여 단어와 temperature을 입력하면 적절한 제목들을 출력해주는 웹 어플리케이션을 만들어볼 계획이다. AWS와 django를 이용해서 간단하게 서빙해볼 것이다.

1. K의 시대

 유튜브의 세계는 냉혹하다. 사람의 관심을 받지 못한 컨텐츠들은 유저들에게 외면받는다. 이런 유튜브 세상에서 자신의 영역을 확고히하고 있는 유튜브 컨텐츠가 있다. 이들은 속된말로 "국뽕" 컨텐츠라 한다. 이 컨텐츠들은 흔히 대부분 한국의 좋은 점을 극대화해서 과장하거나 다른 나라들과 한국을 비교하며 다른 나라를 깎아내리는 내용을 담고 있다. 이런 유튜브 영상들은 호불호가 많이 갈려 소비하지 않는 사람들도 많은 편이지만, 상당수의 구독자를 확보하고 몇십만 이상의 조회수를 꾸준히 뽑아내는 "국뽕" 유튜버도 적지 않다. 

 이들의 영상에서 주목하지 않을 수 없는 특징이 하나 있다. 바로 특유의 길고 온갖 감탄사, 수식어들을 사용한 제목이다. 이 제목들은 대부분 길다 못해 유튜브 창에서는 ...으로 생략되기 마련이며 그 길다란 제목이 자랑스럽기라도 한 듯이 썸네일에 그 제목을 꽉꽉 채워 사람들의 눈길을 끈다. 보기에 추하다는 의견도 많지만 엄연히 이 길고 눈길을 끄는 제목이 조회수에 많은 기여를 한다는 것은 분명해보인다. 이런 제목의 특징은 여러 유튜버들에 걸쳐 공통적으로 나타나는 편이다. 그렇다면 이 "국뽕" 유튜브의 제목에는 어떤 패턴이 있지 않을까? 그리고 이 패턴을 이용해서 제목을 생성할 수 있는 모델을 만들 수 있지 않을까? 이 질문이 이 프로젝트의 시작이다.

 

2. 프로젝트 소개

프로젝트의 제목에 "국뽕"이라는 단어를 사용하기엔 어감이 부적절한 느낌이 있으므로, 그들이 주로 사용하는 접두사 K-를 활용하여 이러한 성향의 유튜브 영상들을 K-유튜브로 칭하기로 하자. 이 프로젝트의 목적은 K-유튜브의 제목을 생성할 수 있는 모델을 만드는 것이다. 프로젝트 팀은 나와 현재 프로그래머스 데브코스에 참여하고 있는 두명의 수강자로 이루어졌다. 우리는 프로젝트 기간을 최대 한 달로 잡고 데이터 수집, 전처리, EDA, 모델 생성 등의 task들의 계획을 세웠다.

데이터 수집은 Sellenium을 이용해 직접 유튜브에서 크롤링하기로 정했다. 유튜브의 경우 웹 드라이버를 이용한 크롤링이 쉬운편이다. 과거에도 유튜브에서 크롤링을 진행해본적이 있었는데 유튜버 별로 고유 URL이 있고 영상제목, 조회수, 좋아요 수 등의 정보가 들어있는 태그가 고정되어 있어 크롤링하기 수월했다. 우리는 "K-유튜브"의 경우 주로 올리는 유튜버가 정해져 있고, 그 유튜버는 거의 대부분 그런 성향의 영상만 올린다는 사실을 확인했다. 이런 유튜버들을 이른바 "K-유튜버"라고 정하고 이 유튜버의 목록을 생성한 후, 영상들의 제목을 크롤링한다는 계획을 세웠다. 물론 어떤 유튜버를 "K-유튜버"라고 정하냐는 문제는 객관적인 기준이 없으므로 해결하기 곤란한 문제였지만 팀원끼리 상의하여 만장일치로 동의하면 K-유튜버라고 분류하기로 했다. 이를 통해 주관적인 특성도 반영하면서 나름의 객관성도 가져가는 방식으로 유튜버 리스트를 작성했다. 이 유튜버의 리스트를 셋으로 나눠 각자의 환경에서 크롤링하였고 이 데이터들을 합치는 방식으로 진행했다.

생성 모델은 오픈소스로 공개되어 있는 KoGPT2를 사용하기로 했다. GPT는 transformer를 기반으로 발전한 pre-trained 모델로, 다양한 Task에 적용할 수 있지만 특히나 자연어 생성에도 잘 활용되기로 유명하다. KoGPT2는 오픈소스여서 가져다 쓰기도 쉽고 관련 예제도 쉽게 찾아볼 수 있으며, 대량의 한국어 Corpus를 미리 학습한 상태라 한국어 생성 모델을 만드는데 적합하다는 장점들을 가지고 있기 때문에 KoGPT2를 이번 프로젝트에 사용할 메인 모델로 선정했다.

 

3. EDA

 우리는 크롤링을 통해 유튜브 제목, URL, 좋아요 수, 조회수, 유튜버의 이름, 업로드 날짜. 태그, 구독자 수 데이터를 수집했다. 물론 유튜브 제목을 생성하는 모델을 만들기 위해선 유튜브 영상들의 제목만 있어도 충분하지만 먼저 이 데이터들을 EDA 해보며 K-유튜버들과 그 영상에는 어떤 특성이 있는지 분석해보기로 했다. 각자 크롤링하여 합쳐진 최종 데이터를 이용해 EDA를 진행했고 서로 토론을 하면서 EDA를 통해 볼 수 있는 의미들을 해석해보기로 했다.

3-1. 이들은 언제부터 등장했는가?

일반적인 대중들은 유튜브에 K-유튜버들이 존재한다는 사실은 많이 알고 있지만 이들이 언제부터 등장하기 시작했는지에 대해선 관심이 없는 경우가 많을 것이다. 우리 팀 역시 그랬다. 영상이 각광을 받기 시작한 것은 최근이지만 이들이 예전부터 K-유튜브를 올리고 있었는데 나중에 관심을 받은 것인지 최근부터 영상을 올리기 시작한 것인지는 모르는 일이다. 그러나 EDA를 진행한 결과 이들이 등장한 시기는 매우 명확해보였다.

크롤링한 영상들의 업로드 날짜를 기준으로 각 유튜버들이 언제부터 영상을 올리기 시작했는지를 보기위해 Range Chart를 그려보았다. 그러자 오래전부터 활동해온 영국남자 채널을 제외하고 대부분의 영상들이 2019년부터 활발하게 올라오기 시작했다는 사실을 알 수 있었다. 2019년은 무슨 해인가? 바로 "일본 불매운동"이 한참 유행하고 있을 시기였다. 구글의 검색어 트렌드를 보더라도 일본 불매운동에 대한 관심은 2019년에 급격히 증가한다.

유튜브 영상의 갯수를 직접 세어보더라도 명확한 차이를 보인다. 2018년의 영상의 수는 131개였지만 2019년엔 819개로 약 6배정도 폭발적으로 증가한다. 아래의 히스토그램이 그 사실을 보여준다. 이 EDA를 통해 이들은 한국에 대한 자존심으로 꾸준히 활동하던 유튜버들이 아닌, 어떤 이슈를 이용하여 의도적으로 조회수를 올리기 위해 등장한 유튜버들이라고 추정할 수 있다.

연도별 영상 수 히스토그램

3-2. K-유튜브의 트렌드

K-유튜브가 일본 불매운동을 계기로 시작되었다면 그들은 2022년 현재까지 어떤 영상들을 만들어왔을까? 이들의 영상에도 트렌드가 있을 것이다. 나는 먼저 유튜브 영상들에 달린 해시태그를 통해 이 트렌드를 찾아보고자 했다.  처음엔 연도별로 해시태그들의 갯수를 세면 해당 연도의 영상 트렌드를 볼 수 있지 않을까 생각했지만 그런 방식으로는 트렌드를 찾기가 어려웠다. 모든 연도에서 #일본, #한국, #외국 등, 트렌드와는 관련없는 일반적인 해시태그가 가장 많은 것으로 나왔다. 이런 해시태그로는 이들이 일본을 비롯한 외국 이슈에 관심이 많은 것을 알 수는 있지만 트렌드를 보기는 어렵다. 그래서 나는 해시태그의 갯수에 후처리를 해주어 트렌드를 볼 수 있는 지표를 만들기로 했다.

먼저 각 해시태그의 갯수를 해당연도에 올라온 영상 수로 나누었다. 우리 팀이 수집한 데이터는 연도별로 영상의 수가 다르고 2022년 데이터의 경우에는 현재인 2월까지의 데이터만 수집할 수 있었으니 단순히 해시태그의 수로 비교를 하면 정보가 왜곡될 가능성이 있다고 생각했다. 이 방법은 영상들중에서 해당 해시태그가 쓰인 영상의 비율을 구하는 것과 같다고 볼 수 있다. 나는 이 수치를 '해시태그의 점유율'이라고 하겠다.

그리고 트렌드를 관찰하기 위해서는 모든 연도에 걸쳐 일반적으로 쓰이는 해시태그가 아니라, 전년도에 비해 늘어난 해시태그를 관찰하는게 맞다고 생각했다. 즉 2019년과 2020년에 각각 100번씩 쓰인 해시태그보단, 2019년엔 쓰이지 않다가 2020년에 20번 쓰인 해시태그가 더 트렌드를 관찰하는데에는 중요하다는 것이다. 그래서 나는 전년도의 해시태그 점유율로 다음해의 점유율을 얼마나 높은 비율로 점유율이 증가했는지 관찰하고자 했다. 다만 이 경우 한번도 쓰이지 않은 해시태그는 점유율 역시 0이기 때문에 'devide with zero' 문제가 발생할 수 있다. 따라서 나는 점유율의 0값을 매우 0에 가깝지만 0은 아닌 값으로 바꿔주었다.(ex. 0.0000001)

또한 트렌드를 관찰하기 위한 데이터는 2019년 이후의 데이터만 사용하였다. K-유튜브가 대량 발생하기 시작한 시점이 2019년이고 그 이전의 데이터는 빈약하기 때문에 의미가 없다고 생각했다. 따라서 2019-2022년 기간의 K-유튜브 데이터를 이용해 트렌드를 관찰했다. 아래의 사진은 위에서 구한 점유율의 변화율을 바탕으로 해시태그를 이용해 wordcloud를 그린 것이다.

2019-2020 2020-2021 2021-2022

2019-2020년 사이의 트렌드를 보면 #네티즌반응, #외신반응 등의 해시태그들이 눈에 띈다. K-유튜버들이 처음 발생했던 2019년의 경우 대부분이 이슈를 정리해 보여주는 이른바 '이슈 유튜버'의 형태였으나 그 영역에서 특히 외국인들의 반응을 집중하여 다루는 유튜버들이 갈라져나왔다. 이 들의 영상에서 나오는 외국인 반응들은 대부분 한국의 장점을 극찬하는 내용을 담고있다. 그리고 2020년은 코로나-19 바이러스가 전세계적으로 퍼지기 시작한 때 였으므로 #코로나, #진단키트 , #이시국등의 해시태그들이 보인다.

2021년은 '오징어 게임'의 해였다. 넷플릭스 드라마 오징어게임은 전세계 넷플릭스에서 1위를 차지함으로써 K-컨텐츠의 위력에 취하기에 딱 좋은 키워드였다. #오징어게임, #이정재, #한류열풍 등의 해시태그들이 보인다. 또한 영화 #미나리와 여기에 출현하여 큰 상을 수상한 #윤여정, 처음으로 100% 한국 기술로 만들어져 쏘아올려진 로켓 #누리호 해시태그가 눈에 띈다. 해외이슈에 관련해서는 미군이 아프가니스탄에서 후퇴한 후 벌어진 사건들을 다루는 #아프가니스탄, #아프간 등의 해시태그들이 보인다.

2022년은 베이징 동계올림픽에 관한 이슈와 여기서 터진 편파판정 논란으로 해시태그들이 뒤덮혔다. #쇼트트랙, #베이징동계올림픽, #황대헌, #한국대표팀, #범내려온다와 같은 해시태그들이 보인다. 또한 최근 인기를 얻은 넷플릭스 드라마 #지금우리학교는 역시 보인다. 지금우리학교는에 대한 해외반응을 다뤘는지 #지금우리학교는해외반응과 같은 해시태그도 보인다.

해시태그 분석에 이어 유튜브 제목들을 분석했다. K-유튜브의 제목들은 신조어나 '오징어게임'과 같은 사전에 등재되지 않은 고유명사들이 많아 일반적인 형태소 분석기로는 tokenize를 하는 것이 부적절하다고 생각했다. 따라서 오픈소스 자연어처리 라이브러리인 나는 soynlp를 이용했다. soynlp는 사전을 사용한 지도학습 기반이 아닌 비지도학습 기반으로 만들어진 토크나이저를 제공한다. 나는 soynlp의 추출기중 명사를 추출하는 NewsNounExtractor를 사용하여 tokenize를 진행했다. 유튜브 제목들을 tokenize했고, '은', '는', '이', '가', '에서'와 같은stopwords들을 제거했다.

트렌드를 비교하는 방식으로 해시태그의 경우와 동일하게 점유율과 변화율을 구하여 wordcloud를 그렸다. 그려진 결과는 다음과 같다.

2019-2020 2020-2021 2021-2022

단어를 통해 진행한 트렌드분석도 해시태그로 진행한 결과와 크게 다르지 않았다. 토크나이저가 완벽하지 않아 명사가 아닌 단어도 섞였고 조사가 분리되지 않은 단어도 있어 보기에 좀 더 어렵긴하지만 대체적으로 2020(코로나, 해외반응) -> 2021(오징어게임, 아프간) -> 2022(베이징올림픽)의 트렌드 변화를 보여준다. 다만 해시태그 분석에선 보이지 않았던  2021년 요소수 대란에 관한 키워드를 새롭게 보여주었다.

3-3 그 외 분석

 

 마지막으로 제목과 태그 수에 관한 간단한 분석을 거쳤다. 영상 제목들의 길이는 20자부터 80자까지 범위가 넓은 편이었으며, 명사의 수로 세보면 대부분 10~20개의 명사를 사용했다. 태그의 경우는 3개이상의 태그를 사용한 영상이 가장 많았지만 태그를 사용하지 않은 영상도 많다는 것을 알 수 있었다.

+ Recent posts