본문 바로가기
머신러닝/NLP(자연어처리, Natural language processing)

자연어 처리 - 문장의 유사도 측정

by 미생22 2024. 5. 30.
728x90

우리가 하려고 하는 것이 문장의 유사도가 아니라, 두 점 사이의 거리를 구하는 것이라면 쉽습니다.

유클리드 기하학에 의해 각각의 x, y를 빼고 제곱해서 더해서 루트를 씌우면 됩니다.

벡터로 표현할 수 있다면 거리를 구할 수 있습니다.

그렇다면 벡터로 표시할 수 있다면 문장이던, 그림이던, 소리던, 비슷한지 아닌지 알 수 있고 가장 가까운 것을 골라와 가장 비슷한 것을 가져올 수 있습니다.

 

1. count vectorize

vectorizer를 통해 글자를 vector화 할 수 있습니다. 많은 vectorizer 중에서 먼저 CountVectorizer를 써보겠습니다.

count vectorizer는 각 토근의 빈도를 카운트해서 벡터 형태로 변환합니다.
from sklearn.feature_extraction.text import CountVectorizer

#count vecotizer는 글자들을 세는것입니다.
vectorizer = CountVectorizer(min_df=1)
min_df는 countvectorizer의 중요한 매개변수중 하나입니다. 토근(단어)이 문서에 등장하는 최소 빈도를 지정합니다. 1로 주는 것은 최소 빈도수가 1이라는 뜻으로, 이는 특정 단어가 적어도 한 번은 등장해야 해당 단어를 벡터화 과정에 포함시킨다는 의미입니다. min_df=0.01인 경우에는 단어가 전체 문서의 1%이상 등장해야 한다는 뜻이랍니다 ㅎㅎ

 

contents = [
    '상처받은 아이들은 너무 일찍 커버려',
    '내가 상처받은 거 아는 사람 불편해',
    '잘 사는 사람들은 좋은 사람 되기 쉬워',
    '아무 일도 아니야 괜찮아'
]

 

from konlpy.tag import Okt

t = Okt()

 

이번에는 pos_tagger가 아니라 t네요 ㅎㅎ 강사님 마음은 알다가도 모르겠습니다.

 

t.morphs()로 형태소 분석 후 리스트에 담아보겠습니다.

 

contents_tokens = [t.morphs(row) for row in contents]
contents_tokens

[['상처', '받은', '아이', '들', '은', '너무', '일찍', '커버', '려'],
 ['내', '가', '상처', '받은', '거', '아는', '사람', '불편해'],
 ['잘', '사는', '사람', '들', '은', '좋은', '사람', '되기', '쉬워'],
 ['아무', '일도', '아니야', '괜찮아']]

 

contents_for_vectorize = []

for content in contents_tokens:
    sentence = '' #null string
    for word in content:
        sentence = sentence + ' ' + word

    contents_for_vectorize.append(sentence)

contents_for_vectorize

[' 상처 받은 아이 들 은 너무 일찍 커버 려',
 ' 내 가 상처 받은 거 아는 사람 불편해',
 ' 잘 사는 사람 들 은 좋은 사람 되기 쉬워',
 ' 아무 일도 아니야 괜찮아']

 

 

이제 형태소 분석한 단위로 띄어씌기를 시킨 후 리스트에 담구요,

형태소로 띄어쓰기를 한 문장을 만들었습니다.

 

X = vectorizer.fit_transform(contents_for_vectorize)
X

<4x17 sparse matrix of type '<class 'numpy.int64'>'
with 20 stored elements in Compressed Sparse Row format>

 

X라고만 쓰면 저렇게 나오는데, X.toarray()라고 써야 ㅎㅎ 나옵니다.

 

num_samples, num_features = X.shape
num_samples, num_features

(4, 17)

X.shape을 통해 문장과 말뭉치의 갯수를 확인할 수 있습니다.

아까 나온 문장이 총 4개였고, 이를 종합하면 말뭉치가 총 17개가 나온다고 하네요. ㅎㅎ

이 말뭉치들을 보고싶으면 vectorizer에서 get_feature_names_out() 함수를 쓰면 됩니다.

 

vectorizer.get_feature_names_out()

array(['괜찮아', '너무', '되기', '받은', '불편해', '사는', '사람', '상처', '쉬워', '아는',
       '아니야', '아무', '아이', '일도', '일찍', '좋은', '커버'], dtype=object)

 

X.toarray().transpose()

