HOWTO · Python

PythonのSoundex

Python で Soundex の音声符号化アルゴリズムを実装し、英語の名前を比較する方法を説明します。このアルゴリズムは、先頭の英字1文字とそれに続く数字3桁からなる4文字のコードを生成します。

このページの内容

Soundex は、英語の名前を索引付けするための音声符号化アルゴリズムです。名前を、先頭の英字1文字とそれに続く数字3桁からなる4文字のコードに変換します。同じコードになる名前は、このアルゴリズム上で似た響きになることがあり、綴りの異なる名前を検索する手がかりになります。ただし、コードが一致しても候補が見つかったにすぎず、2つの名前が同一であることや、発音が同じであることを証明するものではありません。

Python には組み込みの soundex 関数はありません。以下のコードでは、一般的なアメリカ式 Soundex の規則を実装し、各手順を確認して応用できるようにしています。

Soundex の役割

Soundex は名前の先頭文字を残し、それ以降の子音を大まかな音のグループに応じて数字に置き換えます。隣り合う重複コードを取り除き、短い結果にはゼロを補い、長い結果は数字3桁に切り詰めます。先頭文字とその数字を合わせたものが最終的なコードです。

たとえば、よく知られた Robert と Rupert の組み合わせは、この規則ではどちらも R163 になります。この符号化は意図的に簡潔で、綴りの異なる名前が同じキーになるよう細かな情報を省きます。完全一致や人による確認の代わりではなく、検索を補助する手段の一つとして役立ちます。

Python で Soundex を実装する

この実装は、ASCII の英字を少なくとも1文字含む文字列を受け付けます。空白、句読点、その他の非 ASCII 文字は無視し、英字の大文字・小文字を統一します。入力が文字列でない場合、または ASCII の英字を含まない場合は、明確なメッセージを伴う ValueError を送出します。コードでは先頭の英字を接頭部として残し、それ以降の英字を対応表で変換します。

次の例を実行してください。

このセクションのコードと表示されるラベルや出力は英語のままです。

name = "Robert"
letters = [char.upper() for char in name if ("A" <= char <= "Z") or ("a" <= char <= "z")]
groups = {
    **dict.fromkeys("BFPV", "1"),
    **dict.fromkeys("CGJKQSXZ", "2"),
    **dict.fromkeys("DT", "3"),
    "L": "4",
    **dict.fromkeys("MN", "5"),
    "R": "6",
}
previous = groups.get(letters[0])
digits = []
for letter in letters[1:]:
    code = groups.get(letter)
    if code is None:
        if letter in "AEIOUY":
            previous = None
        continue
    if code != previous:
        digits.append(code)
    previous = code
print(letters[0] + "".join(digits)[:3].ljust(3, "0"))
R163

プログラムは最初の ASCII 英字を残し、対応する数字を最大3桁まで加え、短い結果にはゼロを補います。以下の完全な関数では、符号化の前に入力も検査します。

def soundex(name):
    """Return a four-character Soundex code for an ASCII-letter name."""
    if not isinstance(name, str):
        raise ValueError("name must be a string")

    letters = [
        char.upper()
        for char in name
        if ("A" <= char <= "Z") or ("a" <= char <= "z")
    ]
    if not letters:
        raise ValueError("name must contain an ASCII letter")

    groups = {
        **dict.fromkeys("BFPV", "1"),
        **dict.fromkeys("CGJKQSXZ", "2"),
        **dict.fromkeys("DT", "3"),
        "L": "4",
        **dict.fromkeys("MN", "5"),
        "R": "6",
    }

    first = letters[0]
    previous = groups.get(first)
    digits = []

    for letter in letters[1:]:
        code = groups.get(letter)
        if code is None:
            # Vowels and Y separate equal codes; H and W do not.
            if letter in "AEIOUY":
                previous = None
            continue
        if code != previous:
            digits.append(code)
        previous = code

    return first + "".join(digits)[:3].ljust(3, "0")


print(soundex("Robert"))
R163

