현맨틀 프로젝트

 

기존 서비스 중인 꼬맨틀의 단어 유사도 문제를 해결하기 위해 시작한 프로젝트입니다.

꼬멘틀의 경우 영어 버전인 Sementle을 한국어화 한 버전으로 단어를 입력하는 경우 정답 단어와의 유사도를 보여줍니다.

png

기본적으로 꼬맨틀은 word embedding을 기반으로 벡터의 유사도를 비교하는 방식을 사용하고 있습니다. 그렇다면 단어 유사도가 낮게 나오는건 사전 학습된 벡터의 값에 문제가 있는게 아닐가 하는 생각이 들었고 이 프로젝트를 시작하게 되었습니다.

꼬맨틀이 사용한 한국어 모델

꼬맨틀은 FastText라는 라이브러리를 사용하였습니다.

FastText는 facebook에서 제작한 모델로 위키피디아에 내용을 크롤링 하여 157개국의 언어로 제공 중입니다. (한국어도 제공하고 있습니다.)

FastText 보러가기

다만 2017년 기준으로 작성된 자료이기도 하고 현재 기준으로 위키피디아보다 나무위키와 같은 타 사이트의 데이터가 pre-train 하기에 더 좋다고 판단하여 이 부분을 수정하기로 하였습니다.

추가적으로 이렇게 하게되면 학습할 때 없던 단어(OOV)에 대해서도 조금 더 좋은 결과 값을 도출할 수 있지 않을까 하는 기대감이 있었습니다.

나무위키 데이터 가져오기

한국어 관련한 데이터베이스 중 나무위키가 가장 방대한 자료(인터넷 언어 등등)를 가지고 있다고 생각이 되었고 해당 데이터베이스를 이용하여 사전 학습을 진행하기로 하였습니다.

나무위키 데이터베이스 다운로드

위 사이트에서 나무위키 데이터베이스를 다운로드 할 수 있습니다.

데이터의 압축을 풀고 json을 로드 해보면 줄바꿈 없이 쭉 구성된 텍스트를 볼 수 있습니다 (8.3GB…)

word2vec 모델 제작하기

우선 json 형식으로 되어 있는 데이터를 읽어 txt 파일로 제작해줍니다.

def load_and_write_content(filename, filename2):
    count=0
    file = codecs.open(filename2, 'w', encoding='utf-8', errors='ignore')
    with open(filename, 'r') as fd:
        for item in ijson.items(fd, 'item'):
            count+=1
            file.write('[[제목]]: ')
            file.write(item['title'])
            file.write('\n')
            file.write('[[내용]]: \n')
            file.write(item['text'])
            file.write("\n")
    file.close()
    print('contents count=', count)

이렇게 하면 제목과 내용으로 구분된 텍스트 파일이 생기게 됩니다.

그러면 이제 해당 파일을 불러서 명사, 동사, 형용사와 같이 필요한 단어로만 분류합니다.

분류할 때는 Okt를 사용해서 분류하는데, 이 때 메모리 초과가 나는 경우가 많기에 20MB씩 스트링을 처리해서 아래와 같은 코드로 생성해줍니다.

def process_block(linepart):
    twitter = Okt()
    malist = twitter.pos(linepart)
    tokens = [word.strip() for word, pumsa in malist if pumsa not in ['Josa', 'Eomi', 'Punctuation']]
    return " ".join(tokens)

