딥러닝으로 동네생활 게시글 필터링하기

우리 나라 동네 사진(감천 문화 마을): 사진 링크

PyTorch를 사랑하는 당근마켓 머신러닝 엔지니어 Matthew 입니다. BERT를 사용해서 동네 생활 게시글 필터링 모델을 개발한 과정을 정리해봤습니다.

포스트는 다음과 같이 진행합니다.

  • 동네생활과 BERT
  • BERT Pretrain 하기
  • 게시글 필터링을 위한 데이터셋 만들기
  • 게시글 필터링 모델 학습하기
  • 게시글 필터링 모델 배포하기

동네생활과 BERT

당근마켓에서 최근에 동네생활이라는 새로운 탭을 일부 지역에 오픈 했습니다. 동네생활은 동네에 관련된 다양한 이야기를 하는 곳입니다. 어느 지역 업체가 괜찮은 지 물어볼 수도 있고 함께 모임을 하자고 게시글을 올릴 수도 있습니다. 동네생활 탭은 다음 사진과 같이 중고거래와 별도 탭으로 분리해서 운영하고 있습니다.

동네생활 탭 예시

하지만 항상 의도한 대로 되지 않습니다. 동네생활 탭을 만든 의도는 동네와 관련된 글을 주고 받는 것입니다. 하지만 글을 올릴 수 있는 공간이 생김에 따라 안 좋은 거래 경험 같은 글도 많이 올라옵니다. 현재는 사람이 보고 동네생활에 어울리지 않는 글인지 판단하고 처리하고 있습니다. 앞으로 동네생활을 전국에 오픈하면 사람이 다 처리할 수 없습니다. 이러한 이유로 사용자가 동네생활에 게시글을 올릴 때 동네생활에 맞는지 판별하는 머신러닝 모델이 필요하다고 판단했습니다.

어떻게 게시글이 동네생활에 맞는지 판단하는 모델을 학습할 수 있을까요? 이러한 판단을 하는 모델은 가장 간단한 머신러닝 모델인 분류 모델입니다. 자연어처리 분야에서는 분류에 CNN이나 LSTM을 이용한 모델을 많이 사용합니다. 이러한 모델은 모두 주어진 데이터에 대해서만 학습합니다. 하지만 가지고 있는 데이터가 충분하지 않을 경우 만족할 만한 성능을 얻기 어렵습니다. 모델이 주어진 데이터에 과도하게 맞춰져 앞으로 새롭게 올라올 글에서 좋은 성능을 내기 어렵습니다. 현재까지 올라온 동네생활 글은 총 10000개 정도로 딥러닝 모델을 학습하기에는 적은 양입니다.

따라서 BERT를 활용하기로 했습니다. BERT는 2018년에 구글에서 발표한 NLP 관련 모델 이름입니다. 기존 간단한 CNN, LSTM 모델과는 다르게 BERT는 주어진 데이터에 대해서 학습하는 것이 아닙니다. BERT는 일반적인 텍스트 데이터에 대해서 사전 학습하는 형식입니다. 아주 큰 일반적인 데이터에 대해 사전학습 즉, Pretrain한 다음에 원래 목적으로한 데이터셋에 학습을 하면 더 뛰어나 성능을 보입니다. 다음 그림 같이 SQuAD라는 Q&A 데이터셋에 대해 BERT는 사람보다 질문에 대한 답을 더 잘 맞춥니다 (SQuAD는 질문에 대한 답을 지문에서 찾는 영어 읽기 평가 같은 느낌의 문제입니다).

SQuAD 1.1 리더보드: 이미지 출처

당근마켓에서는 한 달 동안 200만 개 이상의 중고 거래 게시글이 올라옵니다. 서비스 시작할 때부터 모든 중고 거래 게시글을 모으면 양이 상당히 많습니다. 따라서 중고 거래 게시글을 BERT의 사전 학습에 사용할 수 있습니다. 일반적인 데이터에 대해 사전 학습을 함으로서 당근마켓 내 텍스트 데이터에 대한 모델을 먼저 만드는 것입니다. 사전 학습을 통해 언어 모델을 만들면 게시글을 판별하는 모델을 만들기에 유용한 특징을 모델에 제공할 수 있습니다. 다음 그림은 일반 분류 모델과 BERT를 활용한 모델의 차이를 보여줍니다. 중고 거래 데이터 이외에 위키피디아나 책 데이터를 함께 사용하는 등 선택지는 많지만 간단히 시작하기 위해 중고 거래 데이터만 사용했습니다.

일반 분류 모델과 BERT 활용 모델 학습

BERT Pretrain 하기

