Pythonで文字列をUnicode正規化(unicodedata.normalize)

Modified: | Tags: Python, 文字列, Unicode

Pythonで文字列をUnicode正規化するにはunicodedata.normalize()を使う。また、文字列がUnicode正規化されているかを判定するunicodedata.is_normalized()も提供されている。

単純に全角と半角を変換したい場合は以下の記事を参照。

本記事のサンプルコードでは、以下のようにunicodedataモジュールをインポートしている。標準ライブラリに含まれているので追加のインストールは不要。

import unicodedata

Unicode正規化

Unicode正規化(ユニコードせいきか、英語: Unicode normalization)とは、等価な文字や文字の並びを統一的な内部表現に変換することでテキストの比較を容易にする、テキスト正規化処理の一種である。一般に、正規化はテキストの文字列を検索や整列のために比較(照合、英語: collation)するときに重要である。 Unicode正規化 - Wikipedia

具体的には、半角と全角を変換したり、合成済み文字を分解したりする。自然言語処理の前処理として行われることが多い。

unicodedata.normalize()の使い方

unicodedata.normalize()の第一引数には正規化形式を表す文字列'NFD', 'NFC', 'NFKD', 'NFKC'のいずれか、第二引数には処理する文字列を指定する。正規化形式についての詳細は後述。

s = '123abcアイウエオ①㈱㌖'
print(unicodedata.normalize('NFKC', s))
# 123abcアイウエオ1(株)キロメートル

正規化形式: NFD, NFC, NFKD, NFKC

Unicode正規化には、NFD, NFC, NFKD, NFKCの4つの正規化形式がある。

簡単にまとめると以下の通り。

  • NFD : 正準等価性によって分解
  • NFC: 正準等価性によって分解され、正準等価性によって合成
  • NFKD: 互換等価性によって分解
  • NFKC: 互換等価性によって分解され、正準等価性によって合成

なお、「分解」という表現には、1文字から1文字への置き換えも含まれる

文字は1文字に「分解」される場合もあるので、分解という用語は混乱を招くことがある。この場合、1文字の分解は単に等価な(もしくはおおむね等価な)別の文字への置き換えである。 Unicodeの互換文字 - Wikipedia

日本語テキストの前処理としてはNFKCが用いられることが多い。

正準等価性と互換等価性による違い

正準等価性、互換等価性については以下を参照。

正準等価のほうが対象範囲が狭い。

例えば、半角と全角、普通の数字と丸数字・上付き・下付きは正準等価ではないが互換等価である。したがって、それらの文字はNFD, NFCでは変換されないが、NFKD, NFKCでは変換される。

s = '123abcアイウエオ①②③¹²³'
print(unicodedata.normalize('NFD', s))
# 123abcアイウエオ①②③¹²³

print(unicodedata.normalize('NFC', s))
# 123abcアイウエオ①②③¹²³

print(unicodedata.normalize('NFKD', s))
# 123abcアイウエオ123123

print(unicodedata.normalize('NFKC', s))
# 123abcアイウエオ123123

合成による違い

NFC, NFKCでは分解された文字が再び合成されるが、NFD, NFKDではそのまま。

例えば、ひらがな・カタカナの濁音・半濁音は、NFC, NFKCでは一文字に合成されるが、NFD, NFKDでは濁点・半濁点が分解された形となる。以下の例のように、表示上は見分けがつかない場合もあるので要注意。

s = 'がガぱパ'
print(unicodedata.normalize('NFD', s))
print(list(unicodedata.normalize('NFD', s)))
# がガぱパ
# ['か', '゙', 'カ', '゙', 'は', '゚', 'ハ', '゚']

print(unicodedata.normalize('NFC', s))
print(list(unicodedata.normalize('NFC', s)))
# がガぱパ
# ['が', 'ガ', 'ぱ', 'パ']

print(unicodedata.normalize('NFKD', s))
print(list(unicodedata.normalize('NFKD', s)))
# がガぱパ
# ['か', '゙', 'カ', '゙', 'は', '゚', 'ハ', '゚']

print(unicodedata.normalize('NFKC', s))
print(list(unicodedata.normalize('NFKC', s)))
# がガぱパ
# ['が', 'ガ', 'ぱ', 'パ']

なお、半角カタカナの濁音・半濁音はもともと合成済み文字がなく濁点・半濁点が分解されているので、NFCでもそのまま。

s = 'ガパ'
print(s)
print(list(s))
# ガパ
# ['カ', '゙', 'ハ', '゚']

print(unicodedata.normalize('NFD', s))
print(list(unicodedata.normalize('NFD', s)))
# ガパ
# ['カ', '゙', 'ハ', '゚']

print(unicodedata.normalize('NFC', s))
print(list(unicodedata.normalize('NFC', s)))
# ガパ
# ['カ', '゙', 'ハ', '゚']

