by museonghwang

어텐션 메커니즘(Attention Mechanism) 이해하기

|

  1. Seq2Seq 모델의 한계
  2. 어텐션(Attention)의 아이디어
  3. 어텐션 함수(Attention Function)
  4. 닷-프로덕트(내적) 어텐션(Dot-Product Attention)
    • 4.1 어텐션 스코어(Attention Score)를 구한다.
    • 4.2 소프트맥스(softmax) 함수를 통해 어텐션 분포(Attention Distribution)를 구한다.
    • 4.3 각 인코더의 어텐션 가중치와 은닉 상태를 가중합하여 어텐션 값(Attention Value)을 구한다.
    • 4.4 어텐션 값과 디코더의 $t$ 시점의 은닉 상태를 연결한다.(Concatenate)
    • 4.5 출력층 연산의 입력이 되는 $\tilde{s_t}$ 를 계산합니다.
    • 4.6 $\tilde{s_t}$ 를 출력층의 입력으로 사용합니다.
  5. 바다나우 어텐션(Bahdanau Attention)
    • 5.1 어텐션 스코어(Attention Score)를 구한다.
    • 5.2 소프트맥스(softmax) 함수를 통해 어텐션 분포(Attention Distribution)를 구한다.
    • 5.3 각 인코더의 어텐션 가중치와 은닉 상태를 가중합하여 어텐션 값(Attention Value)을 구한다.
    • 5.4 컨텍스트 벡터로부터 $\tilde{s_t}$ 를 구합니다.
  6. 다양한 종류의 어텐션(Attention)



1. Seq2Seq 모델의 한계

seq2seq 모델은 인코더 에서 입력 시퀀스를 컨텍스트 벡터라는 하나의 고정된 크기의 벡터 표현으로 압축 하고, 디코더이 컨텍스트 벡터를 통해서 출력 시퀀스를 만들어 냈습니다. 하지만 이러한 RNN에 기반한 seq2seq 모델에는 크게 두 가지 문제 가 있습니다.

  1. 입력 시퀸스의 모든 정보를 하나의 고정된 크기의 벡터(컨텍스트 벡터)에 다 압축 요약하려 하다 보니 정보의 손실이 생길 수밖에 없습니다. 특히 시퀸스의 길이가 길다면 정보의 손실이 더 커집니다.
  2. RNN 구조로 만들어진 모델이다 보니, 필연적으로 gradient vaninshing/exploding 현상이 발생합니다.


결국 이는 기계 번역 분야에서 입력 문장이 길면 번역 품질이 떨어지는 현상으로 나타났습니다. 입력 시퀀스가 길어지면 출력 시퀀스의 정확도가 떨어지는 seq2seq의 문제점을 해결 하기 위해 Attention Mechanism(어텐션 메커니즘) 이 제안되었습니다.


2. 어텐션(Attention)의 아이디어

어텐션의 기본 아이디어디코더에서 출력 단어를 예측하는 매 시점(time step)마다, 인코더에서의 전체 입력 문장을 다시 한 번 참고한다는 점 입니다. 단, 전체 입력 문장을 전부 다 동일한 비율로 참고하는 것이 아닌, 해당 시점에서 예측해야할 단어와 연관이 있는 입력 단어 부분을 좀 더 집중(attention)해서 보게 됩니다.


3. 어텐션 함수(Attention Function)

image


어텐션을 함수로 표현하면 주로 다음과 같이 표현됩니다.

[Attention(Q,\ K,\ V) = Attention\ Value]


위 수식, 어텐션 함수 의 $Q$, $K$, $V$ 에 해당되는 각각의 Query, Keys, Values 는 각각 다음과 같습니다.

  • $Q$ = Query : $t$ 시점의 디코더 셀에서의 은닉 상태
  • $K$ = Keys : 모든 시점의 인코더 셀의 은닉 상태들
  • $V$ = Values : 모든 시점의 인코더 셀의 은닉 상태들


어텐션 함수의 작동방식

  1. ‘쿼리(Query)’ 에 대해서 모든 ‘키(Key)’ 와의 유사도를 각각 구합니다.
  2. 구해낸 이 유사도를 키와 맵핑되어있는 각각의 ‘값(Value)’ 에 반영해줍니다.
  3. 유사도가 반영된 ‘값(Value)’ 을 모두 더해서 리턴합니다.

이때 값(Value)을 모두 더해서 리턴하는 값어텐션 값(Attention Value) 입니다. 간단한 어텐션 예제를 통해 어텐션을 이해해보겠습니다.


4. 닷-프로덕트(내적) 어텐션(Dot-Product Attention)

어텐션은 다양한 종류가 있는데, 여기에서는 닷-프로덕트 어텐션(Dot-Product Attention) 을 통해 어텐션을 이해해보겠습니다. seq2seq에서 사용되는 어텐션 중에서 닷-프로덕트 어텐션과 다른 어텐션의 차이는 주로 중간 수식의 차이로 메커니즘 자체는 거의 유사합니다.

image


어텐션 메커니즘에 대해 위의 그림을 통해 전체적인 개요를 이해해보겠습니다.

위 그림은 디코더의 첫번째, 두번째 LSTM 셀이 이미 어텐션 메커니즘을 통해 je와 suis를 예측하는 과정을 거쳤다고 가정하에, 디코더의 세번째 LSTM 셀에서 출력 단어를 예측할 때, 어텐션 메커니즘을 사용하는 모습 을 보여줍니다. 디코더의 세번째 LSTM 셀은 출력 단어를 예측하기 위해서 인코더의 모든 입력 단어들의 정보를 다시 한번 참고 하고자 합니다.


여기서 주목할 것은 인코더의 소프트맥스 함수 입니다. 위 그림의 빨간 직사각형 은 인코더의 소프트맥스 함수를 통해 나온 결과값인, I, am, a, student 단어 각각이 출력 단어를 예측할 때 얼마나 도움이 되는지의 정도를 수치화한 값 입니다. 각 입력 단어는 디코더의 예측에 도움이 되는 정도를 수치화하여 측정되면 이를 하나의 정보로 담아서(위 그림의 초록색 삼각형) 디코더로 전송 됩니다. 결과적으로, 디코더는 출력 단어를 더 정확하게 예측할 확률이 높아집니다.


4.1 어텐션 스코어(Attention Score)를 구한다.

우선, 인코더의 시점(time step)을 각각 1, 2, …, $N$ 이라고 했을때, 인코더의 은닉 상태(hidden state)를 각각 $h_1$, $h_2$, …, $h_N$ 이라고 하고, 디코더의 현재 시점(time step) $t$ 에서의 디코더의 은닉 상태(hidden state)를 $s_t$ 라고 하겠습니다. 또한 여기서는 인코더의 은닉 상태와 디코더의 은닉 상태의 차원이 같다고 가정합니다. 위의 그림의 경우에는 인코더의 은닉 상태와 디코더의 은닉 상태가 동일하게 차원이 4입니다.

image


기존의 디코더 는 현재 시점 $t$ 에서 출력 단어를 예측하기 위해서 디코더의 셀은 두 개의 입력값을 필요 로 했습니다. 하나는 이전 시점 $t-1$ 의 은닉 상태 와 다른 하나는 이전 시점 $t-1$ 에서 나온 출력 단어 입니다. 하지만 어텐션 메커니즘 에선 출력 단어 예측에 또 다른 값을 필요로 하는데 바로 어텐션 값(Attention Value)이라는 새로운 값을 추가로 필요 로 합니다. $t$ 번째 단어를 예측하기 위한 어텐션 값$a_t$ 라고 정의하겠습니다.

어텐션 값을 설명하기 이전에 어텐션 스코어를 먼저 이야기하겠습니다. 어텐션 스코어현재 디코더의 시점 $t$ 에서 단어를 예측하기 위해, 인코더의 모든 은닉 상태 각각이 디코더의 현 시점의 은닉 상태와 얼마나 유사한지를 판단하는 스코어값 입니다. 위에서 잠깐 설명드렸듯이, 모든 단어를 동일한 비율로 참고하지 않고 연관성 있는 입력 단어 부분을 집중(attention!) 해서 본다고 했었습니다.


닷-프로덕트 어텐션 에서는 이 스코어 값을 구하기 위해 $s_t$ 를 전치(transpose)하고 각 은닉 상태와 내적(dot product)을 수행 합니다. 즉, 모든 어텐션 스코어 값은 스칼라 입니다. 예를 들어 $s_t$ 와 인코더의 $i$ 번째 은닉 상태의 어텐션 스코어의 계산 방법은 아래와 같습니다.

image


어텐션 스코어 함수를 정의해보면 다음과 같습니다.

[score(s_t, h_i) = s^T_t h_i]


$s_t$ 와 인코더의 모든 은닉 상태의 어텐션 스코어의 모음값을 $e_t$ 라고 정의한다면, $e_t$ 의 수식은 다음과 같습니다.(여기서 $N$ 은 입력 단어 갯수, 위에선 “I am a student”로 총 4개)

[e^t = [s^T_t h_1,\ …,\ s^T_t h_N]]


4.2 소프트맥스(softmax) 함수를 통해 어텐션 분포(Attention Distribution)를 구한다.

image


$e^t$ 에 소프트맥스 함수 를 적용하면 모든 값을 합했을 때 1이 되는 확률 분포를 얻게 됩니다. 이를 어텐션 분포(Attention Distribution) 라고 하며, 각각의 값은 어텐션 가중치(Attention Weight) 라고 합니다. 예를 들어 소프트맥스 함수를 적용하여 얻은 출력값인 I, am, a, student의 어텐션 가중치를 각각 0.1, 0.4, 0.1, 0.4라고 했을때 이들의 합은 1이며, 위 그림에서 붉은색 사각형의 크기가 가중치 크기를 나타내고 있습니다.

디코더의 시점 $t$ 에서의 어텐션 가중치의 모음값인 어텐션 분포를 $\alpha_t$ 라고 할 때, 다음과 같이 정의할 수 있습니다.

[\alpha_t = softmax(e^t)]


4.3 각 인코더의 어텐션 가중치와 은닉 상태를 가중합하여 어텐션 값(Attention Value)을 구한다.

image


지금까지 준비해온 정보들을 하나로 합치는 단계로, 어텐션의 최종 결과값을 얻기 위해서 각 인코더의 은닉 상태와 어텐션 가중치값들을 곱하고 모두 더합니다. 이때 나오는 값이 어텐션의 최종 결과. 즉, 어텐션 함수의 출력값인 어텐션 값(Attention Value) $\alpha_t$ 입니다.

[\alpha_t = \sum^N_{i=1} \alpha^t_i h_i]


이러한 어텐션 값 $\alpha_t$ 는 인코더의 문맥을 포함하고 있다고 해서 컨텍스트 벡터(context vector) 라고도 불립니다.


4.4 어텐션 값과 디코더의 $t$ 시점의 은닉 상태를 연결한다.(Concatenate)

image


어텐션 값(Attention Value)인 $\alpha_t$ 가 구해지면, $\alpha_t$ 를 디코더의 은닉 상태(hidden state)인 $s_t$ 와 결합(concatenate)하여 하나의 벡터로 만드는 작업을 수행 하며, 이를 $v_t$ 라고 정의합니다.

$v_t$ 는 $\hat{y}$ 예측 연산의 입력으로 사용하여 인코더로부터 얻은 정보를 활용해 $\hat{y}$ 를 좀 더 잘 예측할 수 있게 합니다.


4.5 출력층 연산의 입력이 되는 $\tilde{s_t}$ 를 계산합니다.

image


어텐션 매커니즘에선 위에서 구한 $v_t$ 를 바로 출력층으로 보내지 않고 그 전에 신경망 연산을 추가 합니다. 가중치 행렬과 곱한 후 하이퍼볼릭탄젠트 함수를 지나도록 하여 출력층 연산을 위한 새로운 벡터인 $\tilde{s_t}$ 를 얻습니다. 어텐션 메커니즘을 사용하지 않는 seq2seq 에서는 출력층의 입력이 $t$ 시점의 은닉 상태인 $s_t$ 였던 반면, 어텐션 메커니즘에서는 출력층의 입력이 $\tilde{s_t}$ 가 되는 셈 입니다.

식으로 표현하자면 다음과 같습니다. $W_c$ 는 학습 가능한 가중치 행렬, $b_c$ 는 편향, $[a_t ; s_t]$ 는 위에서 결합한 벡터 $v_t$ 를 나타냅니다.

[\tilde{s_t} = tanh(W_c [a_t ; s_t] + b_c)]


4.6 $\tilde{s_t}$ 를 출력층의 입력으로 사용합니다.

$\tilde{s_t}$ 를 출력층의 입력으로 사용하여 예측 벡터를 얻습니다.

[\hat{y}_t = Softmax(W_y \tilde{s_t} + b_y)]


5. 바다나우 어텐션(Bahdanau Attention)

이번에는 닷-프로덕트 어텐션보다는 조금 더 복잡하게 설계된 바다나우 어텐션 메커니즘을 이해해보겠습니다. 아래 어텐션 함수는 대체적으로 동일하되 다른 점이 하나 있습니다. 바로 Query 가 디코더 셀의 $t$ 시점의 은닉 상태가 아닌 $t-1$ 시점의 은닉 상태라는 것 입니다.

[Attention(Q,\ K,\ V) = Attention\ Value]


  • $t$ = 어텐션 메커니즘이 수행되는 디코더 셀의 현재 시점을 의미
  • $Q$ = Query : $t-1$ 시점의 디코더 셀에서의 은닉 상태
  • $K$ = Keys : 모든 시점의 인코더 셀의 은닉 상태들
  • $V$ = Values : 모든 시점의 인코더 셀의 은닉 상태들


5.1 어텐션 스코어(Attention Score)를 구한다.

image


앞서 닷-프로덕트 어텐션에서는 Query 로 디코더의 $t$ 시점의 은닉 상태를 사용한 것과는 달리, 이번에는 그림에서도 알 수 있듯이 디코더의 $t$ 시점 은닉 상태가 아닌 $t-1$ 시점의 은닉 상태 $s_{t-1}$ 를 사용 합니다. 바다나우 어텐션의 어텐션 스코어 함수. 즉, $s_{t-1}$ 와 인코더의 $i$ 번째 은닉 상태의 어텐션 스코어 계산 방법은 아래와 같습니다.

[score(s_{t-1}, H) = W_a^T tanh(W_b s_{t-1} + W_c h_i)]


여기서 $W_a$, $W_b$, $W_c$ 는 학습 가능한 가중치 행렬입니다. 그리고 $s_{t-1}$ 와 $h_1$, $h_2$, $h_3$, $h_4$ 의 어텐션 스코어를 각각 구해야하므로 병렬 연산을 위해 $h_1$, $h_2$, $h_3$, $h_4$ 를 하나의 행렬 $H$ 로 두면 수식은 아래처럼 변경됩니다.

[score(s_{t-1}, H) = W_a^T tanh(W_b s_{t-1} + W_c H)]


아래 그림을 통해 위 수식을 보면 이해가 쉬울 것입니다. $W_c H$ (주황색 박스)와 $W_b s_{t-1}$ (초록색 박스)는 아래와 같습니다.

image


이들을 더한 후에는 하이퍼볼릭탄젠트 함수를 지나도록 합니다.

[tanh(W_b s_{t-1} + W_c H)]


image


이제 $W_a^T$ 와 곱하여 $s_{t-1}$ 와 $h_1$, $h_2$, $h_3$, $h_4$ 의 유사도가 기록된 어텐션 스코어 벡터 $e^t$ 를 얻습니다.

[e^t = W_a^T tanh(W_b s_{t-1} + W_c H)]


image


5.2 소프트맥스(softmax) 함수를 통해 어텐션 분포(Attention Distribution)를 구한다.

image


$e^t$ 에 소프트맥스 함수를 적용하면 모든 값의 합이 1이 되는 확률 분포를 얻어내고, 이를 어텐션 분포(Attention Distribution) 라고 부릅니다. 또 각각의 값은 어텐션 가중치(Attention Weight) 라고 합니다.