BERT PyTorch 코드 중에는 유명한 코드가 많습니다. 그 중에서도 가장 가독성이 좋은 김준성님의 BERT 코드를 활용하기로 했습니다. 코드는 다음 링크로 들어가시면 볼 수 있습니다. 만약 Tensorflow를 사용하신다면 google의 BERT 공식 코드를 사용하시면 됩니다.

BERT 모델을 학습하기 위해서는 다음과 같이 corpus를 준비해야 합니다. 한국어의 경우 tokenized corpus라고 써져있는 부분과 같이 준비합니다. 자 이제 BERT 학습을 위해 데이터셋을 준비하는 과정을 설명하겠습니다.

텍스트 전처리

BERT 학습에 사용하는 데이터도 기본적으로 텍스트 데이터입니다. 따라서 기존에 텍스트 데이터에 대해 하던 전처리 방법을 사용합니다. 당근마켓에 올라오는 중고 게시글의 경우 책이나 기사 처럼 맞춤법이 다 맞지 않기도 하고 가격, 사이즈 등의 숫자 정보가 많습니다. 다음은 중고 거래 게시글의 예시입니다.

중고 거래 게시글의 예시

따라서 토큰으로 쪼개기 전에 텍스트에서 가격이나 전화번호, url과 같은 정보들은 특정 token으로 바꿔주는 것이 좋습니다. Python에서는 이 작업을 정규표현식을 가지고 많이 합니다. 정규표현식은 string에서 특정한 패턴을 발견하는 일을 합니다. 데이터마다 특징이 다를 수 있지만 당근마켓 데이터에 대해서는 다음과 같은 정규표현식을 사용해서 전화번호, url, 가격을 tel, url, money로 치환했습니다. 정규표현식에 대해서는 구글에 검색하면 많은 자료가 나오니 참고하면 됩니다.

토크나이저 선택

BERT 논문에서 사용했다고 말한 tokenizer는 wordpiece입니다. Wordpiece에 대한 설명은 다음 블로그 글을 참고하면 됩니다. Byte-pair encoding 방식으로 주어진 데이터에서 사전을 학습하는 것입니다.

블로그에 나온 코드를 사용해서 wordpiece model을 학습하면 시간이 상당히 오래 걸립니다. 구글에서 공개한 BERT 공식 코드 README에서는 sentencepiece를 사용하는 것을 추천합니다. sentencepiece 코드는 다음 링크로 들어가면 볼 수 있습니다. (이쯤되면 구글한테서 벗어날 수 없다는 것을 느낍니다) 실제로 sentencepiece를 사용했을 경우 훨씬 빠르게 tokenizer 모델을 학습할 수 있었습니다.

사실 BERT 논문에서 wordpiece 모델을 사용했던 이유 중에 하나가 다국어에 대해서 동작하게 만들고 싶었기 때문입니다. 하지만 당근마켓은 한국에서만 서비스를 하고 있어서 다국어를 고려할 필요가 없습니다. 이럴 때는 wordpiece보다는 mecab과 같이 한국어에 맞게 만들어진 형태소 분석기를 사용하는 것이 더 좋은 경우가 많습니다. mecab은 다음 링크를 통해 볼 수 있습니다. 설치하는 것이 살짝 까다로운데 가이드를 잘 따라가면 설치가 됩니다(mecab-ko-dic도 설치해야 합니다). Python으로 사용해야 하므로 mecab-python도 설치해야 합니다.

두 가지의 tokenizer를 모두 사용해서 BERT를 학습시키고 결과를 비교했습니다. 논문에 나온 것처럼 두 개의 tokenizer 모두 30000개의 token으로 사전을 구축했습니다. 결과적으로는 mecab을 tokenizer로 사용해서 학습했을 때 결과가 더 좋게 나와서 현재는 mecab을 사용하고 있습니다. 추후에 mecab 사전에 단어들을 추가하는 작업을 할 생각입니다.

데이터셋 만들기

BERT를 학습시키려면 tokenize된 데이터를 다음과 같이 구성해야 합니다. 문장을 2개를 \t로 붙이고 마지막에 \n을 붙여서 데이터를 구성하면 됩니다. 기존 BERT의 경우 위키피디아와 책을 데이터로 사용했기 때문에 문장을 나누기가 어렵지 않습니다.

하지만 트위터 글과 같은 성격을 띄는 당근마켓 게시글 데이터는 문장의 개념이 모호합니다. 따라서 게시글 텍스트 전체를 문장 단위로 자른 다음에 두 개의 덩어리로 나눕니다. 예를 들어 다음 그림과 같이 총 11개의 문장을 가지는 게시글을 그 아래와 같이 2개의 문장으로 나누는 것입니다. 이렇게 하면 각 문장이 충분한 길이를 가지게 되어서 BERT의 학습이 잘 됩니다.

