티스토리 뷰

자연어처리와 인공지능으로 페이스북 글을 분류하기

*내가 구현한 첫 기계학습 모델. 부족한 게 많음.

현재 상황

페이스북의 어떤 그룹에는 여러 가지 글이 올라오는데, 그 중에 택시 동승자를 구하는 글들도 있다.
호옥시 내일(15일.금) 저녁 9시에 택시타고 **역 가실 분 있으신가요캐리어하나있어용

이런 글 들이다.


나는 여러 가지 글 중 이 글이 택시 동승자를 구하는 글인지 구분하는 classifier를 만드려고 한다.


사실 이미 있다.

하지만 그건 인공지능이 아니라 정규표현식을 통해 구분하는 함수라, 예상치 못한 형식이 나오면 제대로 구분하지 못한다.

(그래도 정규표현식을 이용한 분류기 정확도는 95% 이상이다)


이미 페이스북 그룹의 글들은 파싱한 상태이고, 정규표현식으로 구분한 정보도 있는 상태다

python의 django를 이용하여 db에 저장해놓은 상태이다.

db에서 잘 꺼내서 python 파일로 저장해뒀다.


목표 : 인공지능으로 택시 동승 글 분류하기

자 일단 하는 법을 모르니 인터넷을 주구장창 찾아봤다.
그 중 이번 글에 많은 참고를 했던 글이다.


다른 곳도 많이 참고했지만, 이 두 개의 글의 알고리즘을 제일 많이 참고했다.

우연히도 같은 곳이다.

감사합니다.


개요

원래는 RNN으로 문장을 분류하려고 했었다.
예전에 배웠던 것 중에 '자연어처리는 RNN이 좋다'는 것이 기억났기 때문이다. (4~5년 전?)
그런데 요새(1~2년 이내?)는 자연어도 CNN으로 하는 것 같았다.
그리고 위에 보듯 참고한 사이트에서도 CNN을 이용했기에 나도 그래보려고 했다.

원래 자연어처리에서 RNN이 쓰였던 이유는 순서가 있기 때문이었다.
국어는 읽는 순서가 오른쪽에서 왼쪽이니 그 흐름을 따라갈 수 있는 RNN이 장점이 있다.
그런데 단어 사이에 거리가 멀면 서로 영향을 거의 미치지 못해서 LSTM 같은 것이 나왔다.
하지만 이것도 복잡하고 등등 단점이 있었다.
그리고 Word2Vec, word to vector라는 게 나오기 시작했다.

간단히 설명하면, 단어를 벡터로 표현하는 것이다.
처음에는 BOW, bag of words, 모든 나온 단어에 인덱스를 다 부여했다.
위의 예시로는 호옥시:1, 내일:2, 15일:3, ... 같이 말이다.
그리고 one-hot vector로 바뀌면 단어가 벡터로 바뀐 게 된다.
호옥시:[1,0,0,...], 내일:[0,2,0,0...], 15일:[0,0,1,0...]

하지만 이런 식이면 동음이의어도 놓치고 단어와의 관계도 다 무시된다.
그리고 단어가 많아지면 vector의 크기도 어어엉어엉엄청 커진다.

그래서 앞뒤 단어와의 관계도 볼 수 있고 크기도 정해질 수 있는 방법이 고안되는데, 그게 Word2Vec이다.
사용자가 vector의 차원을 정할 수 있어서, 모든 단어가 n차원 vector로 표현될 수 있다.
one-hot이 아니라 [-0.24123, 0.2345121, 1.342, ...] 등으로 표시된다.
더 자세한 설명은 위의 블로그나 인터넷 검색...

정리1 : Word2Vec으로 단어를 벡터로 만들자


한국어는 word2vec을 하기 전에 전처리가 필요하다.

위의 예를 보면, 한국인들은 제대로 띄어쓰기를 안 하는 경향이 있다.

영어권은 제대로 띄어쓰기를 한다.

