eubinecto / politely

A hybrid politeness styler for the Korean Language / 하이브리드 존댓말 변환기
https://politely.streamlitapp.com
49 stars 0 forks source link

모든 테스트에 대응할 수 있는 로직을 구현하기 (konlpy -> kiwi 의존성 변경) #5

Closed eubinecto closed 2 years ago

eubinecto commented 2 years ago

Why?

konlpy보다, kiwi가 맥락을 고려하여 어간을 추출하는 능력이 더 뛰어나기 때문이다:

from politetune.fetchers import fetch_okt
from kiwipiepy import Kiwi

def main():
    sent = "저는 그 노래를 들어요"
    okt = fetch_okt()
    kiwi = Kiwi()
    print(okt.morphs(sent, stem=True))
    print([token.form for token in kiwi.tokenize(sent)])

    """
    # 듣다라는 어간이 나와야 하는데, okt는 맥락으로부터 듣다를 파악 못함.
    ['저', '는', '그', '노래', '를', '들다']
    # 하지만 kiwi는 맥락으로부터 어간이 듣-이라는 것을 파악할 수 있음.
    ['저', '는', '그', '노래', '를', '듣', '어요']
    """

    sent = "저는 그 상자를 들어요"
    okt = fetch_okt()
    kiwi = Kiwi()
    print(okt.morphs(sent, stem=True))
    print([token.form for token in kiwi.tokenize(sent)])

    """
    ['저', '는', '그', '상자', '를', '들다']
    # 노래를 -> 상자를로 바뀌면, 어간이 들-로 바뀌는 것을 알 수 있음. 이게 더 정확하다.
    ['저', '는', '그', '상자', '를', '들', '어요']
    """

    # 결론 = 그래서 konlpy보단 kiwi를 쓰는 편이 더 정확할 것이다.

if __name__ == '__main__':
    main()

이와함께, 사실 konlpy가 m1에서 시원찮게 돌아가는 점이 애초에 맘에 안들었다. 그래서 이참에 jvm의존성을 없애고자, kiwi로 변경을 하고싶기도 하다. kiwi또한 순수 파이썬 라이브러리는 아니지만, jvm대신 C++로 돌아가며, jvm보단 C++가 m1 호환성이 더 좋다. kiwi로 이전하면, 앞으로 이런 찝찝한 오류는 보지 않아도 된다:

[0.009s][warning][os,thread] Attempt to protect stack guard pages failed (0x000000016d024000-0x000000016d030000).
[0.009s][warning][os,thread] Attempt to deallocate stack guard pages failed.
['저', '는', '그', '노래', '를', '들다']
['저', '는', '그', '노래', '를', '듣', '어요']
['저', '는', '그', '상자', '를', '들다']
['저', '는', '그', '상자', '를', '들', '어요']

정확도도 더 높고, 의존성도 줄어들고. 그래서 kiwi로 이전하고자 한다.

To-do's

eubinecto commented 2 years ago

질문

그런데 kiwi가 그렇게 맥락을 고려해서 어간을 추출하는 방법이 무엇일까? 예측기반 모델인가? 이미 말뭉치를 학습했나? 아니면...? Wordnet같은 것을 학습을 해서 규칙으로 동작하는 것일까?

eubinecto commented 2 years ago

대응 하고자하는 동사 테스트 작성하기

먼저.. pytest를 활용하더라?

우선, 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만 실행하면 바로 테스트가 실행된다. 편의성 굿!

대응하고자 하는 동사에 대한 failing tests를 추가하자!

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를 사용해서 바꿔나가는 것이다!

eubinecto commented 2 years ago

모든 테스트에 대응하는 방법찾기 (explore)

우선, kiwi로 어떻게 접근할 수 있는지, explore를 해보기