모델 학습하기

데이터를 준비했다면 BERT를 학습할 차례입니다. BERT의 세부적인 내용에 대해서 알고 싶다면 논문을 읽는 것을 추천합니다. 물론 BERT는 Transformer 기반이기 때문에 Transformer를 모른다면 함께 읽어야 할 겁니다.

BERT 모델 학습에 대해 간단히 그림으로 살펴보자면 다음과 같습니다. 입력은 총 세 가지로 나뉩니다.

  • token embedding: tokenizing한 sequence에 2가지 작업을 합니다. 하나는 두 번째 문장을 50 %의 확률로 다른 문장으로 바꾸는 것입니다. 두 번째는 각 토큰을 15 %의 확률로 masking을 하는 것입니다.
  • sentence embedding: 모델이 첫 번째 문장과 두 번째 문장을 구분하도록 합니다.
  • position embedding: 입력 sequence 상에서의 위치에 대한 embedding입니다. Transformer에서도 position embedding을 사용했었는데 BERT에서는 학습하는 embedding으로 사용합니다.

학습은 총 두 가지의 task로 진행됩니다. 둘 다 unsupervised learning이라고 볼 수 있습니다. 이렇게 두 가지의 task로 학습함으로서 text에 대한 유의미한 정보를 학습할 수 있습니다. 또한 Pretrain이 끝나고 finetuning 할 때도 다양한 task에 대해 finetuning성능이 높아집니다.

  • Next Sentence classification: 두 번째 문장이 첫 번째 문장 다음에 오는 것이 맞는지 예측하는 문제입니다.
  • Masked Language Model: 무작위로 가린 token을 예측하는 문제입니다.

모델 크기

BERT 모델에서는 sequence의 길이로 512, Multi-Head Attention의 head 수 12, Layer 수 12 그리고 hidden 수는 768이었습니다. 하지만 구글에서와 같이 컴퓨팅 자원이 풍부하지 않기 때문에 모델의 크기를 줄여서 학습하기로 했습니다. 다음 표와 같이 당근마켓에서는 BERT 모델의 크기를 설정했습니다. 김준성님의 코드에서의 기본 설정이기도 합니다. sequence 길이를 163으로 정한 것은 당근마켓의 데이터를 분석한 결과를 적용한 것입니다.

모델 학습

BERT를 전체 데이터셋을 20 번 학습하도록 했습니다. 학습에 사용한 것은 Titan xp gpu 4개입니다. 4개의 GPU에서 분산학습 하는 것에 대해서는 이전 미디엄 글을 참고하시길 바랍니다. BERT 모델이 크기 때문에 논문에서 실험한 것 같이 256 batch size를 사용할 수 없었습니다. 저는 120 정도의 batch size를 사용해서 학습했습니다.

학습에는 총 1주일 정도의 시간이 걸렸습니다. (회사에서 해외 워크샵 간 사이에 워크스테이션은 일 시켜놨습니다). 학습이 끝났을 때 테스트 데이터에 대해 Next Sentence Prediction 정확도는 93 %가 나왔습니다. Masked LM에 대해서는 66 %의 정확도를 얻었습니다. 학습이 끝난 BERT 모델을 사용하면 문장에 대한 representation 즉, fixed size vector를 뽑을 수 있습니다. 각 게시글을 vector로 변환하고 가장 비슷한 문장을 나열해보는 식으로 정성적인 학습 결과를 확인했습니다.

게시글 필터링을 위한 데이터셋 만들기

당근마켓 내부에서는 사용자들이 올린 글을 질문 답변 글이라고 부르고 있습니다. 사전 학습한 BERT 모델을 지금까지 올라온 질문 답변 데이터 셋에 대해 Finetuning 하면 동네생활 게시글을 자동으로 판별하는 모델을 만들 수 있습니다. 이 task의 목적은 질문 답변 게시글 중에서 거래 관련 신고 내용이거나 거래 관련 글을 필터링 하는 것입니다. 모델을 학습하려면 task에 맞는 데이터 셋을 만들어야 합니다.

지금까지 일부 오픈한 지역에 올라온 동네생활 게시글은 8000개 정도입니다. 8000개 데이터에 대해 당근마켓 직원들은 수작업으로 다음 표처럼 분류했습니다. 총 8000개의 데이터 중에 6000개를 학습 데이터로 나머지 2000개를 테스트 데이터로 사용했습니다.