왜? 띄어쓰기를 안 하면 못 읽으니까.

여기서 한글의 우수성이 나온다. (세종대왕 존경해)

하지만 인공지능에서는 처리하기가 까다롭다. (세종대왕도 21세기의 인공지능까지는 예상 못하신 듯)


위의 예만 봐도, '택시타고'는 '택시'와 '타고'로 나뉘어야 한다.

띄어쓰기 단위로만 보면 '택시타고'가 하나이지만 그렇게 처리하면 안 될 것 같징?

마찬가지로 뒤에는... '있으신가요캐리어하나있어용'이 한 단어가 되면 안 될 것 같다.

그러니 이제부터 띄어쓰기를 잘 하자


전처리가 필요하다.

KoNLPy라는 멋진 툴을 사람들이 만들어놨다.

은전한닢이라는 멋진 툴도 사람들이 만들어놨다.

역시 외국인도 안 만드는 프로그램을 만드는 한국사람은 위대하다.

(예시가 잘 나온 페이지)


정리0 : 한국어는 전처리가 필요하다


word2vec은 단어와 얼마나 유사한지(similarity)를 계산할 수 있다.

벡터는 거리 계산이 가능하니까. 고등학교 수학에서 배웠듯!


그래서 택시 동승자를 구하는 글은 '택시'라는 단어와 굉장히 밀접할 것이라고 생각했다.

인터넷에 찾아보니 word2vec를 이용해서 (쉽게) 2 종류의 글로 분류하는 걸 찾지 못했다.

어쩌면 내가 찾았지만 못알아 먹겠어서 넘겼을 수도 있다.

하여튼 여기서부터는 내 창의력이 발휘됐다.


word2vec으로 할 수 있는 것 : 어떤 단어가 '택시'와 얼마나 유사한지의 값

input data : 페이스북에 올라온 글이 전처리를 거쳐서 쪼개진 단어들의 list

output data : binary(택시 동승 글이냐 아니냐)


흠... 머리를 이리굴리고 저리굴려서,


정리2 : 어떤 글의 단어들이 각각 '택시'와 이루는 similarity의 '합/평균'으로 분류하자


참고한 사이트에서는 두 가지가 아니라 7개로 글 종류를 분류했는데, 각 단어들이 7개 단어들과 이루는 similarity의 합을 비교해서 나누는 것이었다.

예를 들어 어떤 문장이 ['안녕', '하세요', '저', '는' '귀요미']이고 4개 분류가 '택시', '분실', '친목', '공지'라면

 similarity

 택시

분실

친목 

공지 

 안녕

0.01 

0.2

0.8

0.8

 하세요

0.2

0.2

0.5

0.55

저 

 0.22

0.4

0.3

0.6

는 

0.6

0.5

0.6

0.3

 귀요미

 0.01

0.01

0.2

0.02

 합

 0.94

1.31 

2.4

2.28 

 결과

 0

 1

 0


이런 원리를 썼다.


하지만 내 경우, binary이기 때문에 단순 합으로는 비교할 대상이 없다.

그래서 내 경우 '택시'의 similarity의 평균을 사용했다.


 어떤 글의 단어

 안녕

하세요 

 는

귀요미

평균 

 '택시'와 similarity

 0.01

0.2 

0.22 

0.6 

0.01 

 0.208


그래서 저 평균값인 0.208를 '안녕하세요 저는 귀요미'라는 글의 '택시 score'라고 할 것이다.

이게 얼마나 엄밀한지는 모르겠지만, 어느 정도 돌아가긴 했다.


그리고 저 택시 score로 logistic regression을 돌릴 것이다.


정리 3. logistic regression (binary classification)


참 장황했지만 이게 개요다.


개요 : 전처리 -> word2vec -> 주제어와 유사성 -> 평균(택시 score) -> logistic


1. 전처리 : tokenize 등