5.3 각 인코더의 어텐션 가중치와 은닉 상태를 가중합하여 어텐션 값(Attention Value)을 구한다.

image


지금까지 얻은 정보들을 하나로 합치는 단계로, 각 인코더의 은닉 상태와 어텐션 가중치값들을 곱하고 최종적으로 모두 더합니다. 해당 벡터는 인코더의 문맥을 포함하고 있기 때문에 이를 컨텍스트 벡터(context vector) 라고 부릅니다.


5.4 컨텍스트 벡터로부터 $s_t$ 를 구합니다.

기존의 LSTM 은 이전 시점의 셀로부터 전달받은 은닉 상태 $s_{t-1}$ 와 현재 시점의 입력 $x_t$ 를 가지고 연산하였습니다. 아래의 LSTM은 seq2seq의 디코더이며 현재 시점의 입력 $x_t$ 는 임베딩된 단어 벡터입니다.

image


바다나우 어텐션 메커니즘에서의 LSTM 작동 방식 을 살펴보겠습니다. 기존의 LSTM 작동 방식과 달리 현재 시점의 입력 $x_t$ 가 특이합니다. 바다나우 어텐션 메커니즘에서는 컨텍스트 벡터와 현재 시점의 입력인 단어의 임베딩 벡터를 연결(concatenate)하고, 현재 시점의 새로운 입력으로 사용 하는 모습을 보여줍니다. 그리고 이전 시점의 셀로부터 전달받은 은닉 상태 $s_{t-1}$ 와 현재 시점의 새로운 입력으로부터 $s_t$ 를 구합니다.

기존의 LSTM이 임베딩된 단어 벡터를 입력으로 하는 것에서 컨텍스트 벡터와 임베딩된 단어 벡터를 연결(concatenate)하여 입력으로 사용하는 것이 달라졌습니다. 이후의 과정은 어텐션 메커니즘을 사용하지 않는 경우, 즉 일반적인 LSTM을 실행시키는 것과 동일합니다.

image


사실 concatenate 는 닷-프로덕트 어텐션 메커니즘에서도 사용했던 방식입니다. 다른 점은 바다나우 어텐션 에서는 컨텍스트 벡터를 다음 단어를 예측하는 데 입력 단어에 합친다는 것이고, 닷-프로덕트 어텐션 에서는 이전 단어의 예측값인 $s_t$ 에 컨텍스트 벡터를 합치고($v_t$), 가중치 행렬 $W_c$ 와 곱한 뒤 하이퍼볼릭탄젠트 함수를 거쳐 예측 벡터인 $\hat{s}_t$ 를 얻었습니다.


6. 다양한 종류의 어텐션(Attention)

어텐션의 종류는 다양합니다. 어텐션 차이는 주로 중간 수식인 어텐션 스코어 함수 차이를 말하며, 어텐션 스코어를 구하는 방법은 여러가지가 있습니다.

image

Read more

Seq2seq Word‐Level 번역기(NMT) 만들기

|

seq2seq 를 이용해서 기계 번역기를 만들어보겠습니다. 실제 성능이 좋은 기계 번역기를 구현하려면 정말 방대한 데이터가 필요하므로 여기서는 seq2seq 를 실습해보는 수준에서 아주 간단한 기계 번역기를 구축해보겠습니다. 기계 번역기를 훈련시키기 위해서는 훈련 데이터로 병렬 코퍼스(parallel corpus) 데이터 가 필요합니다. 병렬 코퍼스란, 두 개 이상의 언어가 병렬적으로 구성된 코퍼스를 의미 합니다.


본 실습에서는 프랑스-영어 병렬 코퍼스인 fra-eng.zip 파일을 사용하겠습니다. 위 링크에서 해당 파일을 다운받으면 됩니다. 해당 파일의 압축을 풀면 fra.txt 라는 파일이 있는데 이 파일이 이번 실습에서 사용할 파일입니다.



1. 병렬 코퍼스 데이터에 대한 이해와 전처리

우선 병렬 코퍼스 데이터 에 대한 이해를 해보겠습니다. 태깅 작업의 병렬 데이터는 쌍이 되는 모든 데이터가 길이가 같았지만 여기서는 쌍이 된다고 해서 길이가 같지않습니다. 실제 번역기를 생각해보면 구글 번역기에 ‘나는 학생이다.’라는 토큰의 개수가 2인 문장을 넣었을 때 ‘I am a student.’라는 토큰의 개수가 4인 문장이 나오는 것과 같은 이치입니다.

seq2seq는 기본적으로 입력 시퀀스와 출력 시퀀스의 길이가 다를 수 있다고 가정합니다. 지금은 기계 번역기가 예제지만 seq2seq의 또 다른 유명한 예제 중 하나인 챗봇을 만든다고 가정해보면, 대답의 길이가 질문의 길이와 항상 똑같아야 한다고하면 그 또한 이상합니다.

Watch me.   Regardez-moi !


여기서 사용할 fra.txt 데이터는 위와 같이 왼쪽의 영어 문장과 오른쪽의 프랑스어 문장 사이에 탭으로 구분되는 구조가 하나의 샘플입니다. 그리고 이와 같은 형식의 약 21만개의 병렬 문장 샘플을 포함하고 있습니다. 해당 데이터를 다운받고, 읽고, 전처리를 진행해보겠습니다. fra-eng.zip 파일을 다운로드하고 압축을 풀겠습니다.

import re
import os
import unicodedata
import urllib3
import zipfile
import shutil
import numpy as np
import pandas as pd
import tensorflow as tf
from tensorflow.keras.layers import Embedding, GRU, Dense
from tensorflow.keras.preprocessing.text import Tokenizer
from tensorflow.keras.preprocessing.sequence import pad_sequences

http = urllib3.PoolManager()
url ='http://www.manythings.org/anki/fra-eng.zip'
filename = 'fra-eng.zip'
path = os.getcwd()
zipfilename = os.path.join(path, filename)
with http.request('GET', url, preload_content=False) as r, open(zipfilename, 'wb') as out_file:       
    shutil.copyfileobj(r, out_file)

with zipfile.ZipFile(zipfilename, 'r') as zip_ref:
    zip_ref.extractall(path)


본 실습에서는 약 21만개의 데이터 중 33,000개의 샘플 만을 사용하겠습니다.

num_samples = 33000


전처리 함수 들을 구현하겠습니다. 구두점 등을 제거하거나 단어와 구분해주기 위한 전처리입니다.

def unicode_to_ascii(s):
    # 프랑스어 악센트(accent) 삭제
    # 예시 : 'déjà diné' -> deja dine
    return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')

def preprocess_sentence(sent):
    # 악센트 삭제 함수 호출
    sent = unicode_to_ascii(sent.lower())

    # 단어와 구두점 사이에 공백을 만듭니다.
    # Ex) "he is a boy." => "he is a boy ."
    sent = re.sub(r"([?.!,¿])", r" \1", sent)

    # (a-z, A-Z, ".", "?", "!", ",") 이들을 제외하고는 전부 공백으로 변환합니다.
    sent = re.sub(r"[^a-zA-Z!.?]+", r" ", sent)

    # 다수 개의 공백을 하나의 공백으로 치환
    sent = re.sub(r"\s+", " ", sent)
    
    return sent


구현한 전처리 함수들을 임의의 문장을 입력으로 테스트해보겠습니다.

# 전처리 테스트
en_sent = u"Have you had dinner?"
fr_sent = u"Avez-vous déjà diné?"

print('전처리 전 영어 문장 :', en_sent)
print('전처리 후 영어 문장 :', preprocess_sentence(en_sent))
print('전처리 전 프랑스어 문장 :', fr_sent)
print('전처리 후 프랑스어 문장 :', preprocess_sentence(fr_sent))
[output]
전처리 전 영어 문장 : Have you had dinner?
전처리 후 영어 문장 : have you had dinner ?
전처리 전 프랑스어 문장 : Avez-vous déjà diné?
전처리 후 프랑스어 문장 : avez vous deja dine ?


전체 데이터에서 33,000개의 샘플에 대해서 전처리를 수행 합니다. 또한 훈련 과정에서 교사 강요(Teacher Forcing) 을 사용할 예정이므로, 훈련 시 사용할 디코더의 입력 시퀀스와 실제값. 즉, 레이블에 해당되는 출력 시퀀스를 따로 분리하여 저장 합니다. 입력 시퀀스 에는 시작을 의미하는 토큰인 <sos> 를 추가하고, 출력 시퀀스 에는 종료를 의미하는 토큰인 <eos> 를 추가합니다.

def load_preprocessed_data():
    encoder_input, decoder_input, decoder_target = [], [], []

    with open("fra.txt", "r") as lines:
        for i, line in enumerate(lines):
            # source 데이터와 target 데이터 분리
            src_line, tar_line, _ = line.strip().split('\t')

            # source 데이터 전처리
            src_line = [w for w in preprocess_sentence(src_line).split()]

            # target 데이터 전처리
            tar_line = preprocess_sentence(tar_line)
            tar_line_in = [w for w in ("<sos> " + tar_line).split()]
            tar_line_out = [w for w in (tar_line + " <eos>").split()]

            encoder_input.append(src_line)
            decoder_input.append(tar_line_in)
            decoder_target.append(tar_line_out)

            if i == num_samples - 1:
                break
                
    return encoder_input, decoder_input, decoder_target


이렇게 얻은 3개의 데이터셋 인코더의 입력, 디코더의 입력, 디코더의 레이블 을 상위 5개 샘플만 출력해보겠습니다.

sents_en_in, sents_fra_in, sents_fra_out = load_preprocessed_data()

print('인코더의 입력 :', sents_en_in[:5])
print('디코더의 입력 :', sents_fra_in[:5])
print('디코더의 레이블 :', sents_fra_out[:5])
[output]
인코더의 입력 : [['go', '.'], ['go', '.'], ['go', '.'], ['go', '.'], ['hi', '.']]
디코더의 입력 : [['<sos>', 'va', '!'], ['<sos>', 'marche', '.'], ['<sos>', 'en', 'route', '!'], ['<sos>', 'bouge', '!'], ['<sos>', 'salut', '!']]
디코더의 레이블 : [['va', '!', '<eos>'], ['marche', '.', '<eos>'], ['en', 'route', '!', '<eos>'], ['bouge', '!', '<eos>'], ['salut', '!', '<eos>']]


모델 설계에 있어서 디코더의 입력에 해당하는 데이터인 sents_fra_in이 필요한 이유 를 살펴보겠습니다.

테스트 과정에서 현재 시점의 디코더 셀의 입력은 오직 이전 디코더 셀의 출력을 입력으로 받습니다. 하지만 훈련 과정 에서는 이전 시점의 디코더 셀의 출력을 현재 시점의 디코더 셀의 입력으로 넣어주지 않고, 이전 시점의 실제값을 현재 시점의 디코더 셀의 입력값으로 하는 방법을 사용 합니다.


이유이전 시점의 디코더 셀의 예측이 틀렸는데 이를 현재 시점의 디코더 셀의 입력으로 사용하면 현재 시점의 디코더 셀의 예측도 잘못될 가능성이 높고 이는 연쇄 작용으로 디코더 전체의 예측을 어렵게 합니다. 이런 상황이 반복되면 훈련 시간이 느려집니다.

만약 이 상황을 원하지 않는다면 이전 시점의 디코더 셀의 예측값 대신 실제값을 현재 시점의 디코더 셀의 입력으로 사용하는 방법을 사용할 수 있습니다. 이와 같이 RNN의 모든 시점에 대해서 이전 시점의 예측값 대신 실제값을 입력으로 주는 방법교사 강요 라고 합니다.


케라스 토크나이저를 통해 단어 집합을 생성, 정수 인코딩 을 진행 후 이어서 패딩 을 진행하겠습니다.

tokenizer_en = Tokenizer(filters="", lower=False)
tokenizer_en.fit_on_texts(sents_en_in)
encoder_input = tokenizer_en.texts_to_sequences(sents_en_in)
encoder_input = pad_sequences(encoder_input, padding="post")


tokenizer_fra = Tokenizer(filters="", lower=False)
tokenizer_fra.fit_on_texts(sents_fra_in)
tokenizer_fra.fit_on_texts(sents_fra_out)


decoder_input = tokenizer_fra.texts_to_sequences(sents_fra_in)
decoder_input = pad_sequences(decoder_input, padding="post")

decoder_target = tokenizer_fra.texts_to_sequences(sents_fra_out)
decoder_target = pad_sequences(decoder_target, padding="post")
print('인코더의 입력의 크기(shape) :', encoder_input.shape)
print('디코더의 입력의 크기(shape) :', decoder_input.shape)
print('디코더의 레이블의 크기(shape) :', decoder_target.shape)
[output]
인코더의 입력의 크기(shape) : (33000, 7)
디코더의 입력의 크기(shape) : (33000, 16)
디코더의 레이블의 크기(shape) : (33000, 16)


데이터의 크기(shape)를 확인했을때 샘플은 총 33,000개 존재하며 영어 문장의 길이는 7, 프랑스어 문장의 길이는 16입니다. 단어 집합의 크기를 정의 합니다.

src_vocab_size = len(tokenizer_en.word_index) + 1
tar_vocab_size = len(tokenizer_fra.word_index) + 1
print("영어 단어 집합의 크기 : {:d}, 프랑스어 단어 집합의 크기 : {:d}".format(src_vocab_size, tar_vocab_size))
[output]
영어 단어 집합의 크기 : 4516, 프랑스어 단어 집합의 크기 : 7907


단어 집합의 크기는 각각 4,516개와 7,907개입니다. 단어로부터 정수를 얻는 딕셔너리정수로부터 단어를 얻는 딕셔너리 를 각각 만들어줍니다. 이들은 훈련을 마치고 예측값과 실제값을 비교하는 단계에서 사용됩니다.

src_to_index = tokenizer_en.word_index
index_to_src = tokenizer_en.index_word
tar_to_index = tokenizer_fra.word_index
index_to_tar = tokenizer_fra.index_word


테스트 데이터를 분리하기 전 데이터를 섞어줍니다. 이를 위해서 순서가 섞인 정수 시퀀스 리스트 를 만듭니다.

indices = np.arange(encoder_input.shape[0]) # 33000
np.random.shuffle(indices)
print('랜덤 시퀀스 :', indices)
[output]
랜덤 시퀀스 : [24985  5677 24649 ... 19502 14537  9821]


이를 데이터셋의 순서로 지정해주면 샘플들이 기존 순서와 다른 순서로 섞이게 됩니다.

encoder_input = encoder_input[indices]
decoder_input = decoder_input[indices]
decoder_target = decoder_target[indices]


임의로 30,997번째 샘플을 출력해보겠습니다. 이때 decoder_input과 decoder_target은 데이터의 구조상으로 앞에 붙은 <sos> 토큰과 뒤에 붙은 <eos> 을 제외하면 동일한 정수 시퀀스를 가져야 합니다.

encoder_input[30997]
[output]
array([ 2, 97,  3,  1,  0,  0,  0], dtype=int32)
decoder_input[30997]
[output]
array([  2,   4,  54, 757,   1,   0,   0,   0,   0,   0,   0,   0,   0,
         0,   0,   0], dtype=int32)
decoder_target[30997]
[output]
array([  4,  54, 757,   1,   3,   0,   0,   0,   0,   0,   0,   0,   0,
         0,   0,   0], dtype=int32)


4, 54, 757, 173, 1이라는 동일 시퀀스를 확인했습니다. 이제 훈련 데이터의 10%를 테스트 데이터로 분리하겠습니다.

n_of_val = int(33000 * 0.1)
print('검증 데이터의 개수 :', n_of_val)
[output]
검증 데이터의 개수 : 3300


33,000개의 10%에 해당되는 3,300개의 데이터를 테스트 데이터로 사용합니다.

encoder_input_train = encoder_input[:-n_of_val]
decoder_input_train = decoder_input[:-n_of_val]
decoder_target_train = decoder_target[:-n_of_val]