총 네가지 스텝에 걸쳐서 접근이 가능하다!:

  1. 우선 형태소로 쪼갠다
  2. 종결어미만 HONORIFICS에 정의한대로 반말/존댓말로 바꾼다.
  3. 띄어쓰기를 복구한다
  4. 축약이 필요한 경우, 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---
    나는 공부하고 있어 -> 저는 공부하고 있어요.
    저는 공부하고 있어요 -> 저는 공부하고 있어요.
    나는 공부해 -> 저는 공부해요.
    난 공부해 -> 전 공부해요.
    나는 공부할래 -> 저는 공부해요.
    나는 공부한다 -> 저는 공부해요.
    나는 물을 마신다 -> 저는 물을 마셔요.
    나는 마셔 -> 저는 마셔요.
    자동차가 도로를 달려 -> 자동차가 도로를 달려요.
    자동차가 도로를 달린다 -> 자동차가 도로를 달려요.
    자동차가 도로를 달려요 -> 자동차가 도로를 달려요.
    도로가 많이 막힌다 -> 도로가 많이 막혀요.
    나는 학원을 다닌다 -> 저는 학원을 다녀요.
    나는 내 목표를 향해 달린다 -> 저는 제 목표를 향해 달려요.
    한번 달려보자 -> 한번 달려봐요.
    좀만 더 버텨보자 -> 좀만 더 버텨봐요.
    좀만 더 버텨봐 -> 좀만 더 버텨봐요.
    """
eubinecto commented 2 years ago

모든 테스트에 대응하는 방법찾기 (객체지향 구현)

우선 이름부터 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       + 저는 목마르여요

큰 문제는 아니다. 프어 > 파, 마르이어 -> 말라 로 축약해주면 된다:

{
    "하어": "해",
    "리어": "려",
    "시어": "셔",
    "지어": "져",
    "프어": "파",  # 추가
    "마르이어": "말라",  # 추가
    "티어": "텨",
    "니어": "녀",
    "히어": "혀",
    "이어": "여",
    "보어": "봐",
    "나의": "내",
    "저의": "제"
}
eubinecto commented 2 years ago

완성, 간단한 데모

교수님께서 변화된 규칙을 더 쉽게 확인할 수 있도록 규칙의 적용도 다시 하이라이팅 했다:

image

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)

출력:

나는 공부하고 있어 -> 나는 공부하고 있어 | 저는 공부하고 있어요
        저는 공부하고 있어요 -> 나는 공부하고 있어 | 저는 공부하고 있어요
        나는 공부해 -> 나는 공부해 | 저는 공부해요
        난 공부해 -> 난 공부해 | 전 공부해요
        나는 공부할래 -> 나는 공부해 | 저는 공부해요
        나는 공부한다 -> 나는 공부해 | 저는 공부해요
        나는 물을 마신다 -> 나는 물을 마셔 | 저는 물을 마셔요
        나는 마셔 -> 나는 마셔 | 저는 마셔요
        자동차가 도로를 달려 -> 자동차가 도로를 달려 | 자동차가 도로를 달려요
        자동차가 도로를 달린다 -> 자동차가 도로를 달려 | 자동차가 도로를 달려요
        자동차가 도로를 달려요 -> 자동차가 도로를 달려 | 자동차가 도로를 달려요
        도로가 많이 막힌다 -> 도로가 많이 막혀 | 도로가 많이 막혀요
        나는 학원을 다닌다 -> 나는 학원을 다녀 | 저는 학원을 다녀요
        나는 내 목표를 향해 달린다 -> 나는 내 목표를 향해 달려 | 저는 제 목표를 향해 달려요
        한번 달려보자 -> 한번 달려봐 | 한번 달려봐요
        좀만 더 버텨보자 -> 좀만 더 버텨봐 | 좀만 더 버텨봐요
        좀만 더 버텨봐 -> 좀만 더 버텨봐 | 좀만 더 버텨봐요
        이것 좀 들어줘 -> 이것 좀 들어줘 | 이것 좀 들어줘요