전처리에는 KoNLPy의 Mecab을 사용했다.
Mecab은 정확성은 떨어지지만 속도가 빠르다.
품사 하나하나까지 정확히 신경쓰지 않아도 된다면, 그리고 빠르게 결과를 확인하고 싶으면 Mecab을 사용하는 게 좋더라.
다른 것을 써보려하니 시간이 정말 많이 차이가 났다.
자세한 것은 KoNLPy 사이트 참고 (설치법도 참고!)

사용법은 이렇다.
from konlpy.tag import Mecab

mecab = Mecab()
mecab.morphs('안녕하세요. 저는 귀요미~')
# output : ['안녕', '하', '세요', '.', '저', '는', '귀요미', '~']

morphs가 아니라 pos를 쓰면 자세한 형태소 분석을 해주지만, 안 써도 될 것 같아서 쓰지 않았다.

noun을 쓰면 명사만 가져오니, 조사를 빼고 싶으면 유용할 것 같다.


보면 온점과 물결까지 분석됐는데, 빼고 싶으면 python의 re 패키지를 이용해서 한글만 남길 수 있다.

r'[ㄱ-ㅣ가-힣]' 이 한글의 정규표현식이다.

내 경우, html을 읽은 데이터이기 때문에 '\n'(띄어쓰기) 같은 것이 포함되어 있다.

그래서 다음과 같이 없애줬다.

str = re.sub('\n','',str)

str = re.sub('\r','',str)

str = re.sub('\u2028','',str)

result = mecab.morphs(str)

\u2028이라는 유니코드도 에러를 계속 만들어서 없앴다.


이 과정을 거치면 나한테는 2가지 데이터가 있다.

x : 페이스북 하나의 게시글이 전처리되어 쪼개진 단어(형태소)의 list (list 안에 list)

ex) [ ['안녕', '하', '세요', '저','는','귀요미'],

       ['그', '다음', '게시물', ...],

...]

y : 각 게시글이 택시 동승 글인지 여부를 나타낸 list (우습게도 boolean이 string으로 저장되어 있더라)

ex) [ 'False', 'True', 'True', ...]

2. Word2Vec : gensim

Word2Vec을 참 간편하게 지원하는 패키지가 있었다.
이게 없었다면 Tensorflow나 Pytorch에서 여러 명령어를 돌렸을테지만, gensim이 편하게 해줬다.

gensim 설치 방법
pip install -U gensim

사용법

from gensim.models import Word2Vec as w2v

model = w2v(tokenized_data, size=100, window=2, min_count=50, iter=20, sg=1)
# size:vector의 크기 / window:앞뒤 볼 단어 수 / min_count:출현빈도 50개 이상 단어만 분석 / iter = 학습 반복 횟수 / sg:CBOW(0) or skip-gram(1) / workers:사용할 cpu 개수 (나는 몰라서 defaul 값 사용)
tokenized_data가 x 데이터다.
2중 list 형태로 생겼다.
그럼 model에 w2v가 학습됐다.
참 간편하다.

참고로 출현빈도가 50개 이상의 단어만 썼기에, 드물게 나온 단어는 vector값이 존재하지 않는다.
한 번 나온 단어까지 분석하면 너무너무너무너무 많기 때문이다.
아, 내 경우 페이스북 게시글 수는 2800개 밖에 안된다.
하지만 그 속의 단어 수는... 생략.

제대로 됐는지 보고 싶다면?
model.wv.vocab() : 학습된 단어들이 나열된다
model.most_similar(positive='택시') : 택시와 가장 유사한 단어가 출력된다. topn 옵션에 숫자를 넣어주면 유사한 단어가 그 숫자만큼 출력된다.


데이터가 많아서 그때그때 학습시키기 어려우면 저장기능이 있다.

model.save('model') : model이라는 이름으로 저장. model이라는 파일이 생성됨

model = w2v.load('model') : model로 저장한 w2v모델을 불러옴. 참고로 w2v는 gensim.models.Word2Vec이다


머신러닝이 이렇게 간단하다.

알아서 다 해준다.