array([[0, 0, 0, 1],
       [1, 0, 0, 0],
       [0, 0, 1, 0],
       [1, 1, 0, 0],
       [0, 1, 0, 0],
       [0, 0, 1, 0],
       [0, 1, 2, 0],
       [1, 1, 0, 0],
       [0, 0, 1, 0],
       [0, 1, 0, 0],
       [0, 0, 0, 1],
       [0, 0, 0, 1],
       [1, 0, 0, 0],
       [0, 0, 0, 1],
       [1, 0, 0, 0],
       [0, 0, 1, 0],
       [1, 0, 0, 0]], dtype=int64)

 

transpose 시켜서 보면, 첫번째 열이 첫번째 문장이고 두번째 열이 두번째 문장입니다. 1열의 괜찮아는 4번째 문장에 있기 때문에 4번째 열에 1이 있는거고, 2열의 너무는 1번째 문장에 있기 때문에 2번째행 1번째열에 1이 있습니다.

 

이제 새로운 문장을 가져와서 어떤 문장과 가장 유사한지 살펴보겠습니다.

 

new_post = ['상처받기 싫어 괜찮아']
new_post_tokens = [t.morphs(row) for row in new_post]
new_post_tokens

[['상처', '받기', '싫어', '괜찮아']]

 

이번에도 띄어쓰기로 형태소를 붙여줍니다.

 

new_post_for_vectorize = []

for content in new_post_tokens:
    sentence = ''
    for word in content:
        sentence = sentence + ' ' + word

    new_post_for_vectorize.append(sentence)

new_post_for_vectorize

[' 상처 받기 싫어 괜찮아']

 

 

이제 이 문장을 transform 시켜서 vector로 바꾸겠습니다.

 

new_post_vec = vectorizer.transform(new_post_for_vectorize)
new_post_vec

<1x17 sparse matrix of type '<class 'numpy.int64'>'
with 2 stored elements in Compressed Sparse Row format>

 

이렇게 벡터로 바꾼 친구는 toarray()를 해야 보입니다.

 

new_post_vec.toarray()

array([[1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]], dtype=int64)

 

말뭉치에 없는 단어는 벡터로 못만듭니다. '받기'라는 단어는 벡터로 못만들었네요 ㅎㅎ
이제 거리를 계산하는 코드를 짜보겠습니다.

 

import scipy as sp

def dist_raw(v1, v2):
	delta = v1 - v2
    return sp.linalg.norm(delta.toarray())
    #linalg.norm 함수를 쓰면 각각의 제곱을 더한것의 루트를 씌운 값이 됩니다.

linalg.norm 함수를 쓰면 각각의 제곱을 더한것의 루트를 씌운 값이 됩니다.

 이제 dist_raw 함수는 두 점 사이의 거리를 뱉는 함수가 되었습니다.

 

X에 있는 모든 each에서 새로운 문장의 벡터까지의 거리를 각각 구해서 리스트로 담습니다.

dist = [dist_raw(each, new_post_vec) for each in X]
dist

[2.449489742783178, 2.23606797749979, 3.1622776601683795, 2.0]

 

print('Best post is ', dist.index(min(dist)), ',dist = ', min(dist))
print('Test post is --> ', new_post)
print('Best dist post is --> ', contents[dist.index(min(dist))])

dist가 네 문장과 새로운문장의 거리를 나타낸 것인데, 이 거리가 가장 적은 3번(0부터므로) 문장이 가장 유사한 문장이 됩니다.

 

Best post is  3 ,dist =  2.0
Test post is -->  ['상처받기 싫어 괜찮아']
Best dist post is -->  아무 일도 아니야 괜찮아

 

for i in range(0, len(contents)):
    print(X.getrow(i).toarray())

print('-' * 40)
print(new_post_vec.toarray())

[[0 1 0 1 0 0 0 1 0 0 0 0 1 0 1 0 1]]
[[0 0 0 1 1 0 1 1 0 1 0 0 0 0 0 0 0]]
[[0 0 1 0 0 1 2 0 1 0 0 0 0 0 0 1 0]]
[[1 0 0 0 0 0 0 0 0 0 1 1 0 1 0 0 0]]
----------------------------------------
[[1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0]]

 

4개의 문장을 벡터로 만들고, new post vec가 4개의 벡터중 어떤것과 가장 비슷한지 찾는겁니다.

X에서 한 문장씩 가져와서 array로 보여주고 마지막에 새로운문장의 벡터를 보여줬습니다. ^^

 

2. tf-idf vectorizer