encoder_input_test = encoder_input[-n_of_val:]
decoder_input_test = decoder_input[-n_of_val:]
decoder_target_test = decoder_target[-n_of_val:]


훈련 데이터와 테스트 데이터의 크기(shape)를 출력해보겠습니다.

print('훈련 source 데이터의 크기 :', encoder_input_train.shape)
print('훈련 target 데이터의 크기 :', decoder_input_train.shape)
print('훈련 target 레이블의 크기 :', decoder_target_train.shape)

print('\n테스트 source 데이터의 크기 :', encoder_input_test.shape)
print('테스트 target 데이터의 크기 :', decoder_input_test.shape)
print('테스트 target 레이블의 크기 :', decoder_target_test.shape)
[output]
훈련 source 데이터의 크기 : (29700, 7)
훈련 target 데이터의 크기 : (29700, 16)
훈련 target 레이블의 크기 : (29700, 16)

테스트 source 데이터의 크기 : (3300, 7)
테스트 target 데이터의 크기 : (3300, 16)
테스트 target 레이블의 크기 : (3300, 16)


훈련 데이터의 샘플은 29,700개, 테스트 데이터의 샘플은 3,300개가 존재합니다. 이제 모델을 설계합니다.


2. 기계 번역기 만들기

우선 임베딩 벡터의 차원LSTM의 은닉 상태의 크기 를 64로 사용합니다.

from tensorflow.keras.layers import Input, LSTM, Embedding, Dense, Masking
from tensorflow.keras.models import Model

embedding_dim = 64
hidden_units = 64


인코더 를 살펴보면, 우선 Masking 은 패딩 토큰인 숫자 0의 경우에는 연산을 제외하는 역할을 수행합니다. 인코더의 내부 상태를 디코더로 넘겨주어야 하기 때문에 LSTMreturn_state=True 로 설정합니다. 인코더에 입력을 넣으면 내부 상태를 리턴합니다.

LSTM 에서 state_h, state_c 를 리턴받는데, 이는 각각 은닉 상태셀 상태 에 해당됩니다. 이 두 가지 상태를 encoder_states 에 저장합니다. encoder_states 를 디코더에 전달하므로서 이 두 가지 상태 모두를 디코더로 전달할 예정입니다. 이것이 컨텍스트 벡터 입니다.

# 인코더
encoder_inputs = Input(shape=(None,))
enc_emb = Embedding(src_vocab_size, embedding_dim)(encoder_inputs) # 임베딩 층
enc_masking = Masking(mask_value=0.0)(enc_emb) # 패딩 0은 연산에서 제외

# 상태값 리턴을 위해 return_state는 True
encoder_lstm = LSTM(hidden_units, return_state=True)

encoder_outputs, state_h, state_c = encoder_lstm(enc_masking) # 은닉 상태와 셀 상태를 리턴

# 인코더의 은닉 상태와 셀 상태를 저장
encoder_states = [state_h, state_c]


디코더 를 살펴보면, 디코더는 인코더의 마지막 은닉 상태로부터 초기 은닉 상태를 얻습니다. initial_state 의 인자값으로 encoder_states 를 주는 코드가 이에 해당됩니다. 디코더도 은닉 상태, 셀 상태를 리턴하기는 하지만 훈련 과정에서는 사용하지 않습니다. seq2seq의 디코더는 기본적으로 각 시점마다 다중 클래스 분류 문제를 풀고있습니다. 매 시점마다 프랑스어 단어 집합의 크기(tar_vocab_size)의 선택지에서 단어를 1개 선택하여 이를 이번 시점에서 예측한 단어로 택합니다. 다중 클래스 분류 문제이므로 출력층으로 소프트맥스 함수와 손실 함수를 크로스 엔트로피 함수를 사용합니다.

categorical_crossentropy를 사용하려면 레이블은 원-핫 인코딩이 된 상태여야 합니다. 그런데 현재 decoder_outputs의 경우에는 원-핫 인코딩을 하지 않은 상태입니다. 원-핫 인코딩을 하지 않은 상태로 정수 레이블에 대해서 다중 클래스 분류 문제를 풀고자 하는 경우에는 categorical_crossentropy가 아니라 sparse_categorical_crossentropy 를 사용하면 됩니다.

# 디코더
decoder_inputs = Input(shape=(None,))
dec_emb_layer = Embedding(tar_vocab_size, hidden_units) # 임베딩 층
dec_emb = dec_emb_layer(decoder_inputs)
dec_masking = Masking(mask_value=0.0)(dec_emb) # 패딩 0은 연산에서 제외

# 상태값 리턴을 위해 return_state는 True, 모든 시점에 대해서 단어를 예측하기 위해 return_sequences는 True
decoder_lstm = LSTM(hidden_units, return_sequences=True, return_state=True) 

# 인코더의 은닉 상태를 초기 은닉 상태(initial_state)로 사용
decoder_outputs, _, _ = decoder_lstm(dec_masking, initial_state=encoder_states)

# 모든 시점의 결과에 대해서 소프트맥스 함수를 사용한 출력층을 통해 단어 예측
decoder_dense = Dense(tar_vocab_size, activation='softmax')
decoder_outputs = decoder_dense(decoder_outputs)
# 모델의 입력과 출력을 정의.
model = Model([encoder_inputs, decoder_inputs], decoder_outputs)
model.summary()
[output]
Model: "model"
__________________________________________________________________________________________________
 Layer (type)                   Output Shape         Param #     Connected to                     
==================================================================================================
 input_1 (InputLayer)           [(None, None)]       0           []                               
                                                                                                  
 input_2 (InputLayer)           [(None, None)]       0           []                               
                                                                                                  
 embedding (Embedding)          (None, None, 64)     289024      ['input_1[0][0]']                
                                                                                                  
 embedding_1 (Embedding)        (None, None, 64)     506048      ['input_2[0][0]']                
                                                                                                  
 masking (Masking)              (None, None, 64)     0           ['embedding[0][0]']              
                                                                                                  
 masking_1 (Masking)            (None, None, 64)     0           ['embedding_1[0][0]']            
                                                                                                  
 lstm (LSTM)                    [(None, 64),         33024       ['masking[0][0]']                
                                 (None, 64),                                                      
                                 (None, 64)]                                                      
                                                                                                  
 lstm_1 (LSTM)                  [(None, None, 64),   33024       ['masking_1[0][0]',              
                                 (None, 64),                      'lstm[0][1]',                   
                                 (None, 64)]                      'lstm[0][2]']                   
                                                                                                  
 dense (Dense)                  (None, None, 7907)   513955      ['lstm_1[0][0]']                 
                                                                                                  
==================================================================================================
Total params: 1,375,075
Trainable params: 1,375,075
Non-trainable params: 0
__________________________________________________________________________________________________


모델을 훈련합니다. 128개의 배치 크기로 총 50 에포크 학습합니다. 테스트 데이터를 검증 데이터로 사용하여 훈련이 제대로 되고있는지 모니터링하겠습니다.

model.compile(
    optimizer='adam',
    loss='sparse_categorical_crossentropy',
    metrics=['acc']
)

model.fit(
    x=[encoder_input_train, decoder_input_train],
    y=decoder_target_train,
    validation_data=(
        [encoder_input_test, decoder_input_test],
        decoder_target_test
    ),
    batch_size=128,
    epochs=50
)
[output]
Epoch 1/50
233/233 [==============================] - 12s 33ms/step - loss: 3.4443 - acc: 0.6133 - val_loss: 2.1003 - val_acc: 0.6172
Epoch 2/50
233/233 [==============================] - 7s 29ms/step - loss: 1.9005 - acc: 0.6512 - val_loss: 1.7903 - val_acc: 0.7075
Epoch 3/50
233/233 [==============================] - 7s 29ms/step - loss: 1.6843 - acc: 0.7355 - val_loss: 1.6207 - val_acc: 0.7490
Epoch 4/50
233/233 [==============================] - 7s 29ms/step - loss: 1.5351 - acc: 0.7559 - val_loss: 1.4994 - val_acc: 0.7611
.
.
.
Epoch 46/50
233/233 [==============================] - 7s 29ms/step - loss: 0.3713 - acc: 0.9116 - val_loss: 0.7621 - val_acc: 0.8646
Epoch 47/50
233/233 [==============================] - 7s 29ms/step - loss: 0.3635 - acc: 0.9133 - val_loss: 0.7616 - val_acc: 0.8651
Epoch 48/50
233/233 [==============================] - 7s 29ms/step - loss: 0.3559 - acc: 0.9147 - val_loss: 0.7601 - val_acc: 0.8660
Epoch 49/50
233/233 [==============================] - 7s 29ms/step - loss: 0.3485 - acc: 0.9158 - val_loss: 0.7568 - val_acc: 0.8660
Epoch 50/50
233/233 [==============================] - 7s 29ms/step - loss: 0.3403 - acc: 0.9176 - val_loss: 0.7584 - val_acc: 0.8671


3. seq2seq 기계 번역기 동작시키기

seq2seq는 훈련 과정(교사 강요)과 테스트 과정에서의 동작 방식이 다릅니다. 그래서 테스트 과정을 위해 모델을 다시 설계해주어야하며, 특히 디코더를 수정 해야 합니다. 이번에는 번역 단계를 위해 모델을 수정하고 동작시켜보겠습니다.

전체적인 번역 단계를 정리하면 아래와 같습니다.

  1. 번역하고자 하는 입력 문장이 인코더로 입력되어 인코더의 마지막 시점의 은닉 상태와 셀 상태를 얻습니다.
  2. 인코더의 은닉 상태와 셀 상태, 그리고 토큰 <sos> 를 디코더로 보냅니다.
  3. 디코더가 토큰 <eos> 가 나올 때까지 다음 단어를 예측하는 행동을 반복합니다.


인코더의 입, 출력으로 사용하는 encoder_inputsencoder_states 는 훈련 과정에서 이미 정의한 것들을 재사용합니다. 이렇게 되면 훈련 단계에 encoder_inputsencoder_states 사이에 있는 모든 층까지 전부 불러오게 되므로 결과적으로 훈련 단계에서 사용한 인코더를 그대로 재사용하게 됩니다.

# 인코더
encoder_model = Model(encoder_inputs, encoder_states)
encoder_model.summary()
[output]
Model: "model_1"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 input_1 (InputLayer)        [(None, None)]            0         
                                                                 
 embedding (Embedding)       (None, None, 64)          289024    
                                                                 
 masking (Masking)           (None, None, 64)          0         
                                                                 
 lstm (LSTM)                 [(None, 64),              33024     
                              (None, 64),                        
                              (None, 64)]                        
                                                                 
=================================================================
Total params: 322,048
Trainable params: 322,048
Non-trainable params: 0
_________________________________________________________________


이어서 디코더를 설계합니다. 테스트 단계에서는 디코더를 매 시점 별로 컨트롤 할 예정으로, 이를 위해서 이전 시점의 상태를 저장할 텐서인 decoder_state_input_h, decoder_state_input_c 를 정의합니다. 매 시점 별로 디코더를 컨트롤하는 함수는 뒤에서 정의할 decode_sequence() 로 해당 함수를 자세히 살펴봐야 합니다.

# 디코더 설계 시작
# 이전 시점의 상태를 보관할 텐서
decoder_state_input_h = Input(shape=(hidden_units,))
decoder_state_input_c = Input(shape=(hidden_units,))
decoder_states_inputs = [decoder_state_input_h, decoder_state_input_c]

# 훈련 때 사용했던 임베딩 층을 재사용
dec_emb2 = dec_emb_layer(decoder_inputs)

# 다음 단어 예측을 위해 이전 시점의 상태를 현 시점의 초기 상태로 사용
decoder_outputs2, state_h2, state_c2 = decoder_lstm(dec_emb2, initial_state=decoder_states_inputs)
decoder_states2 = [state_h2, state_c2]

# 모든 시점에 대해서 단어 예측
decoder_outputs2 = decoder_dense(decoder_outputs2)

# 수정된 디코더
decoder_model = Model(
    [decoder_inputs] + decoder_states_inputs,
    [decoder_outputs2] + decoder_states2
)

decoder_model.summary()
[output]
Model: "model_2"
__________________________________________________________________________________________________
 Layer (type)                   Output Shape         Param #     Connected to                     
==================================================================================================
 input_2 (InputLayer)           [(None, None)]       0           []                               
                                                                                                  
 embedding_1 (Embedding)        (None, None, 64)     506048      ['input_2[0][0]']                
                                                                                                  
 input_3 (InputLayer)           [(None, 64)]         0           []                               
                                                                                                  
 input_4 (InputLayer)           [(None, 64)]         0           []                               
                                                                                                  
 lstm_1 (LSTM)                  [(None, None, 64),   33024       ['embedding_1[1][0]',            
                                 (None, 64),                      'input_3[0][0]',                
                                 (None, 64)]                      'input_4[0][0]']                
                                                                                                  
 dense (Dense)                  (None, None, 7907)   513955      ['lstm_1[1][0]']                 
                                                                                                  
==================================================================================================
Total params: 1,053,027
Trainable params: 1,053,027
Non-trainable params: 0
__________________________________________________________________________________________________


테스트 단계에서의 동작을 위한 decode_sequence 함수를 구현합니다. 입력 문장이 들어오면 인코더는 마지막 시점까지 전개하여 마지막 시점의 은닉 상태와 셀 상태를 리턴합니다. 이 두 개의 값을 states_value 에 저장합니다. 그리고 디코더의 초기 입력으로 <sos> 를 준비합니다. 이를 target_seq 에 저장합니다. 이 두 가지 입력을 가지고 while문 안으로 진입하여 이 두 가지를 디코더의 입력으로 사용합니다.

이제 디코더는 현재 시점에 대해서 예측을 하게 되는데, 현재 시점의 예측 벡터가 output_tokens, 현재 시점의 은닉 상태가 h, 현재 시점의 셀 상태가 c입니다. 예측 벡터로부터 현재 시점의 예측 단어인 target_seq 를 얻고, h와 c 이 두 개의 값은 states_value 에 저장합니다. 그리고 while문의 다음 루프. 즉, 두번째 시점의 디코더의 입력으로 다시 target_seqstates_value 를 사용합니다. 이를 현재 시점의 예측 단어로 <eos> 를 예측하거나 번역 문장의 길이가 50이 넘는 순간까지 반복합니다. 각 시점마다 번역된 단어는 decoded_sentence 에 누적하여 저장하였다가 최종 번역 시퀀스로 리턴합니다.

def decode_sequence(input_seq):
    # 입력으로부터 인코더의 상태를 얻음
    states_value = encoder_model.predict(input_seq)

    # <SOS>에 해당하는 정수 생성
    target_seq = np.zeros((1,1))
    target_seq[0, 0] = tar_to_index['<sos>']

    stop_condition = False
    decoded_sentence = ''

    # stop_condition이 True가 될 때까지 루프 반복
    # 구현의 간소화를 위해서 이 함수는 배치 크기를 1로 가정합니다.
    while not stop_condition:
        # 이점 시점의 상태 states_value를 현 시점의 초기 상태로 사용
        output_tokens, h, c = decoder_model.predict([target_seq] + states_value)

        # 예측 결과를 단어로 변환
        sampled_token_index = np.argmax(output_tokens[0, -1, :])
        sampled_char = index_to_tar[sampled_token_index]

        # 현재 시점의 예측 단어를 예측 문장에 추가
        decoded_sentence += ' '+sampled_char

        # <eos>에 도달하거나 정해진 길이를 넘으면 중단.
        if (sampled_char == '<eos>' or
            len(decoded_sentence) > 50):
            stop_condition = True

        # 현재 시점의 예측 결과를 다음 시점의 입력으로 사용하기 위해 저장
        target_seq = np.zeros((1,1))
        target_seq[0, 0] = sampled_token_index

        # 현재 시점의 상태를 다음 시점의 상태로 사용하기 위해 저장
        states_value = [h, c]

    return decoded_sentence


