フォームから集めた名前の一覧を見たら、TANAKAとTANAKAが別人として数えられていた。そんな経験はありませんか?
見た目はほとんど同じなのに、コンピュータにとっては全角と半角はまったく別の文字です。人間の入力には必ずこうした揺れが混ざるので、データを扱う仕事では避けて通れない問題です。
Pythonには、この揺れをまとめてそろえるための道具が標準で用意されています。それが今回紹介するunicodedataモジュールのnormalize()です。
この記事では、全角と半角をそろえる基本から、変換されすぎて困る落とし穴までを順番に見ていきます。サンプルの実行結果は、手元のPython 3.11で確かめたものです。
全角と半角は、なぜ別の文字として扱われるのか¶
まずは、全角と半角がPythonの中でどう見えているのかを確かめてみましょう。比較演算子で並べるだけで、違いがはっきりします。
print("A" == "A") # False
print("ABC".lower()) # abc(全角のまま小文字になる)
print(len("ガ")) # 2(半角のカと濁点で2文字)
print(len("ガ")) # 1
全角のAと半角のAは、Unicodeでは別の番号を持つ別の文字です。lower()で小文字にしても、全角は全角のまま小文字になるだけでした。
半角カタカナはさらにやっかいです。ガは見た目では1文字ですが、実際には半角のカと半角の濁点の2文字でできています。
文字列の比較そのものについては、isと==の違いを扱った記事も参考になります。
【関連記事】Pythonのisと== は何が違う? Pythonの比較演算子で知っておくべき罠
こうした揺れを1文字ずつreplace()で直そうとすると、変換表がどこまでも長くなります。そこで登場するのが、Unicodeの正規化という考え方です。
Unicodeの正規化とunicodedata.normalize()の基本¶
正規化とは、見た目や意味が同じ文字を、決められたルールでひとつの形にそろえる処理のことです。Unicodeの規格書(UAX #15)でルールが定められていて、Pythonはそれに沿って変換してくれます。
使い方はとてもシンプルです。unicodedataをimportして、正規化の形式と文字列を渡すだけで済みます。
import unicodedata
text = "PythonでAI! パソコン123台"
print(unicodedata.normalize("NFKC", text))
# PythonでAI! パソコン123台
全角の英数字と記号は半角に、半角カタカナは全角カタカナに変わりました。ひらがなや漢字はそのまま残っている点にも注目してください。
戻り値は新しい文字列なので、元の変数は書き換わりません。結果を変数に入れ忘れて、正規化したつもりになっていた。これは私もレビューで何度か見かけた初歩的なミスです。
NFC・NFD・NFKC・NFKDの4つの形式¶
normalize()の最初の引数には、4つの形式のどれかを指定します。名前が似ていて混乱しやすいので、違いを表で整理しておきましょう。
| 形式 | 何をするか | 全角英数字 | 半角カタカナ | 主な用途 |
|---|---|---|---|---|
| NFC | 合成して1文字にまとめる | そのまま | そのまま | 見た目を変えずに保存したいとき |
| NFD | 分解して基本の文字と記号に分ける | そのまま | そのまま | 濁点やアクセントを取り除きたいとき |
| NFKC | 互換文字も含めてそろえ、合成する | 半角になる | 全角になる | 検索や名寄せの前処理 |
| NFKD | 互換文字も含めてそろえ、分解する | 半角になる | 全角になり、濁点は分かれる | 特殊な比較処理 |
名前のKは互換性(Compatibility)を表しています。Kが付く形式は、見た目が違っても意味が同じとみなせる文字までそろえるのが特徴です。
日本語のデータを整える目的なら、まずはNFKCを選んでおけば大丈夫です。迷ったらNFKCと覚えておきましょう。
濁点が分かれる?NFCとNFDの違い¶
KのないNFCとNFDは、全角と半角には手を出しません。その代わり、濁点のような記号を1文字にまとめるか、分けるかを切り替えます。
import unicodedata
a = "ガ"
b = unicodedata.normalize("NFD", a)
print(a == b) # False
print(len(a), len(b)) # 1 2
print([unicodedata.name(c) for c in b])
# ['KATAKANA LETTER KA', 'COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK']
print(unicodedata.normalize("NFC", b) == a) # True
画面ではどちらもガと表示されるのに、==で比べるとFalseになりました。NFDではカと結合用の濁点の2文字に分かれているからです。
このように、見た目が同じでも中身が違う文字列は、ファイル名や外部システムから受け取ったデータで紛れ込むことがあります。検索してもヒットしないときは、正規化の形式がそろっているかを疑ってみてください。
実務で役立つ名寄せ用の関数を作る¶
ここからは、実際のデータ処理で使える形にまとめていきます。名寄せとは、表記の揺れを吸収して同じものを同じものとして扱えるようにする作業のことです。
次の関数は、NFKCで正規化したあとに、前後の空白を取り除き、連続した空白を1つにまとめます。全角スペースもNFKCで半角スペースに変わるので、split()とjoin()でまとめて整えられます。
import unicodedata
def normalize_text(s: str) -> str:
"""表記の揺れをそろえて、比較しやすい文字列にする"""
s = unicodedata.normalize("NFKC", s)
return " ".join(s.split())
samples = [
"TANAKA 太郎 ",
"タナカ タロウ",
"090-1234-5678",
]
for s in samples:
print(repr(normalize_text(s)))
# 'TANAKA 太郎'
# 'タナカ タロウ'
# '090-1234-5678'
全角の英字、全角スペース、末尾の空白、半角カタカナ、全角の数字とハイフンが、まとめてきれいになりました。split()とjoin()の組み合わせは、文字列メソッドの記事でも詳しく紹介しています。
【関連記事】Pythonの文字列メソッドとは?strip・split・replaceでデータを整える基本を初心者向けに解説
重複チェックの前に正規化する¶
正規化の効果がいちばん実感できるのは、重複をチェックする場面です。setに入れる前に正規化するだけで、見落としていた重複が見つかります。
import unicodedata
names = ["ヤマダ", "ヤマダ", "YAMADA"]
print(len(set(names))) # 3
print(len({unicodedata.normalize("NFKC", n) for n in names})) # 2
半角のヤマダと全角のヤマダが同じものとしてまとまり、3件が2件になりました。YAMADAはローマ字なので、カタカナとは別のまま残ります。
ここで大事なのは、正規化した値は比較や検索のために使い、元のデータは残しておくことです。お客さまが入力した表記をそのまま表示したい場面は意外と多いので、元の値を上書きしてしまうと後で困ります。
私は10年ほどエンジニアとして開発に関わってきましたが、顧客リストの取り込みで同じ会社が全角と半角で別々に登録され、請求書が二重に作られかけたことがあります。それ以来、外から入ってくる文字列は、取り込みの入口で正規化してから比べるようにしています。
CSVファイルからデータを読み込む処理と組み合わせると、こうした前処理がぐっと実用的になります。
【関連記事】Pythonのcsvモジュールとは?表データの読み書きと文字化けの防ぎ方を初心者向けに解説
NFKCの落とし穴:変わってほしくない文字まで変わる¶
便利なNFKCですが、何でも都合よくそろえてくれるわけではありません。互換文字をそろえるという性質のせいで、変えたくない文字まで変わってしまうことがあります。
実際にいくつかの文字を変換して確かめてみましょう。結果を見ると、少し驚くかもしれません。
import unicodedata
for s in ["①②", "㈱", "㍻", "Ⅳ", "㌔", "x²", "fi"]:
print(s, "→", unicodedata.normalize("NFKC", s))
# ①② → 12
# ㈱ → (株)
# ㍻ → 平成
# Ⅳ → IV
# ㌔ → キロ
# x² → x2
# fi → fi
丸数字の①②は12になってしまいました。手順書の番号として使っていた丸数字が、ただの数字の12に見えてしまうのは困りますよね。
x²がx2になるのも要注意です。2の2乗を表すつもりの2²が22になるので、数式を含むデータでは意味が変わってしまいます。
変換結果を保存せず、比較用に使う理由¶
こうした事情があるので、NFKCで変換した結果をそのまま元データに上書きするのは危険です。一度①が1になってしまうと、元が①だったのか1だったのかはもう区別できません。
正規化した値は、検索用のキーや重複チェック用の別の列として持つのが安全です。表示用の値と比較用の値を分けて持つと覚えておきましょう。
ちなみに、数字に関しては別の落とし穴もあります。str.isdigit()は①に対してもTrueを返しますが、int()に渡すとエラーになります。
print("①".isdigit()) # True
print(int("123")) # 123(全角数字はそのまま変換できる)
int("①")
# ValueError: invalid literal for int() with base 10: '①'
全角の数字はint()がそのまま受け付けてくれる一方で、丸数字は受け付けません。isdigit()で確かめたから大丈夫、と思い込まないように気をつけたいところです。
NFKCでもそろわない文字への対処¶
逆に、そろえてほしいのにNFKCでは変わらない文字もあります。日本語のデータでよく問題になるのが、波線とハイフンの仲間です。
どの文字が変わり、どの文字が変わらないのかを表にまとめました。手元で確かめた結果です。
| 文字 | Unicodeの名前(概要) | NFKCの結果 |
|---|---|---|
| ~ | 全角チルダ | ~ に変わる |
| 〜 | 波ダッシュ | 変わらない |
| - | 全角ハイフンマイナス | - に変わる |
| ‐ | ハイフン | 変わらない |
| ー | 長音記号 | 変わらない |
| 全角スペース | 和字間隔 | 半角スペースに変わる |
波ダッシュの〜と全角チルダの~は、見た目がほとんど同じなのに扱いが分かれました。10時〜12時と10時~12時を同じものとして扱いたいなら、追加の変換が必要です。
str.translate()で独自の変換表を足す¶
NFKCで足りない部分は、str.maketrans()とstr.translate()で補うのが定番です。変換したい文字の対応表を辞書で渡すだけなので、読みやすく保てます。
import unicodedata
EXTRA_TABLE = str.maketrans({
"〜": "~", # 波ダッシュ
"‐": "-", # ハイフン
})
def normalize_text(s: str) -> str:
s = unicodedata.normalize("NFKC", s)
s = s.translate(EXTRA_TABLE)
return " ".join(s.split())
print(normalize_text("10時〜12時")) # 10時~12時
print(normalize_text("ABC‐123")) # ABC-123
長音記号のーをハイフンに変えるかどうかは、データの中身しだいです。カタカナ語のーまで変えてしまうと、コーヒーがコ-ヒ-になってしまいます。
ルールが増えるほど思わぬ副作用も増えます。実際のデータを見て、本当に揺れている文字だけを足すのがコツです。
正規化と一緒に知っておきたい関数¶
unicodedataには、normalize()以外にも文字を調べるための関数があります。データの中に何が混ざっているのかを調べるときに重宝します。
よく使うものを表にまとめておきます。どれも標準ライブラリなので、インストールは不要です。
| 関数 | 何がわかるか | 例 |
|---|---|---|
| unicodedata.name() | 文字の正式な名前 | A → FULLWIDTH LATIN CAPITAL LETTER A |
| unicodedata.east_asian_width() | 全角か半角かの分類 | A → F、A → Na、ア → H、あ → W |
| unicodedata.is_normalized() | すでに正規化済みかどうか | ABCをNFKCで調べるとFalse |
east_asian_width()は、文字が全角扱いか半角扱いかを1文字の記号で返します。FやWは全角、HやNaは半角と考えておけば、まずは困りません。
正体のわからない文字が混ざっているときは、name()で名前を表示してみるのがおすすめです。見た目では区別できない文字も、名前を見れば一目でわかります。
import unicodedata
for ch in "10時〜12時":
print(ch, unicodedata.name(ch, "不明"))
name()の2つ目の引数には、名前が見つからなかったときに返す値を渡せます。これを省略すると、制御文字などでValueErrorになることがあるので注意してください。
文字コードそのものの仕組みや文字化けについては、こちらの記事で詳しく解説しています。正規化と文字コードは別の話なので、合わせて読むと頭が整理できるはずです。
【関連記事】Pythonの文字コードとは?文字化けとUnicodeDecodeErrorの直し方を初心者向けに解説
正規化を使うときの心構え¶
正規化は、データをきれいにする魔法ではありません。比べやすくするための下ごしらえだと考えると、使いどころを間違えにくくなります。
もう1つ大切なのは、どこで正規化するかをチームで決めておくことです。保存するときはNFKC、検索するときはそのまま、といったずれがあると、同じ文字列なのにヒットしないという不思議なバグが生まれます。
私の現場では、以前このずれのせいで、検索窓から全角で入力したときだけ商品が見つからない不具合が起きました。原因が分かってしまえば1行の修正でしたが、再現条件を突き止めるまでに半日かかったのを覚えています。
正規化の処理は、関数として1か所にまとめておきましょう。登録するときも検索するときも同じ関数を通すようにすれば、こうしたずれは起きにくくなります。
まとめ¶
今回は、unicodedata.normalize()を使って全角と半角の揺れをそろえる方法を紹介しました。最後にポイントを振り返っておきましょう。
日本語のデータを整えるなら、まずはNFKCを選ぶのが基本です。
ただしNFKCは、①や㈱、x²のような文字まで変えてしまいます。変換した結果は比較用に使い、元のデータは残しておくのが安全です。
波ダッシュのように、NFKCではそろわない文字もあります。そうした文字は、実際のデータを見ながらstr.translate()で少しずつ補っていきましょう。
入力の揺れは、どんなシステムでも必ず起きます。今回の内容が、データの前処理で悩んだときのヒントになればうれしいです。
ここまでお読みいただきありがとうございました。