Closed eubinecto closed 2 years ago
그런데 kiwi가 그렇게 맥락을 고려해서 어간을 추출하는 방법이 무엇일까? 예측기반 모델인가? 이미 말뭉치를 학습했나? 아니면...? Wordnet같은 것을 학습을 해서 규칙으로 동작하는 것일까?
우선, pytest
모듈을 설치해주고:
pip3 install pytest
그리고 예전에 wisdomify에서 했던 것처럼, tests
경로를 setup.cfg
에 지정해주면 된다:
# to ignore unnecessary warnings with pytest
# https://github.com/googleapis/python-api-common-protos/issues/23#issuecomment-756495529
[tool:pytest]
testpaths = tests
filterwarnings =
ignore:Call to deprecated create function FieldDescriptor
ignore:Call to deprecated create function Descriptor
ignore:Call to deprecated create function EnumDescriptor
ignore:Call to deprecated create function EnumValueDescriptor
ignore:Call to deprecated create function FileDescriptor
ignore:Call to deprecated create function OneofDescriptor
이렇게 하고나면, 그냥 터미널에 pytest
만 실행하면 바로 테스트가 실행된다. 편의성 굿!
unhonored, honored의 케이스로 나누었다:
def test_do_unhonored(self):
"""
한다 -> 해
:return:
"""
listener, visibility = self.unhonored
sent, _, _ = self.honorifier("나는 공부한다", listener, visibility)
self.assertEqual("`나`는 공부`해`", sent)
sent, _, _ = self.honorifier("나는 수영한다", listener, visibility)
self.assertEqual("`나`는 수영`해`", sent)
sent, _, _ = self.honorifier("나는 요구한다", listener, visibility)
self.assertEqual("`나`는 요구`해`", sent)
def test_do_honored(self):
"""
한다 -> 해요
:return:
"""
listener, visibility = self.honored
sent, _, _ = self.honorifier("나는 공부한다", listener, visibility)
self.assertEqual("`저`는 공부`해요`", sent)
sent, _, _ = self.honorifier("나는 수영한다", listener, visibility)
self.assertEqual("`저`는 수영`해요`", sent)
sent, _, _ = self.honorifier("나는 요구한다", listener, visibility)
self.assertEqual("`저`는 요구`해요`", sent)
def test_thirsty_unhonored(self):
"""
목마르다 -> 목말라
:return:
"""
listener, visibility = self.unhonored
sent, _, _ = self.honorifier("나는 목마르다", listener, visibility)
self.assertEqual("`나`는 `목말라`", sent)
def test_thirsty_honored(self):
"""
목마르다 -> 목말라요
:return:
"""
listener, visibility = self.honored
sent, _, _ = self.honorifier("나는 목마르다", listener, visibility)
self.assertEqual("`저`는 `목말라요`", sent)
def test_hurts_unhonored(self):
"""
아프다 -> 아파, 아파요
:return:
"""
listener, visibility = self.unhonored
sent, _, _ = self.honorifier("나는 아프다", listener, visibility)
self.assertEqual("`나`는 `아파`", sent)
def test_hurts_honored(self):
"""
아프다 -> 아파, 아파요
:return:
"""
listener, visibility = self.honored
sent, _, _ = self.honorifier("나는 아프다", listener, visibility)
self.assertEqual("`저`는 `아파요`", sent)
그리고 현재 failing tests?:
==================================================================================== short test summary info =====================================================================================
FAILED tests/test_honorifier.py::TestHonorifier::test_do_honored - AssertionError: '`저`는 수영`해요`' != '`저`는 수영한다'
FAILED tests/test_honorifier.py::TestHonorifier::test_do_unhonored - AssertionError: '`나`는 수영`해`' != '`나`는 수영한다'
FAILED tests/test_honorifier.py::TestHonorifier::test_thirsty_honored - AssertionError: '`저`는 `목말라요`' != '`저`는 목마르다'
FAILED tests/test_honorifier.py::TestHonorifier::test_thirsty_unhonored - AssertionError: '`나`는 `목말라`' != '`나`는 목마르다'
================================================================================== 4 failed, 2 passed in 2.39s ===================================================================================
6개 중 2개만 패스하고, 나머지 4개는 failing 중이다. 시작이 좋다! 이제 내 목표는 failing을 passing으로, kiwi를 사용해서 바꿔나가는 것이다!
explore
를 해보기총 네가지 스텝에 걸쳐서 접근이 가능하다!:
HONORIFICS
에 정의한대로 반말/존댓말로 바꾼다.ABBREVIATIONS
에서 정의한대로 축약한다.# to be used for testing
SENTS = [
"나는 공부하고 있어",
"저는 공부하고 있어요",
"나는 공부해",
"난 공부해",
"나는 공부할래",
"나는 공부한다",
"나는 물을 마신다",
"나는 마셔",
"자동차가 도로를 달려",
"자동차가 도로를 달린다",
"자동차가 도로를 달려요",
"도로가 많이 막힌다",
"나는 학원을 다닌다",
"나는 내 목표를 향해 달린다",
"한번 달려보자",
"좀만 더 버텨보자",
"좀만 더 버텨봐"
]
# these are pretty much all you need?
HONORIFICS = {
"어+EF": ("어", "어요"),
"ᆯ래+EF": ("어", "어요"),
"ᆫ다+EF": ("어", "어요"),
"자+EF": ("어", "어요"),
"어요+EF": ("어", "어요"),
"나+NP": ("나", "저"),
"저+NP": ("나", "저")
}
# https://www.korean.go.kr/front/onlineQna/onlineQnaView.do?mn_id=216&qna_seq=38766
ABBREVIATIONS = {
"하어": "해",
"리어": "려",
"시어": "셔",
"지어": "져",
"티어": "텨",
"니어": "녀",
"히어": "혀",
"이어": "여",
"보어": "봐",
"나의": "내",
"저의": "제"
}
kiwi = Kiwi()
def honorify(sent: str, honored: bool) -> str:
# preprocess the sentence
sent = sent + "." if not sent.endswith(".") else sent # for accurate pos-tagging
sent = sent.replace(" ", " " * 2) # for accurate spacing
# tokenize the sentence, and replace all the EFs with their honorifics
tokens = kiwi.tokenize(sent)
texts = [
HONORIFICS.get(f"{token.form}+{token.tag}", (token.form,) * 2)[honored]
for token in tokens
]
# restore spacings
starts = np.array([token.start for token in tokens] + [0])
lens = np.array([token.len for token in tokens] + [0])
sums = np.array(starts) + np.array(lens)
spacings = (starts[1:] - sums[:-1]) > 0
sent = "".join([
text + " " if spacing else text
for text, spacing in zip(texts, spacings)
])
# abbreviate tokens
for key, val in ABBREVIATIONS.items():
sent = sent.replace(key, val)
return sent
def main():
print("---de-honorify---")
for sent in SENTS:
print(sent, "->", honorify(sent, False))
print("---honorify---")
for sent in SENTS:
print(sent, "->", honorify(sent, True))
# I call this a success!
결과는 다음과 같고, 생각보다 준수하다:
"""
---de-honorify---
나는 공부하고 있어 -> 나는 공부하고 있어.
저는 공부하고 있어요 -> 나는 공부하고 있어.
나는 공부해 -> 나는 공부해.
난 공부해 -> 난 공부해.
나는 공부할래 -> 나는 공부해.
나는 공부한다 -> 나는 공부해.
나는 물을 마신다 -> 나는 물을 마셔.
나는 마셔 -> 나는 마셔.
자동차가 도로를 달려 -> 자동차가 도로를 달려.
자동차가 도로를 달린다 -> 자동차가 도로를 달려.
자동차가 도로를 달려요 -> 자동차가 도로를 달려.
도로가 많이 막힌다 -> 도로가 많이 막혀.
나는 학원을 다닌다 -> 나는 학원을 다녀.
나는 내 목표를 향해 달린다 -> 나는 내 목표를 향해 달려.
한번 달려보자 -> 한번 달려봐.
좀만 더 버텨보자 -> 좀만 더 버텨봐.
좀만 더 버텨봐 -> 좀만 더 버텨봐.
---honorify---
나는 공부하고 있어 -> 저는 공부하고 있어요.
저는 공부하고 있어요 -> 저는 공부하고 있어요.
나는 공부해 -> 저는 공부해요.
난 공부해 -> 전 공부해요.
나는 공부할래 -> 저는 공부해요.
나는 공부한다 -> 저는 공부해요.
나는 물을 마신다 -> 저는 물을 마셔요.
나는 마셔 -> 저는 마셔요.
자동차가 도로를 달려 -> 자동차가 도로를 달려요.
자동차가 도로를 달린다 -> 자동차가 도로를 달려요.
자동차가 도로를 달려요 -> 자동차가 도로를 달려요.
도로가 많이 막힌다 -> 도로가 많이 막혀요.
나는 학원을 다닌다 -> 저는 학원을 다녀요.
나는 내 목표를 향해 달린다 -> 저는 제 목표를 향해 달려요.
한번 달려보자 -> 한번 달려봐요.
좀만 더 버텨보자 -> 좀만 더 버텨봐요.
좀만 더 버텨봐 -> 좀만 더 버텨봐요.
"""
우선 이름부터 Honorifier
-> Tuner
로 변경하자. honorifier
라는 이름은 너무 생소하기도 하고, 경우에 따라 반말로 바뀌기도 하는걸보면, 무조건 honorify
를 하는 건 아니다. 때문에 이름이 그닥 적절한 것은 아니라... 지금 생각해보면 honorifer
보다는 그냥 Tuner
라고 부르는게 더 적당할 것
그냥 위에서 explore
했던 것을 그대로 Tuner
클래스에 집어넣으면 된다. 우선,
honorifics.json
의 변경이 필요하다. 지금은 konlpy
를 더이상 쓰고 있지 않기 때문에, 더이상 lemma -> ban -> jon 의 형식은 필요없다.
before:
{
"하다": {"x" : "해", "o": "해요", "pos": "Verb"},
"마시다": {"x" : "마셔", "o": "마셔요", "pos": "Verb"},
"알다": {"x" : "알아", "o": "알아요", "pos": "Verb"},
"있다": {"x" : "있어", "o": "있어요", "pos": "Verb"},
"모르다": {"x" : "몰라", "o": "몰라요", "pos": "Verb"},
"들다": {"x" : "들어", "o": "들어요", "pos": "Verb"},
"사다": {"x" : "사", "o": "사요", "pos": "Verb"},
"보다": {"x" : "봐", "o": "봐요", "pos": "Verb"},
"가다": {"x" : "가", "o": "가요", "pos": "Verb"},
"오다": {"x" : "와", "o": "와요", "pos": "Verb"},
"아프다": {"x" : "아파", "o": "아파요", "pos": "Adjective"},
"목마르다": {"x" : "목말라", "o": "목말라요", "pos": "Adjective"},
"배고프다": {"x" : "배고파", "o": "배고파요", "pos": "Verb"},
"고맙다": {"x" : "고마워", "o": "고마워요", "pos": "Verb"},
"나": {"x" : "나", "o": "저", "pos": "Noun"},
"저": {"x" : "나", "o": "저", "pos": "Noun"}
}
after:
{
"어+EF": {"x": "어", "o": "어요"},
"ᆯ래+EF": {"x": "어", "o": "어요"},
"ᆫ다+EF": {"x": "어", "o": "어요"},
"자+EF": {"x": "어", "o": "어요"},
"어요+EF": {"x": "어", "o": "어요"},
"나+NP": {"x": "나", "o": "저"},
"저+NP": {"x": "나", "o": "저"}
}
그냥 pos를 key속애 넣어버렸다. 지금 당장 크게 불편한건 없으니. 일단 이렇게 가자.
또, abbreviations.json
도 추가를 했다:
{
"하어": "해",
"리어": "려",
"시어": "셔",
"지어": "져",
"티어": "텨",
"니어": "녀",
"히어": "혀",
"이어": "여",
"보어": "봐",
"나의": "내",
"저의": "제"
}
음... 생각하면 할수록 그냥 yaml이 가장 편할듯.. 일단 json을 유지하고 이번 이슈를 끝낸다음에 생각하자.
구현은? 그냥 기존의 코드를 __call__
에다가 복붙하면 된다!:
def __call__(self, sent: str, listener: str, visibility: str) -> str:
self.polite = self.RULES[listener][visibility]
# preprocess the sentence
tuned = sent + "." if not sent.endswith(".") else sent # for accurate pos-tagging
tuned = tuned.replace(" ", " " * 2) # for accurate spacing
# tokenize the sentence, and replace all the EFs with their honorifics
tokens = self.kiwi.tokenize(tuned)
texts = [
self.HONORIFICS[f"{token.form}+{token.tag}"][self.polite]
if f"{token.form}+{token.tag}" in self.HONORIFICS.keys()
else token.form
for token in tokens
]
# restore spacings
starts = np.array([token.start for token in tokens] + [0])
lens = np.array([token.len for token in tokens] + [0])
sums = np.array(starts) + np.array(lens)
spacings = (starts[1:] - sums[:-1]) > 0
tuned = "".join([
text + " " if spacing else text
for text, spacing in zip(texts, spacings)
])
# abbreviate tokens
for key, val in self.ABBREVIATIONS.items():
tuned = tuned.replace(key, val)
# post-process the sentence
if not sent.endswith("."):
tuned = tuned.replace(".", "")
return tuned
구현에 집중하기 위해서, 그냥 highlighting은 잠시 제거했다.
이후 테스트를 돌려보니, 몇가지 테스트 케이스가 통과하지 않더라:
def test_hurts_honored(self):
"""
아프다 -> 아파요
:return:
"""
listener, visibility = self.honored
sent = self.tuner("나는 아프다", listener, visibility)
> self.assertEqual("저는 아파요", sent)
E AssertionError: '저는 아파요' != '저는 아프어요'
E - 저는 아파요
E ? ^
E + 저는 아프어요
E ? ^^
def test_thirsty_honored(self):
"""
목마르다 -> 목말라요
:return:
"""
listener, visibility = self.honored
sent = self.tuner("나는 목마르다", listener, visibility)
> self.assertEqual("저는 목말라요", sent)
E AssertionError: '저는 목말라요' != '저는 목마르여요'
E - 저는 목말라요
E + 저는 목마르여요
큰 문제는 아니다. 프어 > 파, 마르이어 -> 말라 로 축약해주면 된다:
{
"하어": "해",
"리어": "려",
"시어": "셔",
"지어": "져",
"프어": "파", # 추가
"마르이어": "말라", # 추가
"티어": "텨",
"니어": "녀",
"히어": "혀",
"이어": "여",
"보어": "봐",
"나의": "내",
"저의": "제"
}
교수님께서 변화된 규칙을 더 쉽게 확인할 수 있도록 규칙의 적용도 다시 하이라이팅 했다:
tuner의 성능을 확인하기 위해 간단한 데모 스크립트도 만들어보자.
from politetune.tuner import Tuner
SENTS = [
"나는 공부하고 있어",
"저는 공부하고 있어요",
"나는 공부해",
"난 공부해",
"나는 공부할래",
"나는 공부한다",
"나는 물을 마신다",
"나는 마셔",
"자동차가 도로를 달려",
"자동차가 도로를 달린다",
"자동차가 도로를 달려요",
"도로가 많이 막힌다",
"나는 학원을 다닌다",
"나는 내 목표를 향해 달린다",
"한번 달려보자",
"좀만 더 버텨보자",
"좀만 더 버텨봐",
"이것 좀 들어줘",
]
tuner = Tuner()
def main():
for sent in SENTS:
unhonored = tuner(sent, "friend", "private")['tuned']
honored = tuner(sent, "friend", "public")['tuned']
print(sent, "->", unhonored, "|", honored)
출력:
나는 공부하고 있어 -> 나는 공부하고 있어 | 저는 공부하고 있어요
저는 공부하고 있어요 -> 나는 공부하고 있어 | 저는 공부하고 있어요
나는 공부해 -> 나는 공부해 | 저는 공부해요
난 공부해 -> 난 공부해 | 전 공부해요
나는 공부할래 -> 나는 공부해 | 저는 공부해요
나는 공부한다 -> 나는 공부해 | 저는 공부해요
나는 물을 마신다 -> 나는 물을 마셔 | 저는 물을 마셔요
나는 마셔 -> 나는 마셔 | 저는 마셔요
자동차가 도로를 달려 -> 자동차가 도로를 달려 | 자동차가 도로를 달려요
자동차가 도로를 달린다 -> 자동차가 도로를 달려 | 자동차가 도로를 달려요
자동차가 도로를 달려요 -> 자동차가 도로를 달려 | 자동차가 도로를 달려요
도로가 많이 막힌다 -> 도로가 많이 막혀 | 도로가 많이 막혀요
나는 학원을 다닌다 -> 나는 학원을 다녀 | 저는 학원을 다녀요
나는 내 목표를 향해 달린다 -> 나는 내 목표를 향해 달려 | 저는 제 목표를 향해 달려요
한번 달려보자 -> 한번 달려봐 | 한번 달려봐요
좀만 더 버텨보자 -> 좀만 더 버텨봐 | 좀만 더 버텨봐요
좀만 더 버텨봐 -> 좀만 더 버텨봐 | 좀만 더 버텨봐요
이것 좀 들어줘 -> 이것 좀 들어줘 | 이것 좀 들어줘요
Why?
konlpy
보다,kiwi
가 맥락을 고려하여 어간을 추출하는 능력이 더 뛰어나기 때문이다:이와함께, 사실 konlpy가 m1에서 시원찮게 돌아가는 점이 애초에 맘에 안들었다. 그래서 이참에 jvm의존성을 없애고자, kiwi로 변경을 하고싶기도 하다.
kiwi
또한 순수 파이썬 라이브러리는 아니지만, jvm대신 C++로 돌아가며, jvm보단 C++가 m1 호환성이 더 좋다.kiwi
로 이전하면, 앞으로 이런 찝찝한 오류는 보지 않아도 된다:정확도도 더 높고, 의존성도 줄어들고. 그래서 kiwi로 이전하고자 한다.
To-do's