minhui study

MLP로 텍스트 분류하기 본문

Python/챗봇

MLP로 텍스트 분류하기

minhui 2020. 7. 27. 03:32

MLP로 텍스트 분류하기

MLP(Multi Layer Perception, 다층 퍼셉트론) 입력층과 출력층 사이에 각각 전체 결합하는 은닉층을 넣은 뉴럴 네트워크이다.

여기서 중요한 점은 텍스트 데이터를 숫자로 표현할 수 있는 벡터로 변환한다는 것이다. 머신러닝 프레임워크는 글을 그대로 입력할 수 없으므로 텍스트 데이터를 숫자로 변환해야 한다. 또한 텍스트 데이터는 이미지 데이터와 다르게 길이가 다른데 이를 고정된 길이의 벡터로 어떻게 변환할 수 있는지 살펴보자

 

즉, 가장 기본적인 형태의 인공신경망 구조이며, 하나의 입력층(input layer), 하나 이상의 은닉층(hidden layer),그리고 하나의 출력층(output layer)로 구성된다. MLP는 층의 갯수(depth)와 각 층의 크기(width)로 결정된다.

 

https://buomsoo-kim.github.io/keras/2018/04/21/Easy-deep-learning-with-Keras-2.md/

위 그림은 MLP에서 뉴런의 개수는 다음과 같다.

 

- 입력층의 뉴런 개수 : 3

- 은닉층의 뉴런 개수 : 4

- 출력층의 뉴런 개수 : 2

 

 

https://buomsoo-kim.github.io/keras/2018/04/21/Easy-deep-learning-with-Keras-2.md/

위 그림은 MLP에서 뉴런의 개수는 다음과 같다.

 

- 입력층의 뉴런 개수 : 3

- 1번째 은닉층의 뉴런 개수 : 4

- 2번째 은닉층의 뉴런 개수 : 4

- 출력층의 뉴런 개수 : 1

 

텍스트 데이터를 고정 길이의 벡터로 변환하는 방법

텍스트를 벡터 데이터로 변환하는 기본적인 방법은 단어 하나 하나에 ID를 부여하고 그러한 ID의 출현 빈도와 정렬 순서를 기반으로 벡터를 만드는 방법이다. 이번에는 단어의 정렬 순서는 무시하고, 출현 빈도만 사용할 것이다.

* Bow(Bag-of-words) : 글에 어떠한 단어가 있는지를 수치로 나타내는 방법

 

예를 들어 "몇 번을 쓰러지더라도 몇 번을 무너지더라도 다시 일어나라"라는 문장을 BoW로 나타내보자.

1. 형태소 분석

쓰러지다 무너지다 다시 일어나다

 

2. 각 단어에 ID 부여(okt.pos("몇 번 ... 일어나라", stem=True, norm=True)를 실행했을 때의 결과)

형태소 쓰러지다 무너지다 다시 일어나다
ID 1 2 3 4 1 2 3 5 6 7

 

3. 단어의 출현 횟수

형태소 쓰러지다 무너지다 다시 일어나다
ID 1 2 3 4 5 6 7
출현 횟수 2 2 2 1 1 1 1

이렇게 하면 단어의 출현 횟수를 기반으로 문장을 나타낼 수 있고 어떠한 문자이라도 고정 길이의 데이터로 나타낼 수 있게 된다.

 

 

 

텍스트 분류하기

[1] 텍스트에서 불필요한 품사를 제거한다.

[2] 사전을 기반으로 단어를 숫자로 변환한다.

[3] 파일 내부의 단어 출현 비율을 계산한다.

[4] 데이터를 학습시킨다.

[5] 테스트 데이터를 넣어 성공률을 확인한다.

 

이번 실습에서는 신문 기사와 카테고리를 학습시켜 모델을 마들고 해당 모델을 사용해 새로운 신문 기사의 카테고리를 파악하는 예제를 만들지만 국내 신문을 무단 전재가 금지되어 있으므로 참고한 책에서 제공해주는 여러 카테고리와 관련된 신문 기사 1만개를 추출하고 형태소 분석을 수행한 파일을 이용할 것이다.

 

 

< mlp.py>