필터링 모델은 위와 같이 4가지의 카테고리로 게시글을 분류하도록 학습했습니다. 위 데이터 통계를 보면 동네생활에 해당하는 게시글에 비해 중고 거래 관련 신고나 중고 거래 게시글은 적습니다. 하지만 중고 거래 관련 신고 글이나 중고 거래 게시글은 당근마켓 DB에서 충분히 가져올 수 있습니다. 실제로 많이 올라오지 않는 글이기 때문에 현재 시점에서는 고려하지 않기로 했습니다. 또한 구인 구직에 해당하는 글은 모두 동네생활 관련된 글로 봤습니다. 실제로 동네생활에서 구인 구직글을 쓸 수 있도록 했습니다.

사용자가 중고 거래에서 안 좋은 경험을 하면 신고를 하게 되는데 현재까지 총 15,000개 정도의 신고 문의가 왔습니다. 중고 거래 관련 신고 글이 15,000개 정도이고 우리 동네 이야기는 7290 입니다. 동네생활에 올라온 중고 거래에 관련된 게시글이 495 인데 상대적으로 너무 적습니다. 당근마켓에는 기본적으로 중고 거래 게시글은 많습니다. 따라서 기존 중고 거래 게시글에서 데이터를 가져와서 중고 거래 게시글 카테고리의 데이터를 늘릴 수 있습니다.

하지만 실제로 데이터를 보면 질문 답변 게시글에서의 중고 거래 관련글과 실제 중고 거래 게시글은 차이가 있습니다. 다음 예시를 보면 많이 차이가 난다는 것을 알 수 있습니다. 따라서 데이터가 적더라도 중고 거래 게시글은 질문 답변으로 올라온 것만 사용하는 것이 좋습니다. 추후에 동네생활이 활성화되면 더 많은 데이터를 확보할 수 있습니다.


게시글 필터링 모델 학습하기

모델 학습하기

모델에 들어가는 입력은 게시글 단위입니다. 게시글 텍스트는 sentencepiece로 학습한 model을 그대로 사용하거나 mecab tokenizer를 사용합니다. BERT finetune 모델은 pretrain된 BERT 모델을 load 하고 그 뒤에 fully-connected layer를 붙입니다. 아래 코드를 보면 BERT pretrain 모델의 output 중에서 가장 첫 번째 output을 활용해서 모델 출력을 만들어내는 것을 볼 수 있습니다.

BERT finetune 모델의 입력은 token index list + segment index list 입니다. finetune 할 때는 masking을 하지 않습니다. Finetuning 단계에서는 두 개의 문장을 집어넣는 것이 아니기 때문에 BERT 모델에 들어가는 segment embedding은 모두 같은 embedding을 사용합니다.

필터링 모델을 위해서 만든 데이터셋의 카테고리 마다의 샘플 수가 일정하지 않기 때문에 PyTorch의 WeightedRandomSampler 을 사용합니다. Mini-batch를 sampling 할 때 카테고리 마다 다른 확률로 샘플링 하는 방법입니다. Weighted sampling 관련해서는 다음 글을 참고하시길 바랍니다.

weights와 data_size를 정하면 WeightedRandomSampler를 다음 코드와 같이 사용할 수 있습니다.

만든 학습 데이터에 대해서 BERT를 5 epoch 정도 finetune 했습니다. validation 데이터 셋에 대해 평균적으로 97 퍼센트 정도의 정확도가 나옵니다.

학습 결과 분석

8000개의 학습 데이터 중에 2000개를 테스트 데이터로 사용했습니다. 2000개의 테스트 데이터에 대해 학습한 모델로 동네생활 관련 게시글인지 아닌지 판단하는 것으로 테스트 했습니다. 학습이 얼마나 잘 됐는지에 대한 지표로 정확도를 사용할 수 있지만 정확도가 주는 정보는 한정적입니다. 따라서 추가적인 지표가 필요합니다. Recall, Precision, F1 score그리고 confusion matrix 가 주로 많이 사용하는 지표입니다. scikit learn에서 이러한 지표를 계산하는 함수를 제공합니다.

최종적으로 서버에 배포한 모델의 테스트 결과는 다음과 같습니다. 서비스에 들어갈 필터링 모델에게 중요한 것은 선의의 피해자를 최대한 줄이는 것입니다. 즉 원래는 우리 동네 이야기에 맞는 게시글을 올렸는데 모델이 잘못 판단해서 미노출 하는 경우를 이야기합니다. 이러한 실수를 얼마나 하는지 알 수 있는 지표가 recall 입니다. 목표는 recall을 높게 유지하면서 precision을 높이는 것 입니다.