大文字に変換する前に、元の文字をフィルターしてください。これにより、ß のように大文字化すると ASCII の英字に展開される Unicode 文字が誤って対象になるのを防ぎます。小文字の ASCII 英字は引き続き正規化されます。文字列でない入力や ASCII の英字を含まない文字列では、ValueError が送出されます。

Soundex の規則を理解する

子音のグループは BFPV → 1、CGJKQSXZ → 2、DT → 3、L → 4、MN → 5、R → 6 です。母音と Y は数字を追加しません。これらは同じ子音コードを区切るため、その前後にある同じ音はそれぞれコードに反映されることがあります。H と W は無視されますが、コードを区切りません。

先頭文字は子音グループにも含まれる場合でも、そのまま保持されます。同じ文字を二重に数えないよう、実装では先頭文字に対応するコードを直前のコードとして初期化します。たとえば Pfister の先頭の P とその次の F はどちらも 1 に対応するため、隣接するこのグループから出力される 1 は一つだけです。名前の処理後、数字は先頭から3桁だけ残し、足りない桁はゼロで埋めます。

対象になるのは ASCII の英字だけです。そのため、この実装では空白や句読点は隣接するコードの並びを区切りません。これは明示的な正規化方針です。アポストロフィ、アクセント付き文字、その他の文字体系に別の扱いが必要なら、検索にこのコードを使う前に、その方針を定めてテストしてください。

実装をテストする

一致する名前の組み合わせと、H/W の規則を確認できる名前を使って、関数全体を実行します。

このセクションのコードと表示されるラベルや出力は英語のままです。

def soundex(name):
    """Return a four-character Soundex code for an ASCII-letter name."""
    if not isinstance(name, str):
        raise ValueError("name must be a string")
    letters = [
        char.upper()
        for char in name
        if ("A" <= char <= "Z") or ("a" <= char <= "z")
    ]
    if not letters:
        raise ValueError("name must contain an ASCII letter")
    groups = {
        **dict.fromkeys("BFPV", "1"),
        **dict.fromkeys("CGJKQSXZ", "2"),
        **dict.fromkeys("DT", "3"),
        "L": "4",
        **dict.fromkeys("MN", "5"),
        "R": "6",
    }
    first = letters[0]
    previous = groups.get(first)
    digits = []
    for letter in letters[1:]:
        code = groups.get(letter)
        if code is None:
            if letter in "AEIOUY":
                previous = None
            continue
        if code != previous:
            digits.append(code)
        previous = code
    return first + "".join(digits)[:3].ljust(3, "0")


for name in ("Robert", "Rupert", "Ashcraft", "Pfister"):
    print(f"{name}: {soundex(name)}")
Robert: R163
Rupert: R163
Ashcraft: A261
Pfister: P236

Robert と Rupert は同じコードになります。Ashcraft では H が同じ子音コードを区切らないことを、Pfister では先頭の子音が F との重複抑制に関わることを確認できます。

プロジェクトでは、代表的な名前を使い、採用する Soundex の方式で期待されるコードをアサーションとして指定してください。各子音グループで始まる名前、同じ子音グループの間に母音がある名前、句読点を含む名前、ASCII の英字を含まない入力を含めます。入力検査は無効な入力に対して ValueError を送出します。アプリケーションで役立つメッセージを表示したり、レコードをスキップしたりできる箇所で、その例外を処理してください。どの文字を対象にするかを変えるとコードも変わるため、これらの検査はアプリケーション独自の正規化規則と合わせてください。

Soundex の限界

Soundex は英語の発音と綴りの慣習を前提に設計されています。大まかなグループ分けのため、人が似ていると感じる名前を区別してしまうことも、文脈上は似ていない名前に同じコードを付けることもあります。発音は言語、地域、個人によって異なりますが、この実装が扱うのは綴りだけです。

4文字のコードには衝突もあります。限られた数のコードを多くの異なる名前で共有するためです。検索対象を絞るために使い、その後、より詳しい記録と照合するか、人に一致を確認してもらってください。多言語のデータには、そのデータセットの言語や名前に適した方法を検討し、結果を再現できるよう、正確な正規化方針と Soundex の方式を記録してください。