NLP (1).자소 분리, 예제 소스
형태소 분석기를 만들고, 회사 사전을 만드는데,, nlp 포스팅은 한번도 안했네??
그래서 뭘 적을까 하고 적는 포스팅
오늘은 자모, 이중모음 분리 등을 다루어 본다,,
개념적으로 설명하고, 그후 예제 코드로 이걸 분리하는 방법에 대해 알아보도록 하자 =.
목차Permalink
- 자소분리 개념
- 한글 유니코드
- 한글 인코딩
- 자소 분리
- 자소 결합
1. 자모분리란?Permalink
자소
자소(字素), 또는 낱글자는 어떤 언어의 문자 체계에서 의미상 구별할 수 있는 가장 작은 단위를 가리킨다.
서기소(書記素), 문자소라고 부르기도 한다.
한국어 자연어 처리를 하다보면 자소 분리를 해야하는 일이 굉장히 많이 생긴다. 일단 내가 만들고 있는 검색엔진에서도 엘레스틱서치의 단순 fuzzy를 사용하게 되면, 우리가 원하는 편집거리 계산을 정확하게 할 수도 없다.
- 자음 닿자 19자
- 기본 자음자: ㄱ, ㄴ, ㄷ, ㄹ, ㅁ, ㅂ, ㅅ, ㅇ, ㅈ, ㅊ, ㅋ, ㅌ, ㅍ, ㅎ (14자)
- 복합 자음자: ㄲ, ㄸ, ㅃ, ㅆ, ㅉ (5자)
- 모음 홀자 21자
- 기본 모음자: ㅏ, ㅑ, ㅓ, ㅕ, ㅗ, ㅛ, ㅜ, ㅠ, ㅡ, ㅣ (10자)
- 복합 모음자: ㅐ, ㅒ, ㅔ, ㅖ, ㅘ, ㅙ, ㅚ, ㅝ, ㅞ, ㅟ, ㅢ (11자)
- 받침 27자
- 기본 받침: ㄱ, ㄴ, ㄷ, ㄹ, ㅁ, ㅂ, ㅅ, ㅇ, ㅈ, ㅊ, ㅋ, ㅌ, ㅍ, ㅎ (14자)
- 겹받침: ㄱㅅ, ㄴㅈ, ㄴㅎ, ㄹㄱ, ㄹㅁ, ㄹㅂ, ㄹㅅ, ㄹㅌ, ㄹㅍ, ㄹㅎ, ㅂㅅ (11자)
- 쌍받침: ㄲ, ㅆ (2자)
총 19 x 21 x (27 + 1) = 11172개 (가~힣)의 음절로 구성 자소분리를 하기 위해선 한글 유니코드를 이해해야 한다.
2. 한글 유니코드Permalink
컴퓨터가 세상에 나왔을떄는 영어와 몇가지 특수문자만을 통해 컴퓨터를 사용하였고, 이를 저장하는데 1byte(0~255)면 충분했다. 하지만 다른 언어들이 추가되고 1Byte 내에서 모든 언어를 표현하는것은 불가능했다. 그래서 국제적으로 언어를 모두 표시할 수 있는 표준 코드를 만들게 되었는데 이것이 바로 유니코드 (unicode) 이다. 이것은 약속이며, 약속이 꺠지지 않는이상 변하지 않는다.
유니코드는 글자와 코드가 1:1매핑 되어 이쓴ㄴ 코드포이다.
먼저, 한글의 기본 구성은 아래의 그림과 같다.
유니코드에서의 한글은 아래와 같이 구분된다.
- 초성(19개): ㄱ, ㄲ, ㄴ, ㄸ, ㄸ, ㄹ, ㅁ, ㅂ, ㅃ, ㅅ, ㅆ, ㅇ, ㅈ, ㅉ, ㅊ, ㅋ, ㅌ, ㅍ, ㅎ
- 중성(21개): ㅏ, ㅐ, ㅑ, ㅒ, ㅓ, ㅔ, ㅕ, ㅖ, ㅗ, ㅘ, ㅙ, ㅚ, ㅛ, ㅜ, ㅝ, ㅞ, ㅟ, ㅠ, ㅡ, ㅢ, ㅣ
- 종성(28): None, ㄱ, ㄲ, ㄱㅅ, ㄴ, ㄴㅈ, ㄴㅎ,, ㄷ, ㄹ, ㄹㄱ, ㄹㅁ, ㄹㅂ, ㄹㅅ, ㄹㅌ, ㄹㅍ, ㄹㅎ, ㅁ, ㅂ, ㅂㅅ, ㅅ, ㅆ, ㅇ, ㅈ, ㅊ, ㅋ, ㅌ, ㅍ, ㅎ
초성 19개, 중성 21개, 종성 28 개의 조합으로 한그을 만들 수 있으며 한글의 경우는 0xAC00으로 시작하고 이곳에 초성, 중성, 종성의 값을 넣으면 된다.
아 한글의 인코드와 유니코드는 엄현히 차이가 있다. 그 차이는 아래서 알아본다.
한글 인코딩 디코딩Permalink
위에선 유니코드가 무엇인지 알아보았다. 그러면 우리가 쓰는 UTF-8, CP949 등의 코드는 무엇일까? 유니코드를 통해 코드표가 약속으로 정의가 되었다. 그러면 이제 그 ‘코드’가 컴퓨터에 어떻게 저장될까이다. 이때 인코딩 이 필요한데, 컴퓨터가 이해할 수 있는 형태로 변경하는 것이다. 즉 UTF-8, CP949등은 __유니코드__를 __인코딩__하는 방식이다.
#한글 인코딩
name1 = "MR (ㄱ) (ㅏ) (나) (달) (랅)".encode("EUC-KR")
name2 = "MR (ㄱ) (ㅏ) (나) (달) (랅)".encode("CP949")
name3 = "MR (ㄱ) (ㅏ) (나) (달) (랅)".encode("UTF-8")
name4 = "MR (ㄱ) (ㅏ) (나) (달) (랅)".encode("UTF-16")
name5 = "MR ASCII CODE".encode("ASCII")
print(name1)
print(name2)
print(name3)
print(name4)
print(name5)
결과
b'MR (\xa4\xa1) (\xa4\xbf) (\xb3\xaa) (\xb4\xde) (\xa4\xd4\xa4\xa9\xa4\xbf\xa4\xaa)'
b'MR (\xa4\xa1) (\xa4\xbf) (\xb3\xaa) (\xb4\xde) (\x8d\xf0)'
b'MR (\xe3\x84\xb1) (\xe3\x85\x8f) (\xeb\x82\x98) (\xeb\x8b\xac) (\xeb\x9e\x85)'
b'\xff\xfeM\x00R\x00 \x00(\x0011)\x00 \x00(\x00O1)\x00 \x00(\x00\x98\xb0)\x00 \x00(\x00\xec\xb2)\x00 \x00(\x00\x85\xb7)\x00'
b'MR ASCII CODE'
위 결과 첫줄을 보면 ㄱ의 표기는 16진수 2바이트(4비트 자리수가 총 네개 있으니 16비트) ‘0xA4A1’ (42145) 이다. 영어 알파벳이 65~90 인거에 비해 굉장히 어렵다. 이게 한글을 다루는데 어려운 점이다.
UTF-8은 더욱더 복잡하다. UTF-8에서의 ‘ㄱ’을 디코드 하면 숫자값으론 14,910,641 이 된다.
## 한글 디코딩
#
print("[디코딩 문자 출력]")
print(name1.decode('EUC-KR'))
print(name2.decode('CP949'))
print(name3.decode('UTF-8'))
print(name4.decode('UTF-16'))
print(name5.decode('ASCII'))
print()
[디코딩 문자 출력]
MR (ㄱ) (ㅏ) (나) (달) (랅)
MR (ㄱ) (ㅏ) (나) (달) (랅)
MR (ㄱ) (ㅏ) (나) (달) (랅)
MR (ㄱ) (ㅏ) (나) (달) (랅)
MR ASCII CODE
코드만 사용해서 복호화를 하는것도 가능하다. EUC-KR의 확장이 CP949이니 같은 코드도 있지만 CP949에는 있고 EUC-KR 에는 없는 코드가 존재하는 경우 문자가 깨져서 출력이 된다.
# 코드를 사용한 디코딩(복호화)
print(b'\xa4\xa1'.decode('EUC-KR'))
print(b'\xa4\xa1'.decode('CP949'))
print()
ㄱ
ㄱ
이를 응용하면 서로다른 코드 체계를 변환하는 것이 가능하다. (물론 다른 패키지를 쓰는게,, 훨씬 편하지만)
decoded = 'ㄱ'.encode('CP949').decode('CP949')
encoded = decoded.encode('utf-8')
print("CP949코드 'ㄱ': ", 'ㄱ'.encode('CP949'))
print("UTF-8코드 'ㄱ': ", encoded)
CP949코드 'ㄱ': b'\xa4\xa1'
UTF-8코드 'ㄱ': b'\xe3\x84\xb1'
4.자소 분리Permalink
완성된 유니코드를 초성, 중성, 종성으로 분리 하려면
chosung = ["ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",
"ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"]
jungsung = ["ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅘ",
"ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ"]
jongsung = ["", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ", "ㄺ", "ㄻ", "ㄼ", "ㄽ",
"ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ", "ㅄ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"]
unicode = ord(word) - 0xAC00
cho = unicode // (len(jungsung)*len(jongsung))
jung = unicode % (len(jungsung)*len(jongsung)) // len(jongsung)
jong = unicode % len(jongsung)
5.자소 결합Permalink
분리는 비교적 간단하다. 하지만 결합은 조금 복잡해진다. 이런걸 지원하는 한글 유틸들도 있지만, 각자에 완벽한 상황에 맡는걸 찾기 위해선 커스터마이징은 필수이다,
공식으로는 0xAC00(44032) + 초성Index0x24C(588) + 중성Index0x1C(28) + 종성Index 이다. 16진수로 표시함
이걸 10진수로 변경시 44032 + 초성Index588 + 중성Index28 + 종성Index 가된다.
만약에 문자 ‘글’을 구하고 싶을 경우
초성 ‘ㄱ’의 index 0 중성 ‘ㅡ’의 index 18 종성 ‘ㄹ’의 index 8 을 이용하여 0xAC00 + [0]588 + [18]28 + 8 = 0xAE00의 값을 사용하면된다.
유틸함수 모음Permalink
import re
import unicodedata
chosung_map = {"ㄱ":"ᄀ","ㄲ":"ᄁ","ㄴ":"ᄂ","ㄷ":"ᄃ","ㄸ":"ᄄ","ㄹ":"ᄅ","ㅁ":"ᄆ","ㅂ":"ᄇ","ㅃ":"ᄈ","ㅅ":"ᄉ","ㅆ":"ᄊ","ㅇ":"ᄋ","ㅈ":"ᄌ","ㅉ":"ᄍ","ㅊ":"ᄎ","ㅋ":"ᄏ","ㅌ":"ᄐ","ㅍ":"ᄑ","ㅎ":"ᄒ"}
chosung = ["ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",
"ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"]
jungsung = ["ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅘ",
"ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ"]
jongsung = ["", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ", "ㄺ", "ㄻ", "ㄼ", "ㄽ",
"ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ", "ㅄ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"]
ligatures_map = { "ㅘ":["ㅗ","ㅏ"], "ㅙ":["ㅗ","ㅐ"], "ㅚ":["ㅗ","ㅣ"], "ㅝ":["ㅜ","ㅓ"], "ㅞ":["ㅜ","ㅔ"], "ㅟ":["ㅜ","ㅣ"], "ㅢ":["ㅡ","ㅣ"],
"ㄳ":["ㄱ","ㅅ"], "ㄵ":["ㄴ","ㅈ"], "ㄶ":["ㄴ","ㅎ"], "ㄺ":["ㄹ","ㄱ"], "ㄻ":["ㄹ","ㅁ"], "ㄼ":["ㄹ","ㅂ"], "ㄽ":["ㄹ","ㅅ"], "ㅀ":["ㄹ","ㅎ"], "ㅄ":["ㅂ","ㅅ"] }
def is_char(syllable, debug=False):
if debug: print(syllable)
if syllable.encode() >= '가'.encode() and syllable.encode() <= '힣'.encode():
return True
elif is_hangul_Jamo(syllable):
return False
return False
def is_jaem(syllable):
if syllable.encode() >= 'ㄱ'.encode() and syllable.encode() <= 'ㅎ'.encode():
return True
elif is_hangul_Jamo(syllable):
return True
return False
def is_moem(syllable):
if syllable.encode() >= 'ㅏ'.encode() and syllable.encode() <= 'ㅣ'.encode():
return True
elif is_hangul_Jamo(syllable):
return True
return False
def is_blank(char):
if re.match(r'\s+', char):
return True
return False
def is_hangul_Jamo(syllable):
if ord(syllable) > 0x01100 and ord(syllable) < 0x011FF:
return True
return False
def hangul_jamo_to_hangul_char(syllable, debug=False):
if debug: print(ord(syllable))
if ord(syllable) >= 0x01100 and ord(syllable) <= 0x01112:
for char, orign in chosung_map.items():
if syllable == orign:
syllable = char
elif ord(syllable) >= 0x01161 and ord(syllable) <= 0x01175:
syllable=syllable
def getLigatures(jamo):
result = ligatures_map.get(jamo)
if result:
return result
else:
return jamo
def syllables(char, debug=False):
result = list()
char = char[:1]
if not is_char(char):
if debug:
print('분해 될수 없는 글자 입니다.')
return None
b = ord(char) - 0xAC00
cho = b // (len(jungsung)*len(jongsung))
jung = b % (len(jungsung)*len(jongsung)) // len(jongsung)
jong = b % len(jongsung)
if jong == 0:
return (chosung[cho], getLigatures(jungsung[jung]))
else:
return (chosung[cho], getLigatures(jungsung[jung]), getLigatures(jongsung[jong]))
def combine_char(cho, jung):
try:
return unicodedata.normalize('NFKC', ''.join([cho, jung]))
except:
return None
def combine_moem(last_moem, syl, debug=False):
out_moem = syl
if last_moem in ['ㅗ', 'ㅜ'] and syl == 'ㅣ':
out_moem = jungsung[jungsung.index(last_moem)+3]
elif last_moem == 'ㅗ' and syl == 'ㅏ':
out_moem = 'ㅘ'
elif last_moem == 'ㅜ' and syl == 'ㅓ':
out_moem = 'ㅝ'
else:
if syl == 'ㅣ':
out_moem = jungsung[jungsung.index(last_moem)+1]
else:
out_moem = last_moem
if debug: print(out_moem)
return out_moem
def has_jongsung(char):
if (ord(char)-0xAC00)%28 != 0:
return True
return False
flag = False
syls = "ㄱㅏㄴㅏ"
output = list()
for i, syl in enumerate(syls):
if not is_char(syl):
try:
last_word = output.pop()
if is_moem(syls[i+1]):
flag = False
except:
pass
if is_jaem(syl):
if flag:
#붙일 단어일 경우
if is_hangul_Jamo(syl):
syl = hangul_jamo_to_hangul_char(syl)
output.append(chr(ord(last_word) + jongsung.index(syl)))
flag = False
else:
try:
output.append(last_word)
except:
pass
output.append(syl)
flag = True
elif is_moem(syl):
flag = False
while True:
try:
if is_blank(last_word):
last_word = output.pop()
else:
break
except:
last_word = None
break
if last_word is None:
# output.append(last_word)
# print('여기서1',syl)
output.append(syl)
continue
else:
exploded = syllables(last_word)
if exploded:
if has_jongsung(last_word):
last_jaem = exploded[2]
output.append(combine_char(last_jaem, syl))
else:
last_jaem = exploded[0]
combined_syl = combine_moem(exploded[1], syl)
syl = combined_syl
else:
last_jaem = last_word
try:
output.append(combine_char(last_jaem, syl))
except:
output.append(last_word)
output.append(syl)
flag = True
else:
output.append(syl)
if has_jongsung(syl):
flag = False
else:
flag = True