여기까지만 해도 그럴 줄 알았다.


3. similarity 구하기

이제는 각 게시물의 '택시 score'(이하 스코어)를 구할 것이다.
앞서 말했듯, 스코어는 각 단어와 '택시'라는 단어의, 유사성의 평균값을 사용할 것이다. (복잡)

코드는 다음과 같다.

def avg_set(sentences, y, model): #sentences = x

    avg_dist = []


    for i,j in enumerate(sentences):

        dist = []

        if not j:

# 페이스북 게시물이 단순한 공유 게시물일 경우, 내용이 존재하지 않을 수 있다

            avg_dist.append(0)

            continue

        else:

            for k in j:

                try:

                    dist.append(model.similarity('택시', k))

                except:

# 게시물에 포함된 단어가 드문 단어라서 model에 vector로 존재하지 않는 경우

                    continue

        if not dist:

# 단어와 유사성이 계산되지 못한 경우

            dist = [0]

        avg_dist.append(np.mean(dist))


    return avg_dist

빨간 글씨가 핵심이다.

나머지는 들러리.


'택시'와 게시글(j)의 각 단어(k)의 similarity를 구한뒤 평균 낸 것이다.

그래서 avg_dist가 각 게시물의 스코어 리스트이다.


대충 쭉 훑어보니 택시와 관계 없는 글의 경우 스코어가 0.1~0.2 정도 였는데, 관계가 있으면 스코어가 0.3 정도였다.

여기서 가능성을 보았다.


자, 그런데 내 경우엔 약간 보정해줄 것이 있었다.

게시물이 길어지면 길어질 수록, 다양한 단어가 나와서 스코어가 낮게 나오는 경향이 있었다.

그래서 '택시'와 유사성이 높은 10개 단어의 평균을 스코어로 했다.


def most_avg_set(sentences, y, model):

    avg_dist = []

    for i,j in enumerate(sentences):

        dist = []

        if not j:

            avg_dist.append(0)

            continue

        else:

            for k in j:

                try:

                    dist.append(model.similarity('택시', k))

                except:

                    continue

        if not dist:

            dist = [0]

        if len(dist) > 10:

            dist = sorted(dist, reverse=True)[:10]

        avg_dist.append(np.mean(dist))

    return avg_dist

파란 부분만 다르다.

'택시'라는 단어와 유사성이 계산된 값이 10개 이상일 때, 유사성이 높은 10개의 평균만 따진다.

이렇게 해도 될런지는 잘 모르겠지만, 쉽게 생각난 게 이거다.

다른 방법을 생각해본다면, 긴 글은 가중치를 부여하는 방법도 있을 수 있겠는데 복잡해질 것 같았다.


여기서 내가 지금까지 가지고 있는 값을 정리해보자.

x (avg_dist) : 각 게시글의 스코어

y : 각 게시글이 택시글인지 여부


앞서 y가 'False' 또는 'True'라는 string으로 이루어진 list라고 했는데, 앞으로 편하게 쓰게끔 0과 1로 바꿀 것이다.

y = [int(i=='True') for i in y] # 'True'면 1, 나머지면 0


4. logistic regression : pytorch

난 파이토치를 처음 쓴다.
지금껏 텐서플로우를 썼기 때문이다.
그런데 파이토치가 쉽다는 동영상을 오늘 봤다.
그냥 써도 되는데 그냥 파이토치를 써봤다. 그냥.

파이토치 설치법
여기에 들어가서 자신의 환경을 선택하면 명령어를 줌.
완전 간편.

설치를 했으면 사용해야지.

텐서플로우 개념을 대충 알고 있었기에 예제를 보고 쓰려는데, 예제라고 나오는 것들이 예전 자료다.

그래서 실행하면 "요즘 걸로 좀 바꿔!" 한다.

일단 돌아가긴 해서 놔두는데, 자세한 건 직접 공부해보고 바꿔보세요.


내 경우 스코어는 1차원이고 나오는 값도 1차원이다.