import os, glob, json
root_dir = "./newstext"
dic_file = root_dir + "/word-dic.json"
data_file = root_dir + "/data.json"
data_file_min = root_dir + "/data-mini.json"

# 어구를 자르고 ID로 변환하기 ---(※1)
word_dic = { "_MAX": 0 } #"_MAX " : n 에서 n은 단어들의 ID들 중 가장 큰 것에 +1
def text_to_ids(text):
    text = text.strip()
    words = text.split(" ") # 공백을 기준으로 텍스트 나누기
    result = []
    for n in words:
        n = n.strip() # 텍스트에서 나눠진 단어 양끝에 붙어있는 \n과 공백 제거해주기
        if n == "": continue # 아무것도 없으면 continue
        if not n in word_dic: # 만약 word_dic에 없는 단어라면
            wid = word_dic[n] = word_dic["_MAX"] # 단어에 ID부여
            word_dic["_MAX"] += 1 #단어에 ID부가한 후 1추가
            print(wid, n)
        else: # word_dic에 이미 있는 단어라면
            wid = word_dic[n]
        result.append(wid)
    return result

# 파일을 읽고 고정 길이의 배열 리턴하기 ---(※2)
def file_to_ids(fname): #파일 하나를 열어 읽어와 text_to_ids에 넘긴다.
    with open(fname, "r", encoding='UTF-8') as f:
        text = f.read()
        return text_to_ids(text)

# 딕셔너리에 단어 모두 등록하기 --- (※3)
def register_dic():
    files = glob.glob(root_dir+"/*/*.txt.wakati", recursive=True) #glob함수: 조건에 맞는 파일명 리스트 형식으로 반환
    for i in files:
        file_to_ids(i)

# 파일 내부의 단어 세기 --- (※4)
def count_file_freq(fname):
    cnt = [0 for n in range(word_dic["_MAX"])] #0 ~ 마지막 단어 ID
    with open(fname,"r", encoding='UTF-8') as f:
        text = f.read().strip()
        ids = text_to_ids(text)
        for wid in ids:
            cnt[wid] += 1
    return cnt

# 카테고리마다 파일 읽어 들이기 --- (※5)
def count_freq(limit = 0):
    X = []
    Y = []
    max_words = word_dic["_MAX"]
    cat_names = []
    for cat in os.listdir(root_dir):
        cat_dir = root_dir + "/" + cat
        if not os.path.isdir(cat_dir): continue #dir이 없으면 패스~
        cat_idx = len(cat_names)
        cat_names.append(cat)
        files = glob.glob(cat_dir+"/*.txt.wakati")
        i = 0
        for path in files:
            print(path)
            cnt = count_file_freq(path)
            X.append(cnt) # 파일 내 단어 세기
            Y.append(cat_idx) # 카테고리(dir) 인덱스
            if limit > 0: # data_mini를 만들 때는 각 카테고리별로 20개씩만 추출하므로 limit로 제한
                if i > limit: break
                i += 1
    return X,Y
# 단어 딕셔너리 만들기 --- (※5)
if os.path.exists(dic_file): # 만약 word-dic.json이 있다면
    word_dic = json.load(open(dic_file,encoding='UTF-8')) # 그 파일을 열어 읽어오기
else:
    register_dic()
    json.dump(word_dic, open(dic_file,"w",encoding='UTF-8')) # 없으면 파일 열어 쓰기

# 벡터를 파일로 출력하기 --- (※6)
# 테스트 목적의 소규모 데이터 만들기
X, Y = count_freq(20) #6개 카테고리에서 각 각 20개씩 추출
json.dump({"X": X, "Y": Y}, open(data_file_min,"w",encoding='UTF-8')) #data-mini.json파일에 데이터 쓰기
#{X:[ [기사 1의 단어 베열] , [기사 2의 단어배열], [기사 3의 단어 배열] .....},Y:[기사1의 카테고리 , 기사2의 카테고리 .......] 이런식으로 표현 시켜준다. 
# 전체 데이터를 기반으로 데이터 만들기
X, Y = count_freq()
json.dump({"X": X, "Y": Y}, open(data_file,"w",encoding='UTF-8'))
print("ok")

 

 