결과 확인을 위한 함수를 만듭니다. seq_to_src 함수는 영어 문장에 해당하는 정수 시퀀스를 입력받으면 정수로부터 영어 단어를 리턴하는 index_to_src 를 통해 영어 문장으로 변환합니다. seq_to_tar 은 프랑스어에 해당하는 정수 시퀀스를 입력받으면 정수로부터 프랑스어 단어를 리턴하는 index_to_tar 을 통해 프랑스어 문장으로 변환합니다.

# 원문의 정수 시퀀스를 텍스트 시퀀스로 변환
def seq_to_src(input_seq):
    sentence = ''
    for encoded_word in input_seq:
        if(encoded_word!=0):
            sentence = sentence + index_to_src[encoded_word] + ' '
    return sentence

# 번역문의 정수 시퀀스를 텍스트 시퀀스로 변환
def seq_to_tar(input_seq):
    sentence = ''
    for encoded_word in input_seq:
        if(encoded_word!=0 and encoded_word!=tar_to_index['<sos>'] and encoded_word!=tar_to_index['<eos>']):
            sentence = sentence + index_to_tar[encoded_word] + ' '
    return sentence


훈련 데이터에 대해서 임의로 선택한 인덱스의 샘플의 결과를 출력해봅시다.

for seq_index in [3, 50, 100, 300, 1001]:
    input_seq = encoder_input_train[seq_index: seq_index + 1]
    decoded_sentence = decode_sequence(input_seq)
    
    print("입력문장 :", seq_to_src(encoder_input_train[seq_index]))
    print("정답문장 :", seq_to_tar(decoder_input_train[seq_index]))
    print("번역문장 :", decoded_sentence[1:-5])
    print("-"*50)
[output]
입력문장 : i retired in . 
정답문장 : j ai pris ma retraite en . 
번역문장 : je suis tombe a la retraite . 
--------------------------------------------------
입력문장 : i found you . 
정답문장 : je t ai trouve . 
번역문장 : je vous ai trouve . 
--------------------------------------------------
입력문장 : i have many discs . 
정답문장 : j ai beaucoup de disques . 
번역문장 : j ai beaucoup de disques . 
--------------------------------------------------
입력문장 : i m shivering . 
정답문장 : je tremble . 
번역문장 : je tremble . 
--------------------------------------------------
입력문장 : i often hiccup . 
정답문장 : j ai souvent le hoquet . 
번역문장 : je fais que j ai gagne . 
--------------------------------------------------


테스트 데이터에 대해서 임의로 선택한 인덱스의 샘플의 결과를 출력해봅시다.

for seq_index in [3, 50, 100, 300, 1001]:
    input_seq = encoder_input_test[seq_index: seq_index + 1]
    decoded_sentence = decode_sequence(input_seq)
    
    print("입력문장 :", seq_to_src(encoder_input_test[seq_index]))
    print("정답문장 :", seq_to_tar(decoder_input_test[seq_index]))
    print("번역문장 :", decoded_sentence[1:-5])
    print("-"*50)
[output]
입력문장 : tom is childish . 
정답문장 : tom est immature . 
번역문장 : tom est en train de danser . 
--------------------------------------------------
입력문장 : i have needs . 
정답문장 : j ai des besoins . 
번역문장 : il me faut que j en arriere . 
--------------------------------------------------
입력문장 : do as you want . 
정답문장 : fais comme ca te chante . 
번역문장 : fais comme tu veux . 
--------------------------------------------------
입력문장 : brace yourselves . 
정답문장 : accrochez vous . 
번역문장 : preparez vous . 
--------------------------------------------------
입력문장 : tom hates french . 
정답문장 : tom deteste le francais . 
번역문장 : tom deteste le francais . 
--------------------------------------------------

Read more

Seq2seq Character‐Level 번역기(NMT) 만들기

|

seq2seq 를 이용해서 기계 번역기를 만들어보겠습니다. 실제 성능이 좋은 기계 번역기를 구현하려면 정말 방대한 데이터가 필요하므로 여기서는 seq2seq 를 실습해보는 수준에서 아주 간단한 기계 번역기를 구축해보겠습니다. 기계 번역기를 훈련시키기 위해서는 훈련 데이터로 병렬 코퍼스(parallel corpus) 데이터 가 필요합니다. 병렬 코퍼스란, 두 개 이상의 언어가 병렬적으로 구성된 코퍼스를 의미 합니다.


본 실습에서는 프랑스-영어 병렬 코퍼스인 fra-eng.zip 파일을 사용하겠습니다. 위 링크에서 해당 파일을 다운받으면 됩니다. 해당 파일의 압축을 풀면 fra.txt 라는 파일이 있는데 이 파일이 이번 실습에서 사용할 파일입니다.



1. 병렬 코퍼스 데이터에 대한 이해와 전처리

우선 병렬 코퍼스 데이터 에 대한 이해를 해보겠습니다. 태깅 작업의 병렬 데이터는 쌍이 되는 모든 데이터가 길이가 같았지만 여기서는 쌍이 된다고 해서 길이가 같지않습니다. 실제 번역기를 생각해보면 구글 번역기에 ‘나는 학생이다.’라는 토큰의 개수가 2인 문장을 넣었을 때 ‘I am a student.’라는 토큰의 개수가 4인 문장이 나오는 것과 같은 이치입니다.

seq2seq는 기본적으로 입력 시퀀스와 출력 시퀀스의 길이가 다를 수 있다고 가정합니다. 지금은 기계 번역기가 예제지만 seq2seq의 또 다른 유명한 예제 중 하나인 챗봇을 만든다고 가정해보면, 대답의 길이가 질문의 길이와 항상 똑같아야 한다고하면 그 또한 이상합니다.

Watch me.   Regardez-moi !


여기서 사용할 fra.txt 데이터는 위와 같이 왼쪽의 영어 문장과 오른쪽의 프랑스어 문장 사이에 탭으로 구분되는 구조가 하나의 샘플입니다. 그리고 이와 같은 형식의 약 21만개의 병렬 문장 샘플을 포함하고 있습니다. 해당 데이터를 다운받고, 읽고, 전처리를 진행해보겠습니다.

import os
import urllib3
import zipfile
import shutil

import pandas as pd
import tensorflow as tf
from tensorflow.keras.preprocessing.sequence import pad_sequences
from tensorflow.keras.utils import to_categorical


http = urllib3.PoolManager()
url ='http://www.manythings.org/anki/fra-eng.zip'
filename = 'fra-eng.zip'
path = os.getcwd()
zipfilename = os.path.join(path, filename)
with http.request('GET', url, preload_content=False) as r, open(zipfilename, 'wb') as out_file:       
    shutil.copyfileobj(r, out_file)

with zipfile.ZipFile(zipfilename, 'r') as zip_ref:
    zip_ref.extractall(path)


lines = pd.read_csv('fra.txt', names=['src', 'tar', 'lic'], sep='\t')
del lines['lic']
print('전체 샘플의 개수 :',len(lines))
[output]
전체 샘플의 개수 : 217975


해당 데이터는 약 21만 7천개의 병렬 문장 샘플로 구성되어있지만 여기서는 간단히 60,000개의 샘플만 가지고 기계 번역기를 구축해보도록 하겠습니다. 우선 전체 데이터 중 60,000개의 샘플만 저장하고 현재 데이터가 어떤 구성이 되었는지 확인해보겠습니다.

lines = lines.loc[:, 'src':'tar']
lines = lines[0:60000] # 6만개만 저장
lines.sample(10)

image


위의 테이블은 랜덤으로 선택된 10개의 샘플을 보여줍니다. 번역 문장에 해당되는 프랑스어 데이터 는 시작을 의미하는 심볼 <sos> 과 종료를 의미하는 심볼 <eos> 을 넣어주어야 합니다. 여기서는 Character‐Level 이므로 <sos><eos> 대신 ‘\t’ 를 시작 심볼, ‘\n’ 을 종료 심볼로 간주하여 추가하고 다시 데이터를 출력해보겠습니다.

lines.tar = lines.tar.apply(lambda x : '\t ' + x + ' \n')
lines.sample(10)

image


프랑스어 데이터에서 시작 심볼과 종료 심볼이 추가된 것 을 볼 수 있습니다. 문자 집합을 생성 하고 문자 집합의 크기 를 보겠습니다. 단어 집합이 아니라 문자 집합이라고 하는 이유는 토큰 단위가 단어가 아니라 문자이기 때문 입니다.

# 글자 집합 구축
src_vocab = set()
for line in lines.src:  # 1줄씩 읽음
    for char in line:   # 1개의 글자씩 읽음
        src_vocab.add(char)

tar_vocab = set()
for line in lines.tar:
    for char in line:
        tar_vocab.add(char)

src_vocab_size = len(src_vocab) + 1
tar_vocab_size = len(tar_vocab) + 1
print('source 문장의 char 집합 :', src_vocab_size)
print('target 문장의 char 집합 :', tar_vocab_size)
[output]
source 문장의 char 집합 : 80
target 문장의 char 집합 : 103


영어와 프랑스어는 각각 80개와 103개의 문자가 존재 합니다. 이 중에서 인덱스를 임의로 부여하여 일부만 출력하겠습니다. set() 함수 안에 문자들이 있으므로 현 상태에서 인덱스를 사용하려고하면 에러가 납니다. 하지만 정렬하여 순서를 정해준 뒤에 인덱스를 사용하여 출력해주면 됩니다.