학습 결과를 분석하면서 모델의 출력에 약간의 후 처리를 할 수 있습니다. 현재 학습한 모델의 경우 다음과 같은 후처리를 하고 있습니다. prediction이 0이나 1이나 3이면 동네 이야기가 아니라고 판단한 경우인데 이 때 softmax의 값이 0.7보다 낮으면 잘못 판단한 경우가 많았습니다. 따라서 동네 이야기가 아니라고 판단하는 threshold를 높여서 recall을 높였습니다.


게시글 필터링 모델 배포하기

학습한 필터링 모델을 배포하기 위해서는 일단 prediction 만을 위한 코드를 만들어야 합니다. 이런 식으로 간단하게 predict 만 하는 코드를 작성했습니다.

학습한 모델을 당근마켓 서비스에 적용하려면 요청을 주고 결과를 받을 수 있는 api가 필요합니다. Python에서 api를 만들 수 있는 가장 간단한 방법 중에 하나가 Flask를 사용하는 것입니다. Predict를 Flask에서 할 수 있도록 다음과 같이 코드를 작성했습니다.

make_model 메서드를 통해 trainer를 만들고 학습한 모델을 load 합니다. Flask에서는 app.run을 통해 웹서버를 구축할 수 있습니다. 이 때 host와 port를 지정하면 host:port를 통해 만든 서버에 접속할 수 있습니다. Flask에서는 여러 route를 만들 수 있는데 prediction을 위한 route를 만듭니다. @app.route를 통해 어떤 형태로 요청을 받을 지 정합니다. 요청을 json 형태로 줄 것이기 때문에 request.get_json()을 통해 요청을 받아옵니다. 요청이 오면 prediction을 하고 출력을 다시 json 형태로 반환합니다. 이 때, version 정보도 기록해서 어떤 모델의 결과인지 알 수 있도록 합니다.

local에서 간단히 python serving_flask.py를 실행하면 로컬 웹서버에 접근할 수 있습니다. 정상적으로 Flask가 실행되면 다음과 같이 출력됩니다.

로컬 서버에 요청은 다음과 같이 줄 수 있습니다.

이에 대해 다음과 같이 출력을 받을 수 있습니다. 결과가 정상적으로 잘 나오는 것을 확인했다면 이제 서버로 모델을 배포할 단계입니다.

모델은 AWS ECS로 배포했는데 그러려면 Docker container로 같은 기능을 수행하도록 만들어야 합니다. 서버에서 서빙할 때는 cpu만 사용하기 때문에 pytorch도 cpu 버전으로 설치합니다. Docker image를 build 할 때, 이미 pytorch cpu 버전을 설치한 이미지를 pull 합니다. docker image를 build하기 위해 사용한 Dockerfile은 다음과 같습니다. Tokenizer로 mecab을 사용하기 때문에 mecab 설치 부분도 들어있습니다. Base가 되는 docker image가 궁금하다면 anibali의 깃헙을 방문해보면 됩니다.

이 도커 파일로 build를 하고 docker run으로 구동했을 때 동일한 출력이 나와야 합니다. docker image를 build 했다면 AWS ECR에 업로드 할 수 있습니다. 업로드를 하고 나면 ECR repository에서 확인할 수 있습니다. ECR에 docker image를 upload하고 나면 ECS를 통해 모델 서빙을 할 수 있습니다.

ECR 을 통해 리소스를 할당하는 일반적인 방법은 console로 하는 것입니다. 하지만 console로 리소스를 생성할 경우 생성, 변경과 관리가 어렵다는 단점이 있습니다. Terraform은 이러한 리소스의 관리를 코드로 할 수 있도록 합니다. Terraform이 무엇인지 모른다면 다음 영상을 보는 것을 추천합니다.

실제 서비스에 적용되기 전에 관리자 페이지에 적용하는 과정을 통해 모델의 성능을 지속적으로 평가할 수 있습니다. ECR을 통해 서빙이 가능한 모델의 결과를 볼 수 있도록 관리자 페이지에 다음과 같이 적용했습니다. score는 모델이 얼마나 확신을 가지고 분류를 했는지에 대한 지표입니다.

이 글을 마치며

작년 한 해 동안 딥러닝 계에서 가장 핫했던 BERT를 실제 서비스에 적용하는 과정에 대해 정리해봤습니다. 구글에서와 같은 scale로 BERT를 제대로 학습시키지는 못했지만 상당히 만족스러운 성능을 얻을 수 있었습니다. 실제로 서빙하는 과정까지 설명을 했는데 다음 글에서는 적용한 모델의 성능을 개선해나가는 과정을 다루도록 하겠습니다.