아래 사진을 보면 100~105 총 6개의 카테고리에 형태소로 구분한 여러 기사 내용 텍스트들이 있다.

 

 

결과적으로 이 프로그램을 실행하면 newstext 폴더 안에 텍스트들의 모든 단어들이 등록된 "word-dic.json"파일과 그 파일을 바탕으로 6개의 카테고리에서 각각 20개씩만 추출해서 120개의 파일로만 처리한 "data-mini.json"파일과 모든 데이터를 대상으로 처리한 "data.json"파일이 생성되는 걸 알 수 있다.

 

 

"word-dic.json"파일을 메모장으로 열어보면 안에 각 단어와 할당된 id가 쌍으로 저장되어 있는 것을 볼 수 있다.

또한, MAX의 id를 보면 마지막 단어의 id인 56680보다 1이 큰 56681인 것도 확인할 수 있다.

word-dic.json

 

 

 

MLP로 텍스트 분류하기

그럼 준비가 끝났으니 이제 본격적으로 MLP로 텍스트를 분류해보자

 

<mlp2.py>

from keras.models import Sequential
from keras.layers import Dense, Dropout, Activation
from keras.wrappers.scikit_learn import KerasClassifier
from keras.utils import np_utils
from sklearn.model_selection import train_test_split
from sklearn import model_selection, metrics
import json
max_words = 56681 # 입력 단어 수: word-dic.json 파일 참고
nb_classes = 6 # 6개의 카테고리
batch_size = 64 
nb_epoch = 20

# MLP 모델 생성하기 --- (※1)
def build_model():
    model = Sequential()
    model.add(Dense(512, input_shape=(max_words,)))
    model.add(Activation('relu'))
    model.add(Dropout(0.5))
    model.add(Dense(nb_classes))
    model.add(Activation('softmax'))
    model.compile(loss='categorical_crossentropy',
        optimizer='adam',
        metrics=['accuracy'])
    return model

# 데이터 읽어 들이기--- (※2)
data = json.load(open("./newstext/data-mini.json")) 
#data = json.load(open("./newstext/data.json"))
X = data["X"] # 텍스트를 나타내는 데이터
Y = data["Y"] # 카테고리 데이터

# 학습하기 --- (※3)
X_train, X_test, Y_train, Y_test = train_test_split(X, Y)
Y_train = np_utils.to_categorical(Y_train, nb_classes)
print(len(X_train),len(Y_train))
model = KerasClassifier(
    build_fn=build_model, 
    nb_epoch=nb_epoch, 
    batch_size=batch_size)
model.fit(X_train, Y_train)

# 예측하기 --- (※4)
y = model.predict(X_test)
ac_score = metrics.accuracy_score(Y_test, y)
cl_report = metrics.classification_report(Y_test, y)
print("정답률 =", ac_score)
print("리포트 =\n", cl_report)

먼저 첫번 째 data-mini.json으로 테스트했을 때는 정답률이 75% 나온다.

data.json으로 변경해서 실행보면 90%의 확률로 데이터를 분류한다. 

 

[ ※1 ] : Keras로 MLP모델을 구축한다. Keras에서 Dense()는 전결합 신경망을 나타낸다. Dense()의 첫 번째 매개변수는 출력할 차원을 타나내고 input_shape는 입력 데이터의 차원을 나타낸다. 

현재 모델에서는 Dense(512)가 입력층이며, 뒤의 Dense(nb_classes)가 출력층이다. 각 층 사이에서 활성화 함수를 넣어서 이전 층의 계산을 단순화하면 각 성분의 성능을 높일 수 있다. 또한 드랍아웃을 사이에 넣어 신경망을 최적화했다.

이러한 것들을 활용해 신경말의 자유도를 강제하면 성능을 높일 수 있으며 초과 학습을 피할 수 있다.

 

*Sequential Model API를 통해 레이어를 선형으로 연결하여 구성하고 add()메소드를 통해서 쉽게 레이어를 추가할 수 있다.

model = Sequential()만 쓰면 현재 이 모델은 레이어가 하나도 추가되어 있는 않은 상태인 것이다.(빈 모델)

 