ex) 스코어 0.3 -> 1

스코어 0.1 -> 0


그러므로 input data와 output data는 둘다 1차원이다.


import torch


class MyModel(torch.nn.Module):

# 사실 이거 뭔지 잘 모른다. 사용자 설정 값인 경우 이렇게 override하는 것 같은데 default를 써도 괜찮을 것 같다.

    def __init__(self):

        super(MyModel,self).__init__()

        self.linear = torch.nn.Linear(1,1)

# 앞이 input dimension, 뒤가 output dimesion인 듯


    def forward(self,x):

        y_pred = torch.sigmoid(self.linear(x))

        return y_pred


x_tensor = torch.Tensor([[i] for i in x[:int(len(x)*0.8)]]) #input training set (전체의 80%)

y_tensor = torch.Tensor([[i] for i in y[:int(len(x)*0.8)]]) #output training set (80%)


md = MyModel()

criterion = torch.nn.BCELoss(reduction='mean')

optimizer = torch.optim.SGD(md.parameters(), lr = 0.08)


for epoch in range(300000):

    y_pred = md(x_tensor)

    loss = criterion(y_pred, y_tensor)

    if (epoch%30000 == 0):

        print(epoch, loss.item())


    optimizer.zero_grad()

    loss.backward()

    optimizer.step()

텐서플로우든 파이토치든 변수는 node의 형태를 띄기 때문에, 바로 사용할 수 없다.

파이토치는 torch.Tensor 객체로, 텐서플로우는 tf.Variable 형태로 바꿔줘야 한다.

둘 다 가장 바깥에 list가 하나 더 있어야 한다. (2중 list 이상이어야 한다)

무슨 소리냐, 현재 x 데이터가 [0.324, 0.19232, 0.2241, ...] 형태인데, Tensor에 들어가면 [ [0.324], [0.19232], [0.2241], ... ] 형태로 바뀌어야 한다는 것이다.

그래서 x_tensor를 저런 식으로 바꾼 것이다.

y_tensor도 그렇다.


criterion이 loss 함수이다.

BCE가 binary cross entropy이다.

SGD는 optimizer 중 하나이다.


epoch이 300000인데, 데이터가 작아서 그런지, 그정도 되어야 loss가 변동이 없어진다.

저렇게 하면 training은 끝난다.


# 제대로 예상하는지 값 하나 출력

test_set = torch.Tensor([x[-2]]) # 스코어 하나 집어 넣음

print(raw_x[-2])

#게시글이 출력된다

print(y[-2], x[-2], md(test_set).item())

# md(test_set).item()이 예상 값을 출력해줌. 0.5 이상이면 True


# accurate 계산

acc_mat = []

for idx in range(int(len(x)*0.8), len(x)): # training set에 사용하지 않은 test set 20%

    test_set = torch.Tensor([x[idx]])

    acc_mat.append(int((md(test_set).item()>0.5)==y[idx]))

# 예측이 맞으면 1, 아니면 0

print(np.mean(acc_mat))


정확도가 97% 정도 나온다.

epoch나 스코어 평균 갯수 등을 조절해서 얻은 값이다

와우, 이 정도면 예상보다 잘 나왔다.

변수를 약간씩 바꿔주면 더 좋은 결과가 나올 것 같다.


중간 중간에 대략 계산하거나 설정해준 것이 있어서 더 정확하고 엄밀하게 하면 (시간은 걸리겠지만) 더 잘 나올 것 같다.

전처리도 사실 엉망으로 되긴 했다.

고유명사가 다 쪼개져서 이상하게 되기도 했다.

그래도 이정도면 만족한다.

게다가 첫 실습인데!


언젠가 정규표현식으로 분류하는 지금의 분류기를, 인공지능으로 바꿔도 될 것 같다.



글이 장황해져서 가독성이 떨어질 것 같지만, 최대한 자세하게 쓰려고 노력했음 ㅠ

댓글