- 이제 vector를 만들기만 하면 거리를 구한다는걸 알았으니까, vector 만드는 방법이 count vectorize밖에 없냐 이겁니다.
- 다른 방법으로 tf-idf 방법이 있습니다. wiki백과에 의하면, TF-IDF(Term Frequency - Inverse Document Frequency)는 정보 검색과 텍스트 마이닝에서 이용하는 가중치로, 여러 문서로 이루어진 문서군이 있을 때 어떤 단어가 특정 문서 내에서 얼마나 중요한 것인지를 나타내는 통계적 수치입니다.

TF(term frequency)는 특정한 단어가 문서 내에 얼마나 자주 등장하는지를 나타내는 값
DF(document frequency)는 단어 자체가 문서군 내에서 자주 사용되는 경우인데 알고보니 이 단어가 원래 흔하게 등장하는 단어인 경우의 값입니다.
이럴 때 TF와 DF의 역수인 IDF(inverse document frequency)를 곱한 값이 TF-IDF입니다.

 

from sklearn.feature_extraction.text import TfidfVectorizer

vectorizer = TfidfVectorizer(min_df=1, decode_error='ignore')

이 vectorizer에서도 마찬가지로 최소발생빈도, min_df=1로 주고, decode_error가 나는걸 무시하라는 옵션을 줍니다.

 

이후로는 vectorizer로 잡았으니 코드가 똑같습니다.

X = vectorizer.fit_transform(contents_for_vectorize)
X

<4x17 sparse matrix of type '<class 'numpy.float64'>'
with 20 stored elements in Compressed Sparse Row format>

num_samples, num_features = X.shape
num_samples, num_features

(4, 17)

 

크기는 똑같습니다. 4,17이네요

 

X.toarray().transpose()

array([[0.        , 0.        , 0.        , 0.5       ],
       [0.43671931, 0.        , 0.        , 0.        ],
       [0.        , 0.        , 0.39264414, 0.        ],
       [0.34431452, 0.40104275, 0.        , 0.        ],
       [0.        , 0.50867187, 0.        , 0.        ],
       [0.        , 0.        , 0.39264414, 0.        ],
       [0.        , 0.40104275, 0.6191303 , 0.        ],
       [0.34431452, 0.40104275, 0.        , 0.        ],
       [0.        , 0.        , 0.39264414, 0.        ],
       [0.        , 0.50867187, 0.        , 0.        ],
       [0.        , 0.        , 0.        , 0.5       ],
       [0.        , 0.        , 0.        , 0.5       ],
       [0.43671931, 0.        , 0.        , 0.        ],
       [0.        , 0.        , 0.        , 0.5       ],
       [0.43671931, 0.        , 0.        , 0.        ],
       [0.        , 0.        , 0.39264414, 0.        ],
       [0.43671931, 0.        , 0.        , 0.        ]])

 

그런데 그 내용이 다릅니다. 0,1로만 이루어지지 않았네요

이제 가중치와 역가중치가 추가되면서 숫자가 바뀐겁니다.

 

new_post_vec = vectorizer.transform(new_post_for_vectorize)
new_post_vec.toarray()

array([[0.78528828, 0.        , 0.        , 0.        , 0.        ,
        0.        , 0.        , 0.6191303 , 0.        , 0.        ,
        0.        , 0.        , 0.        , 0.        , 0.        ,
        0.        , 0.        ]])

 

비중이 조금 바꼈네요. 상처를 중요한 단어로 하고 괜찮아를 상처보단 덜 중요하다고 보았습니다.
두 벡터의 크기를 1로 변경한 다음에 거리를 측정하면, 한쪽 특성이 과하게 도드라지는걸 막을 수 있습니다.
따라서 벡터를 normalize시켜줍니다.

 

def dist_norm(v1, v2):
    v1_normalized = v1 / sp.linalg.norm(v1.toarray())
    v2_normalized = v2 / sp.linalg.norm(v2.toarray())

    delta = v1_normalized - v2_normalized
    return sp.linalg.norm(delta.toarray())

sp.linalg.norm을 쓰는 함수를 다시 들고옵니다.

 

dist = [dist_norm(each, new_post_vec) for each in X]
dist

[1.254451632446019, 1.2261339938790283, 1.414213562373095, 1.1021396119773588]

 

숫자들은 바뀌었지만 여전히 4번째 문장이 제일 작은 값을 나타냅니다.
세번째 문장이 제일 큰 값이네요..

print('Best pos is', dist.index(min(dist)), ', dist = ', min(dist))
print('Test pos is -->', new_post)
print('Best dist post is -->', contents[dist.index(min(dist))])

Best pos is 3 , dist =  1.1021396119773588
Test pos is --> ['상처받기 싫어 괜찮아']
Best dist post is --> 아무 일도 아니야 괜찮아

 

 

 

 

 

 

728x90