src_vocab = sorted(list(src_vocab))
tar_vocab = sorted(list(tar_vocab))
print(src_vocab[45:75])
print(tar_vocab[45:75])
[output]
['U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x']
['Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't']


문자 집합에 문자 단위로 저장된 것을 확인할 수 있습니다. 각 문자에 인덱스를 부여 하겠습니다.

src_to_index = dict([(word, i+1) for i, word in enumerate(src_vocab)])
tar_to_index = dict([(word, i+1) for i, word in enumerate(tar_vocab)])
print(src_to_index)
print(tar_to_index)
[output]
{' ': 1, '!': 2, '"': 3, '$': 4, '%': 5, ... 중략 ..., '—': 85, '‘': 86, '’': 87, '₂': 88, '€': 89}
{'\t': 1, '\n': 2, ' ': 3, '!': 4, '"': 5, ... 중략 ..., '’': 111, '…': 112, '\u202f': 113, '‽': 114, '₂': 115}


인덱스가 부여된 문자 집합으로부터 갖고있는 훈련 데이터에 정수 인코딩을 수행 합니다. 우선 인코더의 입력이 될 영어 문장 샘플에 대해서 정수 인코딩을 수행해보고, 5개의 샘플을 출력해보겠습니다. 또한 확인차 훈련 데이터 5개도 출력하여 매칭되는지 확인하겠습니다.

lines.src[:5]
[output]
0    Go.
1    Go.
2    Go.
3    Go.
4    Hi.


encoder_input = []

# 1개의 문장
for line in lines.src:
    encoded_line = []
    # 각 줄에서 1개의 char
    for char in line:
        # 각 char을 정수로 변환
        encoded_line.append(src_to_index[char])
    encoder_input.append(encoded_line)

print('source 문장의 정수 인코딩 :', encoder_input[:5])
[output]
source 문장의 정수 인코딩 : [[32, 66, 11], [32, 66, 11], [32, 66, 11], [32, 66, 11], [33, 60, 11]]


정수 인코딩이 수행된 것을 볼 수 있습니다. 디코더의 입력이 될 프랑스어 데이터에 대해서 정수 인코딩을 수행 해보겠습니다.

lines.tar[:5]
[output]
0          \t Va ! \n
1       \t Marche. \n
2    \t En route ! \n
3       \t Bouge ! \n
4       \t Salut ! \n


decoder_input = []

for line in lines.tar:
    encoded_line = []
    for char in line:
        encoded_line.append(tar_to_index[char])
    decoder_input.append(encoded_line)

print('target 문장의 정수 인코딩 :', decoder_input[:5])
[output]
target 문장의 정수 인코딩 : [[1, 3, 51, 56, 3, 4, 3, 2], [1, 3, 42, 56, 73, 58, 63, 60, 15, 3, 2], [1, 3, 34, 69, 3, 73, 70, 76, 75, 60, 3, 4, 3, 2], [1, 3, 31, 70, 76, 62, 60, 3, 4, 3, 2], [1, 3, 48, 56, 67, 76, 75, 3, 4, 3, 2]]


정상적으로 정수 인코딩이 수행된 것을 볼 수 있습니다. 아직 정수 인코딩을 수행해야 할 데이터가 하나 더 남았습니다. 디코더의 예측값과 비교하기 위한 실제값이 필요합니다. 하지만 이 실제값에는 시작 심볼에 해당되는 <sos> 가 있을 필요가 없습니다. 그래서 이번에는 정수 인코딩 과정에서 <sos> 를 제거합니다. 즉, 모든 프랑스어 문장의 맨 앞에 붙어있는 ‘\t’를 제거 하도록 합니다.

decoder_target = []

for line in lines.tar:
    timestep = 0
    encoded_line = []
    for char in line:
        if timestep > 0:
            encoded_line.append(tar_to_index[char])
        timestep = timestep + 1
    decoder_target.append(encoded_line)
    
print('target 문장 레이블의 정수 인코딩 :', decoder_target[:5])
[output]
target 문장 레이블의 정수 인코딩 : [[3, 51, 56, 3, 4, 3, 2], [3, 42, 56, 73, 58, 63, 60, 15, 3, 2], [3, 34, 69, 3, 73, 70, 76, 75, 60, 3, 4, 3, 2], [3, 31, 70, 76, 62, 60, 3, 4, 3, 2], [3, 48, 56, 67, 76, 75, 3, 4, 3, 2]]


앞서 먼저 만들었던 디코더의 입력값에 해당되는 decoder_input 데이터와 비교하면 decoder_input에서는 모든 문장의 앞에 붙어있던 숫자 1이 decoder_target에서는 제거된 것을 볼 수 있습니다. ‘\t’ 가 인덱스가 1이므로 정상적으로 제거된 것입니다.

모든 데이터에 대해서 정수 인덱스로 변경하였으니 패딩 작업을 수행 합니다. 패딩을 위해서 영어 문장과 프랑스어 문장 각각에 대해서 가장 길이가 긴 샘플의 길이를 확인합니다.

max_src_len = max([len(line) for line in lines.src])
max_tar_len = max([len(line) for line in lines.tar])
print('source 문장의 최대 길이 :', max_src_len)
print('target 문장의 최대 길이 :', max_tar_len)
[output]
source 문장의 최대 길이 : 22
target 문장의 최대 길이 : 76


각각 22와 76의 길이를 가집니다. 이번 병렬 데이터는 영어와 프랑스어의 길이는 하나의 쌍이라고 하더라도 전부 다르므로 패딩을 할 때도 이 두 개의 데이터의 길이를 전부 동일하게 맞춰줄 필요는 없습니다. 영어 데이터는 영어 샘플들끼리, 프랑스어는 프랑스어 샘플들끼리 길이를 맞추어서 패딩 하면 됩니다. 여기서는 가장 긴 샘플의 길이에 맞춰서 영어 데이터의 샘플은 전부 길이가 22이 되도록 패딩하고, 프랑스어 데이터의 샘플은 전부 길이가 76이 되도록 패딩합니다.

encoder_input = pad_sequences(encoder_input, maxlen=max_src_len, padding='post')
decoder_input = pad_sequences(decoder_input, maxlen=max_tar_len, padding='post')
decoder_target = pad_sequences(decoder_target, maxlen=max_tar_len, padding='post')

print('shape of encoder_input : ', encoder_input.shape)
print('shape of decoder_input : ', decoder_input.shape)
print('shape of decoder_target : ', decoder_target.shape)
[output]
shape of encoder_input :  (60000, 22)
shape of decoder_input :  (60000, 76)
shape of decoder_target :  (60000, 76)


모든 값에 대해서 원-핫 인코딩을 수행합니다. 문자 단위 번역기므로 워드 임베딩은 별도로 사용되지 않으며, 예측값과의 오차 측정에 사용되는 실제값뿐만 아니라 입력값도 원-핫 벡터를 사용 하겠습니다.

encoder_input = to_categorical(encoder_input)
decoder_input = to_categorical(decoder_input)
decoder_target = to_categorical(decoder_target)

print('shape of encoder_input : ', encoder_input.shape)
print('shape of decoder_input : ', decoder_input.shape)
print('shape of decoder_target : ', decoder_target.shape)
[output]
shape of encoder_input :  (60000, 22, 80)
shape of decoder_input :  (60000, 76, 103)
shape of decoder_target :  (60000, 76, 103)


데이터에 대한 전처리가 모두 끝났습니다. 본격적으로 seq2seq 모델을 설계 해보겠습니다.


2. 교사 강요(Teacher forcing)

모델 설계에 있어서 decoder_input이 필요한 이유 를 살펴보겠습니다.

테스트 과정에서 현재 시점의 디코더 셀의 입력은 오직 이전 디코더 셀의 출력을 입력으로 받습니다. 하지만 훈련 과정 에서는 이전 시점의 디코더 셀의 출력을 현재 시점의 디코더 셀의 입력으로 넣어주지 않고, 이전 시점의 실제값을 현재 시점의 디코더 셀의 입력값으로 하는 방법을 사용 합니다.


이유이전 시점의 디코더 셀의 예측이 틀렸는데 이를 현재 시점의 디코더 셀의 입력으로 사용하면 현재 시점의 디코더 셀의 예측도 잘못될 가능성이 높고 이는 연쇄 작용으로 디코더 전체의 예측을 어렵게 합니다. 이런 상황이 반복되면 훈련 시간이 느려집니다.

만약 이 상황을 원하지 않는다면 이전 시점의 디코더 셀의 예측값 대신 실제값을 현재 시점의 디코더 셀의 입력으로 사용하는 방법을 사용할 수 있습니다. 이와 같이 RNN의 모든 시점에 대해서 이전 시점의 예측값 대신 실제값을 입력으로 주는 방법교사 강요 라고 합니다.


3. seq2seq 기계 번역기 훈련시키기

seq2seq 모델을 설계 하고 교사 강요를 사용 하여 훈련시켜보도록 하겠습니다.

import numpy as np
from tensorflow.keras.layers import Input, LSTM, Embedding, Dense
from tensorflow.keras.models import Model

# src_vocab_size=80
# shape of encoder_inputs : (None, None, 80)
encoder_inputs = Input(shape=(None, src_vocab_size))
encoder_lstm = LSTM(units=256, return_state=True)

# encoder_outputs은 여기서는 불필요
# shape of encoder_outputs : (None, 256)
# shape of state_h : (None, 256)
# shape of state_c : (None, 256)
encoder_outputs, state_h, state_c = encoder_lstm(encoder_inputs)

# LSTM은 바닐라 RNN과는 달리 상태가 두 개. 은닉 상태와 셀 상태.
encoder_states = [state_h, state_c]


인코더를 보면 기본 LSTM 설계와 크게 다르지는 않습니다. 우선 LSTM 의 은닉 상태 크기는 256으로 선택하였으며, 인코더의 내부 상태를 디코더로 넘겨주어야 하기 때문에 return_state=True로 설정 합니다.


인코더(encoder_lstm) 에 입력을 넣으면 내부 상태, 즉 LSTM 에서 state_h, state_c 를 리턴받는데, 이는 각각 은닉 상태셀 상태 에 해당됩니다. 은닉 상태셀 상태 두 가지를 전달한다고 생각하면 됩니다. 이 두 가지 상태를 encoder_states에 저장 합니다.

encoder_states를 디코더에 전달하므로서 이 두 가지 상태 모두를 디코더로 전달 합니다. 이것이 컨텍스트 벡터 입니다.

# tar_vocab_size=103
# shape of decoder_inputs : (None, None, 103)
decoder_inputs = Input(shape=(None, tar_vocab_size))
decoder_lstm = LSTM(units=256, return_sequences=True, return_state=True)

# 디코더에게 인코더의 은닉 상태, 셀 상태를 전달.
# shape of decoder_outputs : (None, None, 256)
decoder_outputs, _, _ = decoder_lstm(decoder_inputs, initial_state=encoder_states)

# shape of decoder_outputs : (None, None, 103)
decoder_softmax_layer = Dense(tar_vocab_size, activation='softmax')
decoder_outputs = decoder_softmax_layer(decoder_outputs)


디코더는 인코더의 마지막 은닉 상태를 초기 은닉 상태로 사용 합니다. 위에서 initial_state 의 인자값으로 encoder_states 를 주는 코드가 이에 해당됩니다. 또한 동일하게 디코더의 은닉 상태 크기도 256으로 주었습니다.

디코더도 은닉 상태, 셀 상태를 리턴하기는 하지만 훈련 과정에서는 사용하지 않습니다. 그 후 출력층에 프랑스어의 단어 집합의 크기만큼 뉴런을 배치한 후 소프트맥스 함수를 사용하여 실제값과의 오차를 구합니다.

model = Model([encoder_inputs, decoder_inputs], decoder_outputs)

model.compile(
    optimizer="rmsprop",
    loss="categorical_crossentropy"
)

model.fit(
    x=[encoder_input, decoder_input],
    y=decoder_target,
    batch_size=64,
    epochs=50,
    validation_split=0.2
)
[output]
Epoch 1/50
750/750 [==============================] - 17s 20ms/step - loss: 0.7379 - val_loss: 0.6436
Epoch 2/50
750/750 [==============================] - 16s 21ms/step - loss: 0.4455 - val_loss: 0.5092
Epoch 3/50
750/750 [==============================] - 15s 20ms/step - loss: 0.3706 - val_loss: 0.4474
Epoch 4/50
750/750 [==============================] - 15s 21ms/step - loss: 0.3287 - val_loss: 0.4096
.
.
.
Epoch 47/50
750/750 [==============================] - 15s 19ms/step - loss: 0.1198 - val_loss: 0.4011
Epoch 48/50
750/750 [==============================] - 16s 21ms/step - loss: 0.1186 - val_loss: 0.4044
Epoch 49/50
750/750 [==============================] - 15s 21ms/step - loss: 0.1175 - val_loss: 0.4063
Epoch 50/50
750/750 [==============================] - 15s 20ms/step - loss: 0.1163 - val_loss: 0.4115


입력 으로는 인코더 입력디코더 입력 이 들어가고, 디코더의 실제값인 decoder_target 도 필요합니다. 배치 크기는 64로 하였으며 총 50 에포크를 학습합니다.

위에서 설정한 은닉 상태의 크기와 에포크 수는 실제로는 훈련 데이터에 과적합 상태를 불러오지만, 여기서는 우선 seq2seq의 메커니즘과 짧은 문장과 긴 문장에 대한 성능 차이에 대한 확인을 중점으로 두고 훈련 데이터에 과적합 된 상태로 동작 단계로 넘어갑니다.


4. seq2seq 기계 번역기 동작시키기

앞서 seq2seq는 훈련할 때와 동작할 때의 방식이 다르다고 언급한 바 있습니다. 이번에는 입력한 문장에 대해서 기계 번역을 하도록 모델을 조정하고 동작 시켜보도록 하겠습니다.

전체적인 번역 동작 단계를 정리하면 아래와 같습니다.

  1. 번역하고자 하는 입력 문장이 인코더에 들어가서 은닉 상태와 셀 상태를 얻습니다.
  2. 상태와 <SOS> 에 해당하는 ‘\t’ 를 디코더로 보냅니다.
  3. 디코더가 <EOS> 에 해당하는 ‘\n’ 이 나올 때까지 다음 문자를 예측하는 행동을 반복합니다.


우선 인코더를 정의합니다. encoder_inputsencoder_states 는 훈련 과정에서 이미 정의한 것들을 재사용하는 것입니다.

encoder_model = Model(inputs=encoder_inputs, outputs=encoder_states)
encoder_model.summary()
[output]
Model: "model_1"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 input_1 (InputLayer)        [(None, None, 80)]        0         
                                                                 
 lstm (LSTM)                 [(None, 256),             345088    
                              (None, 256),                       
                              (None, 256)]                       
                                                                 
=================================================================
Total params: 345,088
Trainable params: 345,088
Non-trainable params: 0
_________________________________________________________________


디코더를 설계 해보겠습니다.

# 이전 시점의 상태들을 저장하는 텐서
decoder_state_input_h = Input(shape=(256,))
decoder_state_input_c = Input(shape=(256,))
decoder_states_inputs = [decoder_state_input_h, decoder_state_input_c]

# 문장의 다음 단어를 예측하기 위해서 초기 상태(initial_state)를 이전 시점의 상태로 사용.
# 뒤의 함수 decode_sequence()에 동작을 구현 예정
# shape of decoder_inputs : (None, None, 103)
# shape of decoder_outputs : (None, None, 256)
# shape of state_h : (None, 256)
# shape of state_c : (None, 256)
decoder_outputs, state_h, state_c = decoder_lstm(decoder_inputs, initial_state=decoder_states_inputs)

# 훈련 과정에서와 달리 LSTM의 리턴하는 은닉 상태와 셀 상태를 버리지 않음.
# shape of decoder_outputs : (None, None, 103)
decoder_states = [state_h, state_c]
decoder_outputs = decoder_softmax_layer(decoder_outputs)

decoder_model = Model(
    inputs=[decoder_inputs] + decoder_states_inputs,
    outputs=[decoder_outputs] + decoder_states
)
decoder_model.summary()
[output]
Model: "model_2"
__________________________________________________________________________________________________
 Layer (type)                   Output Shape         Param #     Connected to                     
==================================================================================================
 input_2 (InputLayer)           [(None, None, 103)]  0           []                               
                                                                                                  
 input_3 (InputLayer)           [(None, 256)]        0           []                               
                                                                                                  
 input_4 (InputLayer)           [(None, 256)]        0           []                               
                                                                                                  
 lstm_1 (LSTM)                  [(None, None, 256),  368640      ['input_2[0][0]',                
                                 (None, 256),                     'input_3[0][0]',                
                                 (None, 256)]                     'input_4[0][0]']                
                                                                                                  
 dense (Dense)                  (None, None, 103)    26471       ['lstm_1[1][0]']                 
                                                                                                  
==================================================================================================
Total params: 395,111
Trainable params: 395,111
Non-trainable params: 0
__________________________________________________________________________________________________


index_to_src = dict((i, char) for char, i in src_to_index.items())
index_to_tar = dict((i, char) for char, i in tar_to_index.items())


단어로부터 인덱스를 얻는 것이 아니라 인덱스로부터 단어를 얻을 수 있는 index_to_srcindex_to_tar 를 만들었습니다.

def decode_sequence(input_seq):
    # 입력으로부터 인코더의 상태를 얻음
    states_value = encoder_model.predict(input_seq)

    # <SOS>에 해당하는 원-핫 벡터 생성
    target_seq = np.zeros((1, 1, tar_vocab_size))
    target_seq[0, 0, tar_to_index['\t']] = 1.

    stop_condition = False
    decoded_sentence = ""

    # stop_condition이 True가 될 때까지 루프 반복
    while not stop_condition:
        # 이점 시점의 상태 states_value를 현 시점의 초기 상태로 사용
        output_tokens, h, c = decoder_model.predict([target_seq] + states_value)

        # 예측 결과를 문자로 변환
        sampled_token_index = np.argmax(output_tokens[0, -1, :])
        sampled_char = index_to_tar[sampled_token_index]

        # 현재 시점의 예측 문자를 예측 문장에 추가
        decoded_sentence += sampled_char

        # <eos>에 도달하거나 최대 길이를 넘으면 중단.
        if (sampled_char == '\n' or
            len(decoded_sentence) > max_tar_len):
            stop_condition = True

        # 현재 시점의 예측 결과를 다음 시점의 입력으로 사용하기 위해 저장
        target_seq = np.zeros((1, 1, tar_vocab_size))
        target_seq[0, 0, sampled_token_index] = 1.

        # 현재 시점의 상태를 다음 시점의 상태로 사용하기 위해 저장
        states_value = [h, c]

    return decoded_sentence
for seq_index in [3, 50, 100, 300, 1001]: # 입력 문장의 인덱스
    input_seq = encoder_input[seq_index:seq_index+1]
    decoded_sentence = decode_sequence(input_seq)
    
    print(35 * "-")
    print('입력 문장:', lines.src[seq_index])
    print('정답 문장:', lines.tar[seq_index][2:len(lines.tar[seq_index])-1]) # '\t'와 '\n'을 빼고 출력
    print('번역 문장:', decoded_sentence[1:len(decoded_sentence)-1]) # '\n'을 빼고 출력
[output]
-----------------------------------
입력 문장: Go.
정답 문장: Bouge ! 
번역 문장: Décampe ! 
-----------------------------------
입력 문장: Hello!
정답 문장: Bonjour ! 
번역 문장: Bonjour ! 
-----------------------------------
입력 문장: Got it!
정답 문장: Compris ! 
번역 문장: Compris ! 
-----------------------------------
입력 문장: Goodbye.
정답 문장: Au revoir. 
번역 문장: Casse-toi. 
-----------------------------------
입력 문장: Hands off.
정답 문장: Pas touche ! 
번역 문장: Va ! 


지금까지 문자 단위의 seq2seq를 구현하였습니다.

Read more

시퀀스-투-시퀀스(Sequence-to-Sequence, seq2seq) 이해하기

|

RNN을 이용하면 다 대 일(many-to-one) 구조로 텍스트 분류를 풀 수 있고, 다 대 다(many-to-many) 구조로는 개체명 인식이나 품사 태깅과 같은 문제를 풀 수 있습니다. 하지만 이번에 살펴볼 RNN의 구조는 바닐라 RNN과 다소 차이가 있는데, 하나의 RNN을 인코더. 또 다른 하나의 RNN을 디코더라는 모듈로 명명하고 두 개의 RNN을 연결해서 사용하는 인코더-디코더 구조입니다.

이러한 인코더-디코더 구조는 주로 입력 문장과 출력 문장의 길이가 다를 경우에 사용하는데, 대표적인 분야가 번역기나 텍스트 요약과 같은 경우가 있습니다. 영어 문장을 한국어 문장으로 번역한다고 하였을 때, 입력 문장인 영어 문장과 번역된 결과인 한국어 문장의 길이는 똑같을 필요가 없습니다. 텍스트 요약의 경우에는 출력 문장이 요약된 문장이므로 입력 문장보다는 당연히 길이가 짧을 것입니다.



시퀀스-투-시퀀스(Sequence-to-Sequence, seq2seq)

시퀀스-투-시퀀스(Sequence-to-Sequence, seq2seq)입력된 시퀀스로부터 다른 도메인의 시퀀스를 출력 하는 다양한 분야에서 사용되는 Encoder-Decoder 모델 입니다. 문자 그대로 인코더는 입력 데이터를 인코딩(부호화)하고, 디코더는 인코딩된 데이터를 디코딩(복호화), 즉 인코더는 입력을 처리하고 디코더는 결과를 생성 합니다.

  • 챗봇(Chatbot)
    • 입력 시퀀스와 출력 시퀀스를 각각 질문과 대답으로 구성하면 챗봇으로 만들 수 있습니다.
  • 기계 번역(Machine Translation)
    • 입력 시퀀스와 출력 시퀀스를 각각 입력 문장과 번역 문장으로 만들면 번역기로 만들 수 있습니다.
  • 내용 요약(Text Summarization)
  • STT(Speech to Text) 등


seq2seq 는 번역기에서 대표적으로 사용되는 모델로, 기본적으로 RNN을 어떻게 조립했느냐에 따라서 seq2seq라는 구조가 만들어집니다. 기계 번역을 예제로 시퀀스-투-시퀀스를 설명하겠습니다.

image


위 그림은 seq2seq 모델로 만들어진 번역기가 ‘I am a student’라는 영어 문장을 입력 받아서, ‘je suis étudiant’라는 프랑스 문장을 출력 하는 모습을 보여줍니다. 그렇다면, seq2seq 모델 내부의 모습은 어떻게 구성되어있는지 보겠습니다.

image


seq2seq 는 크게 인코더(Encoder) 와 디코더(Decoder)라는 두 개의 모듈로 구성 됩니다.

인코더와 디코더 각각의 역할을 살펴보면, 인코더는 ‘I am a student’라는 입력 문장을 받아 Context 벡터를 만듭니다. Context 벡터는 ‘I am a student’에 대한 정보를 압축하고 있으며 컴퓨터가 이해할 수 있는 숫자로 이루어진 벡터인데, Context 벡터는 다시 디코더로 전달되며 디코더는 이를 활용하여 최종적으로 ‘je suis étudiant’라는 불어 문장을 생성합니다. 즉, 인코더는 문장을 가지고 Context 벡터를 만들어주는데, 이 Context 벡터에는 문장에 대한 정보가 응축되어 있습니다. 반면 디코더는 정보가 응축되어 있는 Context 벡터로부터 다른 문장을 생성해줍니다.

image


인코더 아키텍처와 디코더 아키텍처의 내부는 두 개의 RNN 아키텍처 입니다. 물론, RNN의 성능 문제인 Gradient Vanishing 으로 인해 앞 정보가 뒤로 온전히 전달되지 못하므로 주로 LSTM 셀 또는 GRU 셀 들로 구성됩니다.

  • 인코더(Encoder)
    • 입력 문장을 받는 RNN 셀
    • 입력 문장은 단어 토큰화를 통해서 단어 단위로 쪼개지고, 단어 토큰 각각은 RNN 셀의 각 시점의 입력이 됩니다.
    • 인코더 RNN 셀은 모든 단어를 입력받은 뒤에 인코더 RNN 셀의 마지막 시점의 은닉 상태인 컨텍스트 벡터를 디코더 RNN 셀로 넘겨주며, 컨텍스트 벡터는 디코더 RNN 셀의 첫번째 은닉 상태에 사용됩니다.
    • 입력 문장의 정보가 하나의 컨텍스트 벡터로 모두 압축되면 인코더는 컨텍스트 벡터를 디코더로 전송 합니다.
  • 컨텍스트 벡터(context vector)
    • Encoder에서 입력 문장의 모든 단어들을 순차적으로 입력받은 뒤에 마지막에 이 모든 단어 정보들이 압축된 하나의 벡터
    • 입력 문장의 정보가 하나의 컨텍스트 벡터로 모두 압축되므로 모든 인코더 셀의 과거 정보를 담고 있을 것 입니다.
    • 컨텍스트 벡터는 디코더 RNN 셀의 첫번째 은닉 상태에 사용 됩니다.
  • 디코더(Decoder)
    • 출력 문장을 출력하는 RNN 셀
    • 컨텍스트 벡터를 받아서 번역된 단어를 한 개씩 순차적으로 출력 합니다.


디코더 는 기본적으로 RNNLM(RNN Language Model), 즉 입력 문장을 통해 출력 문장을 예측하는 언어 모델 형식입니다. 위 그림에서 <sos> 는 문장의 시작(start of string)을 뜻하고 <eos> 는 문장의 끝(end of string)을 뜻합니다.

디코더에 인코더로부터 전달받은 Context 벡터와 <sos> 가 입력 되면, 다음에 등장할 확률이 높은 단어를 예측 합니다. 첫번째 시점(time step)의 디코더 RNN 셀은 다음에 등장할 단어로 je를 예측하고, 예측된 단어 je를 다음 시점의 RNN 셀의 입력으로 입력하며. 이후 두번째 시점의 디코더 RNN 셀은 입력된 단어 je로부터 다시 다음에 올 단어인 suis를 예측하고, 또 다시 이것을 다음 시점의 RNN 셀의 입력으로 보냅니다. 디코더는 이런 식으로 기본적으로 다음에 올 단어를 예측하고, 그 예측한 단어를 다음 시점의 RNN 셀의 입력으로 넣는 행위를 반복하고, 문장의 끝을 의미하는 \<eos\> 가 다음 단어로 예측될 때까지 반복 됩니다.


하지만 이는 모델의 학습 후 Test 단계 에서의 디코더 작동 원리입니다. Training 단계 에서는 교사 강요(teacher forcing) 방식으로 디코더 모델을 훈련시킵니다. 즉, seq2seq는 훈련 과정과 테스트 과정의 작동 방식이 조금 다릅니다.

디코더의 훈련 과정 을 살펴보면 디코더에게 인코더가 보낸 컨텍스트 벡터와 실제 정답인 상황인 ‘<sos> je suis étudiant’를 입력 받았을 때, ‘je suis étudiant <eos>‘가 나와야 된다고 정답을 알려주면서 훈련합니다. 반면 디코더의 테스트 과정 은 앞서 설명한 과정과 같이 디코더는 오직 컨텍스트 벡터와 ‘' 만을 입력으로 받은 후에 다음에 올 단어를 예측하고, 그 단어를 다음 시점의 RNN 셀의 입력으로 넣는 행위를 반복합니다. **즉, 위 그림은 테스트 과정에 해당됩니다.**


교사 강요(teacher forcing)

교사 강요(teacher forcing) 란, 테스트 과정에서 $t$ 시점의 출력이 $t+1$ 시점의 입력으로 사용되는 RNN 모델을 훈련시킬 때 사용하는 훈련 기법 입니다.

훈련할 때 교사 강요를 사용 할 경우, 모델이 $t$ 시점에서 예측한 값을 $t+1$ 시점에 입력으로 사용하지 않고, $t$ 시점의 레이블. 즉, 실제 알고있는 정답을 $t+1$ 시점의 입력으로 사용합니다. 교사 강요를 사용하는 이유는 $(n-1)$ 스텝의 예측값이 실제값과 다를 수 있기 때문 입니다. 예측은 예측일 뿐 실제와 다를 수 있으므로, 따라서 정확한 데이터로 훈련하기 위해 예측값을 다음 스텝으로 넘기는 것이 아니라 실제값을 매번 입력값으로 사용하는 것입니다. 이런 방식을 교사 강요라고 합니다.


정리하면, 디코더의 훈련 단계에서는 교사 강요 방식으로 훈련하지만 테스트 단계에서는 일반적인 RNN 방식으로 예측합니다. 즉, 테스트 단계에서는 Context 벡터를 입력값으로 받아 이미 훈련된 디코더로 다음 단어를 예측하고, 그 단어를 다시 다음 스텝의 입력값으로 넣어준다. 이렇게 반복하여 최종 예측 문장을 생성하는 것입니다. 위 그림을 기준으로 디코더의 훈련 단계에서는 필요한 데이터가 Context 벡터와 <sos>, je, suis, étudiant이다. 하지만 테스트 단계에서는 Context 벡터와 <sos>만 필요합니다. 훈련 단계에서는 교사 강요를 하기 위해 <sos> 뿐만 아니라 je, suis, étudiant 모두가 필요한 것입니다. 하지만 테스트 단계에서는 Context 벡터와 <sos> 만으로 첫 단어를 예측하고, 그 단어를 다음 스텝의 입력으로 넣습니다.

이제 입출력에 쓰이는 단어 토큰들이 있는 부분을 더 세분화해서 살펴보겠습니다.

image


seq2seq에서 사용되는 모든 단어들은 임베딩 벡터로 변환 후 입력으로 사용됩니다. 위 그림은 모든 단어에 대해서 임베딩 과정을 거치게 하는 단계인 임베딩 층(embedding layer) 의 모습을 보여줍니다.

image


위 그림은 컨텍스트 벡터와 I, am, a, student라는 단어들에 대한 임베딩 벡터의 모습을 보여줍니다. RNN 셀에 대해서 확대해보겠습니다.

image

image


하나의 RNN 셀은 각각의 시점(time step)마다 두 개의 입력을 받습니다. 현재 시점(time step)을 $t$ 라고 할 때, RNN 셀은 $t-1$ 에서의 은닉 상태와 $t$ 에서의 입력 벡터를 입력으로 받고, $t$ 에서의 은닉 상태를 만듭니다. 이때 $t$ 에서의 은닉 상태는 바로 위에 또 다른 은닉층이나 출력층이 존재할 경우에는 위의 층으로 보내거나, 필요없으면 값을 무시할 수 있습니다. 그리고 RNN 셀은 다음 시점에 해당하는 t+1의 RNN 셀의 입력으로 현재 t에서의 은닉 상태를 입력으로 보냅니다.

이런 구조에서 현재 시점 $t$ 에서의 은닉 상태과거 시점의 동일한 RNN 셀에서의 모든 은닉 상태의 값들의 영향을 누적해서 받아온 값 이라고 할 수 있습니다. 그렇기 때문에 앞서 언급했던 컨텍스트 벡터 는 사실 인코더에서의 마지막 RNN 셀의 은닉 상태값을 말하는 것이며, 이는 입력 문장의 모든 단어 토큰들의 정보를 요약해서 담고있다 고 할 수 있습니다.


테스트 단계에서 디코더는 인코더의 마지막 RNN 셀의 은닉 상태인 컨텍스트 벡터를 첫번째 은닉 상태의 값으로 사용합니다. 디코더의 첫번째 RNN 셀은 이 첫번째 은닉 상태의 값과, 현재 $t$ 에서의 입력값인 <sos> 로 부터, 다음에 등장할 단어를 예측합니다. 그리고 이 예측된 단어는 다음 시점인 $t+1$ RNN에서의 입력값이 되고, 이 $t+1$ 에서의 RNN 또한 이 입력값과 $t$ 에서의 은닉 상태로부터 $t+1$ 에서의 출력 벡터. 즉, 또 다시 다음에 등장할 단어를 예측하게 될 것입니다.

디코더가 다음에 등장할 단어를 예측하는 부분을 확대해보겠습니다.

image


출력 단어로 나올 수 있는 단어들은 다양한 단어들이 있습니다. seq2seq 모델은 선택될 수 있는 모든 단어들로부터 하나의 단어를 골라서 예측해야 하며, 이를 예측하기 위해서 쓸 수 있는 함수 소프트맥스 함수를 사용할 수 있습니다. 디코더에서 각 시점(time step)의 RNN 셀에서 출력 벡터가 나오면, 해당 벡터는 소프트맥스 함수를 통해 출력 시퀀스의 각 단어별 확률값을 반환하고, 디코더는 출력 단어를 결정합니다.

지금까지 가장 기본적인 seq2seq 를 소개했으며, seq2seq 는 어떻게 구현하느냐에 따라서 충분히 더 복잡해질 수 있습니다.

  • 컨텍스트 벡터를 디코더의 초기 은닉 상태로만 사용
  • 컨텍스트 벡터를 디코더가 단어를 예측하는 매 시점마다 하나의 입력으로 사용
  • 어텐션 메커니즘 방법을 통해 지금 알고있는 컨텍스트 벡터보다 더욱 문맥을 반영할 수 있는 컨텍스트 벡터를 구하여 매 시점마다 하나의 입력으로 사용


seq2seq 를 정리하면 인코더와 디코더로 구성되어 있으며, 인코더는 입력 문장의 정보를 압축하는 기능을 합니다. 압축된 정보는 Context 벡터라는 형식으로 디코더에 전달됩니다. 디코더는 훈련 단계에서는 교사 방식(teaching force)으로 훈련되며, 테스트 단계에서는 인코더가 전달해준 Context 벡터와 <sos> 를 입력값으로 하여 단어를 예측하는 것을 반복하며 문장을 생성합니다.


Seq2Seq 모델의 한계

seq2seq 모델은 인코더 에서 입력 시퀀스를 컨텍스트 벡터라는 하나의 고정된 크기의 벡터 표현으로 압축 하고, 디코더이 컨텍스트 벡터를 통해서 출력 시퀀스를 만들어 냈습니다. 하지만 이러한 RNN에 기반한 seq2seq 모델에는 크게 두 가지 문제 가 있습니다.

  1. 입력 시퀸스의 모든 정보를 하나의 고정된 크기의 벡터(컨텍스트 벡터)에 다 압축 요약하려 하다 보니 정보의 손실이 생길 수밖에 없습니다. 특히 시퀸스의 길이가 길다면 정보의 손실이 더 커집니다.
  2. RNN 구조로 만들어진 모델이다 보니, 필연적으로 gradient vaninshing/exploding 현상이 발생합니다.

Read more

자모 단위의 한국어 FastText 이해와 실습

|

  1. FastText Review
  2. FastText Pre-training Review
  3. FastText Summaray
  4. 한국어 FastText
    • 4.1 음절 단위
    • 4.2 자모 단위
  5. 자모 단위 한국어 FastText 실습
    • 5.1 필요 패키지 설치
    • 5.2 네이버 쇼핑 리뷰 데이터 로드
    • 5.3 HGTK 튜토리얼
    • 5.4 자모 단위 토큰화(전처리)
    • 5.5 FastText 학습하기



1. FastText Review

FastTextWord2Vec의 개량 알고리즘으로 Subword를 고려한 알고리즘 입니다. Word2Vec 이후에 나온 것이기 때문에 메커니즘 자체는 Word2Vec 의 확장이라고 볼 수 있습니다.

예를 들어 eateating이 있다고 가정해보겠습니다. 훈련 데이터에서 eat 는 충분히 많이 등장해서, 학습이 충분히 잘 되었지만 eating은 잘 등장하지 않아서 제대로 된 임베딩 값을 얻지 못한다고 가정해보겠습니다.

image


즉, Word2Vec의 문제점 으로 OOV(Out-of-Vocabulary) 문제와, 하나의 단어에 고유한 벡터를 할당하므로 단어의 형태학적 특징을 반영할 수 없다는 문제 가 있습니다. 이때 FastText 의 아이디어는 eat 이라는 공통적인 내부 단어를 가지고 있는데 이를 활용할 수는 없을까 라는 의문에서 시작됩니다.

image


2. FastText Pre-training Review

FastText는 단어를 Character 단위의 n-gram으로 간주하며, $n$ 을 몇으로 하느냐에 따라서 단어가 얼마나 분리되는지가 결정 됩니다. 단어 ‘eating’을 예를 들어보겠습니다.

단어 eating에 시작과 끝을 의미하는 ‘<’와 ‘>’를 추가합니다.

image


n-gram을 기반으로 단어를 분리합니다. 이때 n = 3.

image


실제로는, 주로 n은 범위로 설정해줍니다. 이때 n = 3 ~ 6.

image


훈련 데이터를 N-gram의 셋으로 구성하였다면, 훈련 방법 자체는 SGNS(Skip-gram with Negative Sampleing)와 동일 합니다. 단, Word가 아니라 subwords들이 최종 학습 목표 이며, 이들의 합을 Word의 vector로 간주합니다.

image


의문점이 있을 수 있는데, 단어에 <, >를 해주는 이유 를 살펴보면 다음과 같습니다.

단어 양끝에 <, >를 해주지 않으면 실제로 독립적인 단어와 특정 단어의 n-gram인 경우를 구분하기 어렵습니다. 가령, where의 n-gram 중 하나인 her도 존재하지만 독립적인 단어 her 또한 Vocabulary에 존재할 수 있습니다. 이 때, 독립적인 단어 her는 ’<her>‘ 가 되므로서 where 내의 her와 구분할 수 있습니다.


이제 FastText의 훈련 과정을 이해해보겠습니다. 여기서는 SGNS(Skip-gram with Negative Sampleing)을 사용합니다. 현재의 목표는 중심 단어 eating으로부터 주변 단어 am과 food를 예측하는 것 입니다.

image


앞서 언급하였듯이 단어 eating은 아래와 같이 n-gram들의 합으로 나타냅니다.

image


우리가 해야하는 것은 eating으로부터 am과 food를 예측하는 것 입니다.

image


Negative Sampling이므로 실제 주변 단어가 아닌 단어들도 필요 합니다.

image


우리가 해야하는 것은 eating으로부터 am과 food를 예측하는 것이므로 eating과 am 그리고 eating과 food의 내적값에 시그모이드 함수를 지난 값은 1이 되도록 학습 하고, eating과 paris 그리고 eating과 earth의 내적값에 시그모이드 함수를 지난 값은 0이 되도록 학습 합니다.

image


이런 방법은 Word2Vec에서는 얻을 수 없었던 강점을 가집니다. 예를 들어 단어 Orange에 대해서 FastText를 학습했다고 해보겠습니다. n의 범위는 2-5로 하겠습니다.

image


그 후 Oranges라는 OOV 또는 희귀 단어가 등장했다고 해보겠습니다. Orange의 n-gram 벡터들을 이용하여 Oranges의 벡터값을 얻습니다.

image


또한 FastText는 오타에도 강건합니다.

image


3. FastText Summaray

결과적으로 Word2Vec 는 학습 데이터에 존재하지 않는 단어. 즉, 모르는 단어에 대해서는 임베딩 벡터가 존재하지 않기 때문에 단어의 유사도를 계산할 수 없습니다. 하지만 FastText는 유사한 단어를 계산해서 출력합니다. 정리하면 Word2Vec는 단어를 쪼개질 수 없는 단위로 생각한다면, FastText는 하나의 단어 안에도 여러 단어들이 존재하는 것으로 간주합니다. 내부 단어. 즉, 서브워드(subword)를 고려하여 학습 합니다.

FastText 는 다음과 같은 강점 을 가집니다.

  • 모르는 단어(Out Of Vocabulary, OOV)에 대한 대응
    • FastText의 인공 신경망을 학습한 후에는 데이터 셋의 모든 단어의 각 n-gram에 대해서 워드 임베딩이 됩니다. 만약 데이터 셋만 충분한다면 내부 단어(Subword)를 통해 모르는 단어(Out Of Vocabulary, OOV)에 대해서도 다른 단어와의 유사도를 계산할 수 있습니다.
    • 가령, FastText에서 birthplace(출생지)란 단어를 학습하지 않은 상태라고 했을때, 다른 단어에서 birth와 place라는 내부 단어가 있었다면, FastText는 birthplace의 벡터를 얻을 수 있습니다.
  • 단어 집합 내 빈도 수가 적었던 단어(Rare Word)에 대한 대응
    • Word2Vec의 경우에는 등장 빈도 수가 적은 단어(rare word)에 대해서는 임베딩의 정확도가 높지 않다는 단점이 있었는데, FastText의 경우, 만약 단어가 희귀 단어라도, 그 단어의 n-gram이 다른 단어의 n-gram과 겹치는 경우라면, Word2Vec과 비교하여 비교적 높은 임베딩 벡터값을 얻습니다.
  • 단어 집합 내 노이즈가 많은 코퍼스에 대한 대응
    • Word2Vec에서는 오타가 섞인 단어는 임베딩이 제대로 되지 않지만, FastText는 이에 대해서도 일정 수준의 성능을 보입니다.
    • 예를 들어 단어 apple과 오타로 p를 한 번 더 입력한 appple의 경우에는 실제로 많은 개수의 동일한 n-gram을 가질 것입니다.


충분히 잘 학습된 FastText는 전체 Word가 아니라 Subword들의 유사도를 반영함을 확인할 수 있습니다.

image


4. 한국어 FastText

한국어의 경우에도 OOV 문제를 해결하기 위해 FastText를 적용하고자 하는 시도들이 있었습니다.

image


대표적으로 음절 단위와 자-모 단위가 있습니다.


4.1 음절 단위

예를 들어서 음절 단위의 임베딩 의 경우에 n=3일때 ‘자연어처리’라는 단어에 대해 n-gram을 만들어보면 다음과 같습니다.

<자연, 자연어, 연어처, 어처리, 처리>


4.2 자모 단위

우선 한국어는 다양한 용언 형태를 가지는데, Word2Vec의 경우 다양한 용언 표현들이 서로 독립된 단어로 표현됩니다.

image


한국어의 경우에는 이를 대응하기 위해 한국어 FastText의 n-gram 단위 를 음절 단위가 아니라, 자모 단위(초성, 중성, 종성) 로 하기도 합니다. 자모 단위로 가게 되면 오타나 노이즈 측면에서 더 강한 임베딩을 기대 해볼 수 있습니다.

image


FastText는 하나의 단어에 대하여 벡터를 직접 학습하지 않습니다. 대신에 subwords의 벡터들을 바탕으로 word의 벡터를 추정합니다. 좀 더 자세히 말하자면 v(어디야)는 직접 학습되지 않습니다. 하지만 v(어디야)는 [v(어디), v(디야)]를 이용하여 추정됩니다. 즉 ‘어디야’라는 단어는 ‘어디’, ‘디야’라는 subwords를 이용하여 추정 되는 것입니다.

그런데, 이 경우에는 오탈자에 민감 하게 됩니다. ‘어딛야’ 같은 경우에는 [v(어딛), v(딛야)]를 이용하기 때문에 [v(어디), v(디야)]와 겹치는 subwords가 없어서 비슷한 단어로 인식되기가 어렵습니다. 한국어의 오탈자는 초/중/종성에서 한군데 정도가 틀리기 때문에 자음/모음을 풀어서 FastText를 학습하는게 좋습니다. 즉 어디야는 ‘ㅇㅓ_ㄷㅣ_ㅇㅑ_‘ 로 표현됩니다. 종성이 비어있을 경우에는 ‘_‘ 으로 표시하였습니다. FastText가 word를 학습할 때 띄어쓰기를 기준으로 나누기 때문입니다.


음절 단위의 단어를 다시 예시로 들면, ‘자연어처리’라는 단어에 대해서 초성, 중성, 종성을 분리하고, 만약, 종성이 존재하지 않는다면 ‘_‘라는 토큰을 사용한다고 가정했을때, ‘자연어처리’라는 단어는 아래와 같이 분리가 가능합니다.

ㅈㅏ_ㅇㅕㄴㅇㅓ_ㅊㅓ_ㄹㅣ_


그리고 분리된 결과에 대해서 n=3일 때, n-gram을 적용하여, 임베딩을 한다면 다음과 같습니다.

<ㅈㅏ, ㅈㅏ_, ㅏ_ㅇ, ... 중략>


5. 자모 단위 한국어 FastText 실습

네이버 쇼핑 리뷰 데이터를 이용하여 자모 단위 FastText를 학습해보겠습니다.


5.1 필요 패키지 설치

여기서는 형태소 분석기 Mecab 을 사용합니다. 본 실습은 Mecab 을 편하게 사용하기 위해서 구글의 Colab 을 사용하였습니다. 참고로 Colab 에서 실습하는 경우가 아니라면 아래의 방법으로 Colab 이 설치되지 않습니다.

!pip install konlpy
!pip install mecab-python
!bash <(curl -s https://raw.githubusercontent.com/konlpy/konlpy/master/scripts/mecab.sh)


한글 자모 단위 처리 패키지인 hgtk 를 설치합니다.

# 한글 자모 단위 처리 패키지 설치
!pip install hgtk


이번 실습에 사용할 패키지인 fasttext 를 설치합니다. gensimfasttext 와는 별도의 패키지입니다.

# fasttext 설치
!git clone https://github.com/facebookresearch/fastText.git
%cd fastText
!make
!pip install .


5.2 네이버 쇼핑 리뷰 데이터 로드


필요한 라이브러리를 불러오고, 네이버 쇼핑 리뷰 데이터를 다운하겠습니다.

import re
import pandas as pd
import urllib.request
from tqdm import tqdm
import hgtk
from konlpy.tag import Mecab

urllib.request.urlretrieve("https://raw.githubusercontent.com/bab2min/corpus/master/sentiment/naver_shopping.txt", filename="ratings_total.txt")
[output]
('ratings_total.txt', <http.client.HTTPMessage at 0x7f2e448b2820>)


위의 링크로부터 전체 데이터에 해당하는 ratings_total.txt 를 다운로드합니다. 해당 데이터에는 열제목 이 별도로 없습니다. 그래서 임의로 두 개의 열제목인 ‘ratings’ 와 ‘reviews’ 를 추가해주겠습니다.

total_data = pd.read_table('ratings_total.txt', names=['ratings', 'reviews'])
print('전체 리뷰 개수 :',len(total_data)) # 전체 리뷰 개수 출력
[output]
전체 리뷰 개수 : 200000


총 20 만개의 샘플이 존재합니다. 상위 5개의 샘플만 출력해보겠습니다.

total_data[:5]

image


5.3 HGTK 튜토리얼

한글의 자모를 처리하는 패키지인 hgtk 를 사용하기에 앞서 간단히 사용법을 익혀보겠습니다. hgtkchecker 를 사용하면 입력이 한글인지 아닌지를 판단하여 True 또는 False를 리턴합니다.

# 한글인지 체크
hgtk.checker.is_hangul('ㄱ')
[output]
True


# 한글인지 체크
hgtk.checker.is_hangul('28')
[output]
False


hgtkletter 를 사용하면 음절을 자모 단위로 분리하거나, 자모의 시퀀스를 다시 음절로 조합할 수 있습니다. 이는 각각 decomposecompose 로 가능합니다.

# 음절을 초성, 중성, 종성으로 분해
hgtk.letter.decompose('남')
[output]
('ㄴ', 'ㅏ', 'ㅁ')


# 초성, 중성을 결합
hgtk.letter.compose('ㄴ', 'ㅏ')
[output]
'나'


# 초성, 중성, 종성을 결합
hgtk.letter.compose('ㄴ', 'ㅏ', 'ㅁ')
[output]
'남'


한글이 아닌 입력이 들어오거나 음절로 조합할 수 없는 경우 NotHangulException 을 발생시킵니다.

# 한글이 아닌 입력에 대해서는 에러 발생.
hgtk.letter.decompose('1')
[output]
NotHangulException: 


# 결합할 수 없는 상황에서는 에러 발생
hgtk.letter.compose('ㄴ', 'ㅁ', 'ㅁ')
[output]
NotHangulException: No valid Hangul character index


5.4 자모 단위 토큰화(전처리)

위에서 사용했던 hgtk.letter.decompose() 를 사용하여 특정 단어가 들어오면 이를 초성, 중성, 종성으로 나누는 함수 word_to_jamo 를 구현하겠습니다. 단, 종성이 없는 경우에는 해당 위치에 종성이 없었다는 것을 표시해주기 위해서 종성의 위치에 특수문자 ‘‐’ 를 넣어주었습니다.

def word_to_jamo(token):
    def to_special_token(jamo):
        if not jamo:
            return '-'
        else:
            return jamo

    decomposed_token = ''
    for char in token:
        try:
            # char(음절)을 초성, 중성, 종성으로 분리
            cho, jung, jong = hgtk.letter.decompose(char)

            # 자모가 빈 문자일 경우 특수문자 -로 대체
            cho = to_special_token(cho)
            jung = to_special_token(jung)
            jong = to_special_token(jong)
            decomposed_token = decomposed_token + cho + jung + jong

        # 만약 char(음절)이 한글이 아닐 경우 자모를 나누지 않고 추가
        except Exception as exception:
            if type(exception).__name__ == 'NotHangulException':
                decomposed_token += char

    # 단어 토큰의 자모 단위 분리 결과를 추가
    return decomposed_token


해당 함수에 임의의 단어 ‘남동생’ 을 넣어 정상적으로 분리하는지 테스트해보겠습니다.

word_to_jamo('남동생')
[output]
'ㄴㅏㅁㄷㅗㅇㅅㅐㅇ'


‘남동생’ 이 ‘ㄴㅏㅁㄷㅗㅇㅅㅐㅇ’ 으로 분리된 것을 확인할 수 있습니다. 이번에는 임의의 단어 ‘여동생’을 넣어서 테스트해보겠습니다.

word_to_jamo('여동생')
[output]
'ㅇㅕ-ㄷㅗㅇㅅㅐㅇ'


‘여동생’의 경우 여에 종성이 없으므로 종성의 위치에 특수문자 ‘-‘가 대신 들어간 것을 확인할 수 있습니다. 단순 형태소 분석을 했을 경우와 형태소 분석 후에 다시 자모 단위로 분해하는 경우를 동일한 예문을 통해 비교해보겠습니다. 우선 단순 형태소 분석을 했을 경우입니다.

mecab = Mecab()
print(mecab.morphs('선물용으로 빨리 받아서 전달했어야 하는 상품이었는데 머그컵만 와서 당황했습니다.'))
[output]
['선물', '용', '으로', '빨리', '받', '아서', '전달', '했어야', '하', '는', '상품', '이', '었', '는데', '머그', '컵', '만', '와서', '당황', '했', '습니다', '.']


우리가 일반적으로 봐왔던 형태소 분석 결과입니다.

word_to_jamo 함수를 형태소 분석 후 호출하도록 하여 형태소 토큰들을 자모 단위로 분해하는 함수 tokenize_by_jamo 를 정의합니다. 이후 형태소 분석 후 자모 단위로 다시 한 번 분해한 경우입니다.

def tokenize_by_jamo(s):
    return [word_to_jamo(token) for token in mecab.morphs(s)]

print(tokenize_by_jamo('선물용으로 빨리 받아서 전달했어야 하는 상품이었는데 머그컵만 와서 당황했습니다.'))
[output]
['ㅅㅓㄴㅁㅜㄹ', 'ㅇㅛㅇ', 'ㅇㅡ-ㄹㅗ-', 'ㅃㅏㄹㄹㅣ-', 'ㅂㅏㄷ', 'ㅇㅏ-ㅅㅓ-', 'ㅈㅓㄴㄷㅏㄹ', 'ㅎㅐㅆㅇㅓ-ㅇㅑ-', 'ㅎㅏ-', 'ㄴㅡㄴ', 'ㅅㅏㅇㅍㅜㅁ', 'ㅇㅣ-', 'ㅇㅓㅆ', 'ㄴㅡㄴㄷㅔ-', 'ㅁㅓ-ㄱㅡ-', 'ㅋㅓㅂ', 'ㅁㅏㄴ', 'ㅇㅘ-ㅅㅓ-', 'ㄷㅏㅇㅎㅘㅇ', 'ㅎㅐㅆ', 'ㅅㅡㅂㄴㅣ-ㄷㅏ-', '.']


자모 단위 FastText 에서는 위와 같이 각 형태소 분석 결과 토큰들이 추가적으로 자모 단위로 분해된 토큰들을 가지고 학습을 하게 됩니다. 전체 데이터에 대해서 위의 자모 단위 토큰화를 적용하겠습니다.

from tqdm import tqdm

tokenized_data = []

for sample in total_data['reviews'].to_list():
    tokenzied_sample = tokenize_by_jamo(sample) # 자소 단위 토큰화
    tokenized_data.append(tokenzied_sample)


첫번째 샘플을 출력해보겠습니다.

tokenized_data[0]
[output]
['ㅂㅐ-ㄱㅗㅇ', 'ㅃㅏ-ㄹㅡ-', 'ㄱㅗ-', 'ㄱㅜㅅ']


‘배공빠르고 굿’이라는 기존 샘플이 형태소 분석 후에는 [‘배공’, ‘빠르’, ‘고’, ‘굿’]으로 분해되었으며, 이를 다시 자모 단위로 나누면서 [‘ㅂㅐ-ㄱㅗㅇ’, ‘ㅃㅏ-ㄹㅡ-‘, ‘ㄱㅗ-‘, ‘ㄱㅜㅅ’]라는 결과가 됩니다.

그런데 이렇게 바꾸고나니 원래 단어가 무엇이었는지 알아보기 힘들다는 문제가 있습니다. 출력했을 때, 사용자가 기존의 단어가 무엇이었는지를 쉽게 알아보기 위해 초성, 중성, 종성을 입력받으면 역으로 단어로 바꿔주는 jamo_to_word 함수를 구현합니다.

def jamo_to_word(jamo_sequence):
    tokenized_jamo = []
    index = 0

    # 1. 초기 입력
    # jamo_sequence = 'ㄴㅏㅁㄷㅗㅇㅅㅐㅇ'

    while index < len(jamo_sequence):
        # 문자가 한글(정상적인 자모)이 아닐 경우
        if not hgtk.checker.is_hangul(jamo_sequence[index]):
            tokenized_jamo.append(jamo_sequence[index])
            index = index + 1

        # 문자가 정상적인 자모라면 초성, 중성, 종성을 하나의 토큰으로 간주.
        else:
            tokenized_jamo.append(jamo_sequence[index:index + 3])
            index = index + 3

    # 2. 자모 단위 토큰화 완료
    # tokenized_jamo : ['ㄴㅏㅁ', 'ㄷㅗㅇ', 'ㅅㅐㅇ']

    word = ''
    try:
        for jamo in tokenized_jamo:

            # 초성, 중성, 종성의 묶음으로 추정되는 경우
            if len(jamo) == 3:
                if jamo[2] == "-":
                    # 종성이 존재하지 않는 경우
                    word = word + hgtk.letter.compose(jamo[0], jamo[1])
                else:
                    # 종성이 존재하는 경우
                    word = word + hgtk.letter.compose(jamo[0], jamo[1], jamo[2])
            # 한글이 아닌 경우
            else:
                word = word + jamo

    # 복원 중(hgtk.letter.compose) 에러 발생 시 초기 입력 리턴.
    # 복원이 불가능한 경우 예시) 'ㄴ!ㅁㄷㅗㅇㅅㅐㅇ'
    except Exception as exception:
        if type(exception).__name__ == 'NotHangulException':
            return jamo_sequence

    # 3. 단어로 복원 완료
    # word : '남동생'

    return word


해당 함수의 내부 동작 방식을 설명하기 위해 ‘ㄴㅏㅁㄷㅗㅇㅅㅐㅇ’이라는 임의의 입력이 들어왔을 때를 가정해보겠습니다.

초기 입력이 들어왔을 때는 jamo_sequence 라는 변수에 저장되어져 있습니다. while 문 내부에서는 jamo_sequences 의 각 문자에 대해서 세 개씩 분리하여 초성, 중성, 종성을 하나의 묶음으로 간주합니다. while문을 지나고나면 ‘ㄴㅏㅁㄷㅗㅇㅅㅐㅇ’이라는 문자열은 [‘ㄴㅏㅁ’, ‘ㄷㅗㅇ’, ‘ㅅㅐㅇ’]이라는 리스트로 변환이 되며, 해당 리스트는 tokenized_jamo 라는 변수에 저장됩니다. 그리고 각 리스트의 원소를 hgtk.letter.compose() 의 입력으로 넣어 기존의 음절로 복원합니다.


결과적으로 ‘남동생’이라는 단어로 복원되고 해당 함수는 ‘남동생’을 최종 결과로서 리턴합니다. 실제로 ‘ㄴㅏㅁㄷㅗㅇㅅㅐㅇ’을 입력으로 넣어 결과를 확인해보겠습니다.

jamo_to_word('ㄴㅏㅁㄷㅗㅇㅅㅐㅇ')
[output]
'남동생'


5.5 FastText 학습하기

자모 단위로 토큰화 된 데이터를 가지고 FastText 를 학습시켜보겠습니다.

import fasttext


FastText 학습을 위해서 기존 훈련 데이터를 txt 파일 형식으로 저장해야합니다.

with open('tokenized_data.txt', 'w') as out:
    for line in tqdm(tokenized_data, unit=' line'):
        out.write(' '.join(line) + '\n')
[output]
100%|██████████| 200000/200000 [00:00<00:00, 473187.85 line/s]


두 가지 모델 Skip-gramCBoWCBoW 를 선택했습니다.

model = fasttext.train_unsupervised('tokenized_data.txt', model='cbow')
model.save_model("fasttext.bin") # 모델 저장
model = fasttext.load_model("fasttext.bin") # 모델 로드


학습이 완료되었습니다. 임의로 ‘남동생’이라는 단어의 벡터값을 확인해보겠습니다. 주의할 점은 학습 시 자모 단위로 분해하였기 때문에 모델에서 벡터값을 확인할 때도 자모 단위로 분해 후에 입력으로 사용해야 합니다.

model[word_to_jamo('남동생')] # 'ㄴㅏㅁㄷㅗㅇㅅㅐㅇ'
[output]
array([ 2.85749108e-01,  5.32697797e-01,  9.72192764e-01, -1.74561024e-01,
       -9.40615535e-01, -8.52213144e-01,  1.84142113e-01,  7.23624051e-01,
       -6.02583587e-01, -4.90640759e-01,  9.03603256e-01,  2.96650290e-01,
       -2.10492730e-01,  6.45978987e-01, -5.46604753e-01,  6.41264975e-01,
        6.83590710e-01,  5.07914782e-01,  1.90157950e-01, -3.27531621e-02,
        5.80425084e-01, -5.27899086e-01,  6.99647903e-01, -1.41876325e-01,
        5.80996238e-02, -3.13640386e-01,  7.06844479e-02,  1.19922531e+00,
        9.48624730e-01, -7.08683491e-01,  5.12313426e-01, -8.10058236e-01,
        2.31832623e-01, -2.90871948e-01,  1.51137781e+00, -2.15766624e-01,
        4.38416228e-02, -2.49742463e-01,  9.85836610e-02,  1.48401380e-01,
        4.82708663e-01, -9.81113911e-02,  3.92483830e-01, -8.28986242e-02,
       -2.54946172e-01, -1.10600853e+00,  7.52483681e-03,  3.19441855e-01,
       -6.56547397e-02, -2.13221177e-01, -2.11511150e-01, -2.59903312e-01,
        1.69138134e-01,  1.49033908e-02, -9.99034107e-01, -3.28279957e-02,
        1.10627757e-02,  2.43498445e-01, -2.38837197e-01,  1.86610088e-01,
       -1.39049098e-01, -1.18185975e-01,  1.61835730e-01,  7.25804329e-01,
       -4.35180724e-01,  3.77287447e-01, -4.06595647e-01, -1.76645592e-01,
       -2.67820716e-01,  4.91925776e-01, -2.82297432e-01, -6.00573897e-01,
        4.94795799e-01,  1.35222033e-01, -1.17796496e-01, -7.76124895e-01,
        2.27492508e-02,  1.36140555e-01,  3.97971332e-01,  9.36240926e-02,
        8.48273218e-01,  7.88985193e-01,  5.37583753e-02,  6.32351160e-01,
        7.73415864e-01,  6.23026609e-01, -8.15240979e-01, -7.78561473e-01,
        7.49277830e-01,  1.29948840e-01,  6.60207570e-01, -4.03202087e-01,
       -6.72111869e-01, -9.39618289e-01, -8.69688034e-01,  8.82879972e-01,
       -1.33745838e-02,  4.36232805e-01, -2.32288629e-01, -1.67192949e-04],
      dtype=float32)


남동생 ‘벡터’와 가장 유사도가 높은 벡터들을 뽑아보겠습니다. 이는 get_nearest_neighbors() 를 사용하여 가능합니다. 두번째 인자인 k 값으로 10을 주면, 가장 유사한 벡터 상위 10개를 출력합니다.

model.get_nearest_neighbors(word_to_jamo('남동생'), k=10)
[output]
[(0.8671373724937439, 'ㄷㅗㅇㅅㅐㅇ'),
 (0.8345811367034912, 'ㄴㅏㅁㅊㅣㄴ'),
 (0.7394193410873413, 'ㄴㅏㅁㅍㅕㄴ'),
 (0.7316157817840576, 'ㅊㅣㄴㄱㅜ-'),
 (0.7173355221748352, 'ㅅㅐㅇㅇㅣㄹ'),
 (0.7168329358100891, 'ㄴㅏㅁㅇㅏ-'),
 (0.7005258202552795, 'ㅈㅗ-ㅋㅏ-'),
 (0.6888477802276611, 'ㅈㅜㅇㅎㅏㄱㅅㅐㅇ'),
 (0.6667895317077637, 'ㅇㅓㄴㄴㅣ-'),
 (0.6643229126930237, 'ㄴㅏㅁㅈㅏ-')]


그런데 출력으로 나오는 벡터들도 자모 단위로 분해해서 나오기 때문에 읽기가 어렵습니다. 이전에 만들어준 jamo_to_word 함수를 사용하여 출력 결과를 좀 더 깔끔하게 확인할 수 있습니다.

def transform(word_sequence):
    return [(jamo_to_word(word), similarity) for (similarity, word) in word_sequence]

지금부터 결과들을 나열해보겠습니다.

print(transform(model.get_nearest_neighbors(word_to_jamo('남동생'), k=10)))
[output]
[('동생', 0.8671373724937439), ('남친', 0.8345811367034912), ('남편', 0.7394193410873413), ('친구', 0.7316157817840576), ('생일', 0.7173355221748352), ('남아', 0.7168329358100891), ('조카', 0.7005258202552795), ('중학생', 0.6888477802276611), ('언니', 0.6667895317077637), ('남자', 0.6643229126930237)]


print(transform(model.get_nearest_neighbors(word_to_jamo('남동쉥'), k=10)))
[output]
[('남동생', 0.8909438252449036), ('남친', 0.8003354668617249), ('남매', 0.7774966955184937), ('남김', 0.7451346516609192), ('남긴', 0.7383974194526672), ('남짓', 0.7368336319923401), ('남녀', 0.7326962351799011), ('남아', 0.7286370992660522), ('남여', 0.7266424894332886), ('남길', 0.7219088077545166)]


print(transform(model.get_nearest_neighbors(word_to_jamo('남동셍ㅋ'), k=10)))
[output]
[('남동생', 0.8234370946884155), ('남친', 0.7265772819519043), ('남김', 0.7082480788230896), ('남길', 0.6784865260124207), ('남녀', 0.6686286330223083), ('남매', 0.6675403714179993), ('남여', 0.6633204817771912), ('남겼', 0.6621609926223755), ('남짓', 0.6599602103233337), ('남긴', 0.6571483016014099)]


print(transform(model.get_nearest_neighbors(word_to_jamo('난동생'), k=10)))
[output]
[('남동생', 0.8642392158508301), ('난생', 0.8244139552116394), ('남편', 0.8014969229698181), ('남친', 0.7568559646606445), ('동생', 0.7568278312683105), ('남아', 0.754828929901123), ('나눴', 0.7011327147483826), ('중학생', 0.7001649737358093), ('남자', 0.6799314022064209), ('신랑', 0.6761581897735596)]


print(transform(model.get_nearest_neighbors(word_to_jamo('낫동생'), k=10)))
[output]
[('남동생', 0.9303204417228699), ('동생', 0.8740969300270081), ('남편', 0.7611657381057739), ('남친', 0.7513895034790039), ('친구', 0.7390786409378052), ('중학생', 0.7209896445274353), ('조카', 0.7082139253616333), ('남아', 0.7011557817459106), ('난생', 0.7001751661300659), ('나눴', 0.6832748055458069)]


print(transform(model.get_nearest_neighbors(word_to_jamo('납동생'), k=10)))
[output]
[('남동생', 0.9049338102340698), ('동생', 0.8326806426048279), ('남편', 0.7896609902381897), ('남친', 0.7583615183830261), ('난생', 0.7417805790901184), ('중학생', 0.7253825664520264), ('남아', 0.7192257046699524), ('친구', 0.7001274824142456), ('나눴', 0.697450578212738), ('고등학생', 0.694034218788147)]


print(transform(model.get_nearest_neighbors(word_to_jamo('냚동생'), k=10)))
[output]
[('동생', 0.967219889163971), ('남동생', 0.8974405527114868), ('친구', 0.8116076588630676), ('조카', 0.7770885229110718), ('언니', 0.7635160088539124), ('딸', 0.7545560598373413), ('생일', 0.7490536570549011), ('딸애', 0.7439687252044678), ('중학생', 0.7377141714096069), ('남편', 0.7292447686195374)]


print(transform(model.get_nearest_neighbors(word_to_jamo('고품질'), k=10)))
[output]
[('품질', 0.8602216839790344), ('음질', 0.795451819896698), ('땜질', 0.72904372215271), ('퀄리티', 0.7188094854354858), ('찜질', 0.6836755871772766), ('군것질', 0.6558197736740112), ('고감', 0.6491621732711792), ('사포질', 0.6487373113632202), ('성질', 0.6361984014511108), ('퀄러티', 0.6332342624664307)]


print(transform(model.get_nearest_neighbors(word_to_jamo('고품쥘'), k=10)))
[output]
[('고품질', 0.8344911932945251), ('재고품', 0.7825105786323547), ('소모품', 0.7390694618225098), ('재품', 0.7284044027328491), ('반제품', 0.7194015979766846), ('고퀄', 0.7192908525466919), ('중고품', 0.6962606310844421), ('제품', 0.6944983005523682), ('화학제품', 0.6882331967353821), ('타제품', 0.6859912276268005)]


print(transform(model.get_nearest_neighbors(word_to_jamo('노품질'), k=10)))
[output]
[('고품질', 0.8985658884048462), ('품질', 0.874873697757721), ('음질', 0.7521613836288452), ('퀄리티', 0.7211642265319824), ('땜질', 0.7002740502357483), ('화질', 0.668508768081665), ('찜질', 0.6579814553260803), ('퀄러티', 0.6188082098960876), ('질', 0.6149688363075256), ('가격', 0.6097935438156128)]


print(transform(model.get_nearest_neighbors(word_to_jamo('보품질'), k=10)))
[output]
[('고품질', 0.8238304853439331), ('품질', 0.7731610536575317), ('음질', 0.755185067653656), ('땜질', 0.6912787556648254), ('화질', 0.6854788661003113), ('재질', 0.6820527911186218), ('보풀', 0.6702924370765686), ('찜질', 0.668892502784729), ('퀄리티', 0.6623635292053223), ('사포질', 0.6457920670509338)]


print(transform(model.get_nearest_neighbors(word_to_jamo('제품'), k=10)))
[output]
[('반제품', 0.8848034739494324), ('완제품', 0.872488796710968), ('상품', 0.8489471077919006), ('타제품', 0.8351007699966431), ('재품', 0.8256241083145142), ('중품', 0.8064692616462708), ('최상품', 0.7975529432296753), ('화학제품', 0.7878970503807068), ('명품', 0.7761856317520142), ('제풍', 0.7698684930801392)]


print(transform(model.get_nearest_neighbors(word_to_jamo('제품ㅋ'), k=10)))
[output]
[('제품', 0.8449548482894897), ('완제품', 0.7483550906181335), ('최상품', 0.7337923645973206), ('제풍', 0.704480767250061), ('상품', 0.7037019729614258), ('반제품', 0.6991694569587708), ('성품', 0.6684542298316956), ('타제품', 0.6669901609420776), ('재품', 0.6596834063529968), ('완성품', 0.6593179106712341)]


print(transform(model.get_nearest_neighbors(word_to_jamo('제품^^'), k=10)))
[output]
[('제품', 0.9371058344841003), ('제풍', 0.791787326335907), ('반제품', 0.7801757454872131), ('완제품', 0.7779927849769592), ('상품', 0.7686623930931091), ('타제품', 0.762442946434021), ('최상품', 0.7511221766471863), ('재품', 0.7040295004844666), ('화학제품', 0.695855438709259), ('중품', 0.6953531503677368)]


print(transform(model.get_nearest_neighbors(word_to_jamo('제푼ㅋ'), k=10)))
[output]
[('제풍', 0.6460399627685547), ('제품', 0.5884508490562439), ('최상품', 0.5207403898239136), ('완제품', 0.504409670829773), ('젝', 0.49801012873649597), ('제왕', 0.4525451362133026), ('반제품', 0.4501373767852783), ('제습', 0.44745227694511414), ('최상급', 0.4468994140625), ('상품', 0.4445939064025879)]

Read more