*Dense 레이어는 입력과 출력을 모두 연결해준다. 예를 들어 입력 뉴런이 4개, 출력 뉴런이 8개가 있다면 연결선은 총 32개(4*8=32)이다. 각 연결선에는 가중치가 포함되어 있는데 이 가중치가 나타내는 의미는 연결강도라고 볼 수 있다. 즉, 연결선이 32개이므로 가중치도 32개라고 할 수 있다.

가중치가 높을수록 해당 입력 뉴런이 출력 뉴런에 미치는 영향이 크고, 낮을수록 미치는 영향이 적다.

.add(Dense(512, input_shape=(max_words,)))는 입력층이며 첫 번째 레이어에서 모델은 (max_words, )형태로 배열을 인풋으로 받고 ( ,512)형태를 출력한다.

.add(Dense(nb_classes))는 출력층이며 즉, 카테고리 6개 중 하나로 출력된다.

 

*Activation은 각 층에서 계산한 결과에 적용하는 함수로 활성화 함수라고 한다. 간단하게 어떤 역할을 하는지만 알아보자.add(Activation(relu))에서 ReLU는 rectified linear unit(꺾인 선형)의 줄임말로 값이 음수이면 다음 층부터는 영향을 전혀주지 않게 만드는 활성 함수이다. 한편 값이 양수이면 늘 기울기로 1을 받게 되므로 여러 번 합성한다고 값이 작아지지 않기 때문에 깊게 층을 쌓는 데 좋다. .add(Activation(relu))에서 Softmax는 값들을 합이 1이고 늘 양수인 확률분포로 표준화하는 함수이다. (값의 상대적인 순위는 변하지 않는다.)

 

*Dropout을 하는 이유는 overfitting(과적합)때문이다.

아래 그래프와 같이 훈련 data에 있어서는 높은 정화도를 내지만 실제 test data에 있어서는 높은 예측률을 내지 못하게 되는 현상이다. 

 

https://doorbw.tistory.com/147

이걸 해결하기 위해서는 무슨 방법이 있을까?

1) More training data

2) Reduce the number of features

3) Regularization

그리고 dropout이라는 방법이 있다. 쉽게 말해서 아래 그림에서 왼쪽 그림과 같은 모델에서 몇 개의 연결을 끊어서, 즉 몇 개의 노드를 죽이고 남은 노드들을 통해서만 훈련을 하는 것이다. 이때, 죽이는, 쉬게하는 노드들은 랜덤하게 선택한다.

https://doorbw.tistory.com/147

위 코드에서 Dropout(0.5)라고 되어 있는데 이는 즉 50%의 노드들이 랜덤하게 훈련되게 할 것이라는 뜻이다. 

 

*Compilation은 모델을 학습하기 전에 complie 메서드를 이용하여 학습 절차를 구성하는 과정으로 이 때 3가지 인수를 지정한다.

1) optimizer : 존재하는 최적화기의 이름 문자열이거나 Optimizer 객체 이름을 지정한다.

2) loss : 모델이 최소화를 하게 도와주는 객체로 존재하는 loss 함수 이름 문자열이나 객체 함수 이름을 지정한다.

3) metrics : 어떤 분류 문제에서는 metrics=['accuracy']처럼 설정한다. 존재하는 측정 기준의 이름 문자열이나 사용자 측정 함수를 지정한다.

model.compile(loss='categorical_crossentropy',  optimizer='adam',  metrics=['accuracy']) 

-> 다중 분류 문제

 

[ ※2 ] : 데이터를 읽어 들인다. X가 텍스트를 나타내는 데이터이고 Y가 카테고리를 나타내는 레이블이다.

{X:[ [기사 1의 단어 베열] , [기사 2의 단어배열], [기사 3의 단어 배열] .....},Y:[기사1의 카테고리 , 기사2의 카테고리 .......]

 

[ ※3 ] : 테스트 데이터와 훈련 데이터를 나누고 머신러닝을 수행한다.

 

[ ※4 ] : 학습 결과를 테스트 데이터로 평가한다.

 



<참고 자료>

파이썬을 이용한 머신러닝, 딥러닝 실전 개발 입문(개정판)

 

Comments