print(unicodedata.normalize('NFKD', s))
print(list(unicodedata.normalize('NFKD', s)))
# ガパ
# ['カ', '゙', 'ハ', '゚']

print(unicodedata.normalize('NFKC', s))
print(list(unicodedata.normalize('NFKC', s)))
# ガパ
# ['ガ', 'パ']

また、NFKCにおいては、互換等価性によって分解され、正準等価性によって合成されるため、分解されたあと合成されない場合もある。

s = '㈱㌖'
print(unicodedata.normalize('NFD', s))
# ㈱㌖

print(unicodedata.normalize('NFC', s))
# ㈱㌖

print(unicodedata.normalize('NFKD', s))
# (株)キロメートル

print(unicodedata.normalize('NFKC', s))
# (株)キロメートル

NFKCでのUnicode正規化の例

正規化形式がNFKCのUnicode正規化によってどのような変換が行われるかを紹介する。

英数字は半角、カタカナは全角に変換される。

s = '123abcアイウエオ123abcアイウエオ'
print(unicodedata.normalize('NFKC', s))
# 123abcアイウエオ123abcアイウエオ

記号については、ASCII文字は半角に、主に日本語で使われる文字(カギカッコや句読点など)は全角に変換される。

s = '().,「」。、().,「」。、'
print(unicodedata.normalize('NFKC', s))
# ().,「」。、().,「」。、

紛らわしい文字には注意が必要。例えば、(全角チルダ: U+FF5E)は~(半角チルダ: U+007E)に変換されるが、(波ダッシュ: U+301C)は変換されない。

s = '~〜'
print(unicodedata.normalize('NFKC', s))
# ~〜

見分けがつかない文字はord()でUnicodeコードポイントを確認できる。

print([hex(ord(c)) for c in s])
# ['0xff5e', '0x301c']

print([hex(ord(c)) for c in unicodedata.normalize('NFKC', s)])
# ['0x7e', '0x301c']

全角・半角だけでなく、各種合成済み文字も分解・変換される。

s = '①㈱㌖'
print(unicodedata.normalize('NFKC', s))
# 1(株)キロメートル

変換されそうでも変換されない文字もあるので注意。

s = '®©💯'
print(unicodedata.normalize('NFKC', s))
# ®©💯

任意の文字を変換(置換)したい場合

上述のように、(全角チルダ: U+FF5E)は~(半角チルダ: U+007E)に変換されるが、(波ダッシュ: U+301C)は変換されない。

波ダッシュも半角チルダに変換したい場合は、unicodedata.normalize()のあとで波ダッシュを半角チルダに置換するか、unicodedata.normalize()の前に波ダッシュを全角チルダに変換すればよい。

s = '~〜'
print(unicodedata.normalize('NFKC', s).replace('〜', '~'))
# ~~

print(unicodedata.normalize('NFKC', s.replace('〜', '~')))
# ~~

置換したい文字が複数ある場合はtranslate()が便利。

s = '®©'
print(s.translate(str.maketrans('®©', 'RC')))
# RC

文字列の置換についての詳細は以下の記事を参照。

文字列がUnicode正規化されているか判定: unicodedata.is_normalized()

unicodedata.is_normalized()で文字列がUnicode正規化されているかを判定できる。第一引数には正規化形式を表す文字列'NFD', 'NFC', 'NFKD', 'NFKC'のいずれか、第二引数には処理する文字列を指定する。

s = '123abcアイウエオ①②③¹²³'
print(unicodedata.is_normalized('NFC', s))
# True

print(unicodedata.is_normalized('NFKC', s))
# False

unicodedata.normalize()unicodedata.is_normalized()の簡単な速度比較は以下の通り。以下のコードはJupyter Notebook上でマジックコマンド%%timeitを使って計測したもの。Pythonスクリプトとして実行しても計測されないので注意。

s = '123abcアイウエオ'
print(unicodedata.is_normalized('NFKC', s))
# False

%%timeit
unicodedata.is_normalized('NFKC', s)
# 42.5 ns ± 0.0538 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

%%timeit
unicodedata.normalize('NFKC', s)
# 988 ns ± 5.71 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)

s = '123abcアイウエオ'
print(unicodedata.is_normalized('NFKC', s))
# True

%%timeit
unicodedata.is_normalized('NFKC', s)
# 54.3 ns ± 0.0637 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

%%timeit
unicodedata.normalize('NFKC', s)
# 51 ns ± 0.163 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

例の条件では、正規化されている(unicodedata.is_normalized()Trueを返す)文字列の場合、かかる時間はほぼ同じ(むしろunicodedata.normalize()のほうが若干速い)。したがって、最終的に文字列を正規化するのであれば、unicodedata.is_normalized()で判定せずに最初からunicodedata.normalize()で変換してしまう方が速い。

環境や文字列の中身によって結果が異なる可能性もあるので、自身の条件でも確認されたい。

関連カテゴリー

関連記事