def make_wakati(f1, f2, f3):
    file = codecs.open(f1, 'r', encoding='utf-8')
    text = file.read()
    lines = text.split('\r\n')
    blocksize = 1000 * 1000 * 20

    print('making wakati start')
    print('lines count=', len(lines))
    t1 = time.time()

    all_blocks = []
    for line in lines:
        linelen = len(line)
        if linelen == 0:
            continue
        blockcnt = (linelen // blocksize) + (1 if linelen % blocksize != 0 else 0)
        for li in range(blockcnt):
            if li == blockcnt - 1:
                linepart = line[li * blocksize:]
            else:
                linepart = line[li * blocksize:(li + 1) * blocksize]
            all_blocks.append(linepart)

    print('total blocks:', len(all_blocks))
    with Pool(processes=cpu_count()) as pool:
        results = pool.map(process_block, all_blocks)

    with open(f2, 'w', encoding='utf-8') as fp:
        for r in results:
            fp.write(r)
            fp.write('\n')

    t2 = time.time()
    print('making wakati end time=', t2 - t1)

자 이제 형태소별로 구분이 완료 되었으니 이걸로 word2vec 모델을 제작해봅시다.

200차원 벡터를 생성하는 코드로 아래와 같은 코드를 사용하면 제작할 수 있습니다.

def make_word2vec(f1, f2):
    data = word2vec.LineSentence(f1)
    model = word2vec.Word2Vec(data, vector_size=200, window=10, hs=1, min_count=5, sg=1, workers=6)
    model.save(f2)
    print('end word2vec')

평균 메모리 사용량 : 78GB, 소요시간 33337초(9.2 시간)

이렇게 학습시킨 뒤 한국과 가장 비슷한 단어를 찾아보니 아래와 같은 결과가 나왔습니다.

png

word2vec는 의미의 유사도를 비교하기보다 문맥 기반으로 학습을 진행합니다. 나무위키 특성상 연도에 대한 내용이 많이 나오기 때문에 발생하는 문제로 해당 문제를 없애기 위해서는 전처리 과정에서 아래와 같이 코드 수정이 필요합니다.

def process_block(linepart):
    twitter = Okt()
    malist = twitter.pos(linepart)
    tokens = [word.strip() for word, pumsa in malist if pumsa not in ['Josa', 'Eomi', 'Punctuation', 'Number']]
    return " ".join(tokens)

이렇게 수정하게 되면 전처리 과정에서 숫자를 제외하기 때문에 조금 더 정확한 값을 뽑아낼 수 있습니다.

png

결과를 보면 기존과 달리 조금 더 한국과 관련된 내용의 단어가 높은 유사도로로 나오는 것을 볼 수 있습니다.

제작한 모델 적용하기

그러면 이제 모델을 적용해봅시다.

기존 꼬맨틀의 경우 사전에 없는 단어는 필터링을 하여 제공해주지 않습니다. 하지만 이렇게 하면 유사도가 높은 외래어, 신조어 등은 나오지 않아 재미를 반감시킬거 같았고 모든 단어에서 유사도를 검사 가능하도록 수정하였습니다.

def is_hangul(text) -> bool:
    return bool(re.match(r'^[\u3130-\u318F\uAC00-\uD7A3]+$', text))

def load_dic(path: str) -> Set[str]:
    rtn = set()
    with open(path, 'r', encoding='utf-8') as f:
        for line in f.readlines():
            word = line.strip()
            word = unicodedata.normalize('NFC', word)
            if is_hangul(word):
               rtn.add(word)
    return rtn

def blocks(files, size=65536):
    while True:
        b = files.read(size)
        if not b: break
        yield b

def count_lines(filepath):
    with open(filepath, "r", encoding="utf-8", errors='ignore') as f:
        return sum(bl.count("\n") for bl in tqdm(blocks(f), desc='Counting lines', mininterval=1))

model = word2vec.Word2Vec.load("data/namu.model")

# 단어 리스트 불러오기 (기존 코드 활용)
normal_words = load_dic('data/ko-aff-dic-0.7.92/ko_filtered.txt')

connection = sqlite3.connect('data/valid_guesses.db')
cursor = connection.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS guesses (word text PRIMARY KEY, vec blob)")

valid_nearest = []
valid_nearest_mat = []

for word in tqdm(normal_words, desc="Embedding with Word2Vec"):
    vec = model.encode(word)
    cursor.execute("INSERT OR REPLACE INTO guesses VALUES (?, ?)", (word, pickle.dumps(vec)))
    valid_nearest.append(word)
    valid_nearest_mat.append(vec)

connection.commit()
connection.close()

이렇게 수정하게 되면 기존에 사용하던 모델이 아닌 제작한 모델을 기반으로 정해진 사전 데이터에 대한 벡터 값을 추출해내 db에 저장해둘 수 있습니다.

적용 후 github action을 이용해 docker hub에 배포한 뒤 서버를 실행해 보았습니다.

png

오늘의 정답은 당하다였고 당하여, 받다, 당해, 처참히와 같이 비슷한 단어들이 순위에 있는 모습을 볼 수 있습니다.

이런 결과를 통해 모델이 문맥상 비슷한 단어들을 잘 학습했음을 확인할 수 있었습니다.

소스코드 링크

https://github.com/Choih0401/hyunmantle