補足情報付き人名エンティティのパース（〇〇で歌ってみた）

# 概要

趣味で「〇〇で歌ってみた」の分析や[自動生成システム](soramimic.com)の開発をしています。
「〇〇で歌ってみた」は特定ジャンルの単語のみで元歌詞の音韻を再現する替え歌のジャンルです。
「野球選手名で歌ってみた」（やきゅうた）の歌詞を分析するときに、「(山田)太郎(タロウ)」みたいな表記が登場するので、これのパース方法を考えました。

# 背景
替え歌作成者の方から歌詞ファイルを頂いてそれを分析することがあります。
概ね、以下のような形式でやきゅうたの歌詞が整理されています（サンプルです）。
替え歌歌詞、元歌詞が交互に記述され、下の方に使用選手一覧があります。

```
宇佐美  大井  鹿野  陽(耀勲)(ヤン)  マン
うさぎ追いしかの山

小塚  辻勇夫  河
こぶな釣りしかの川

使用選手一覧

宇佐美一夫
大井久士
鹿野浩司
陽耀勲
ブランドン・マン(ブランドン)
小塚弘司
辻勇夫(辻功)
河文雄
```

ここから、やきゅうたの替え歌歌詞と元歌詞の対応を分析しようとしています。
例えば、元歌詞と替え歌歌詞の子音の対応をみて、どういう子音が入れ替わりやすいかを分析することなどを目指しています。

このためには、替え歌歌詞の音韻（カナ表記）を正確に推定する必要があります。
人名のため、MeCabなどで推定すると間違う可能性が高いです。
幸い「陽(耀勲)(ヤン)」のように読みの候補が複数ありえる人名には、カッコで補足がついていることが多いので、こうした情報は積極的に活用していきたいというのがモチベーションです。

そこで、人命に対する補足情報のつき方を整理して、可能な範囲で多くのケースをカバーできるパース方法を検討します。

# 方針
パターンとしては、実際に発音する表記（e.g. 「陽」）の前後にカッコでその選手を一意に特定するための姓や名（e.g. 「(耀勲)」）、また音韻のひらがなやカタカナ表記（e.g. 「（ヤン）」）がついています。

いくつか実例を眺めてみます。

- `陽（ﾔﾝ）`
  - 読みの全部が補足されている
- `相沢晋（すすむ）`
  - 読みの一部が補足されている。上記では読みの全部である「あいざわすすむ」のうち「すすむ」だけが補足されている。
- `石井（一）`
  - 名前（一：ハジメ）が補足されている
- `中野（栄）` 
  - 名前の一部が補足されている。上記では、本名は「中野栄一」だが「栄」のみが補足されている。
- `(津田)恒実`
  - 名字（津田:ツダ）が補足されている
- `キニー（マット・キニー）`
  - 名字、名前またはフルネームが補足されている。ただし外国人選手。
- `（田尾）`、`（土肥 土肥）`
  - 補足情報があるわけではないが、元歌詞がカッコの中に書かれているので合わせて替え歌歌詞もカッコに書かれているケース。１名または複数名の人名がカッコの中に含まれている
- `(陽(やん))`、`(森野 李(い))`
  - 補足情報がついたうえで全体がカッコに囲まれている。
- `ナニー(→\nナニー→)タ`
  - 一つの人名が２行にまたがって書かれている。
- `王（貞治）（読売）`
  - チーム名（e.g. 「読売」）が補足されている。

かなりバリエーションが多く、全部のパターンを網羅するのは難しいので、一部のパターンを正確に抽出しつつ、パターンに当てはまらないものは警告を出すようなプログラムを考えます。警告が出たものについては手動で修正することにします。

今回は、以下のようなパターンをパースすることにします。

- `(pre_silent_surface)pronounced_surface(post_silent_surface)(pronounciation)`


pre_silent_surfaceは補足情報で、主には名字の表層です。替え歌歌詞としては発音されないのでsilentとしています。
pronounced_surfaceは発音される人名の表層です。
post_silent_surfaceは補足情報で主には名前の表層です。替え歌歌詞としては発音されないのでsilentとしています。
pronunciationはpronounced_surfaceの音韻のカナ表記です。

# 実装
## 前後処理
正規表現で上記のパターンを検出しつつ、前後処理で可能な限り多くのパターンに対応できるようにします。具体的には以下の処理を実施します。

- 前処理
  - 半角全角の正規化。
  - 入力全体が最初または最後だけにカッコをもつ場合、カッコを削除
    - (土井) -> 土井
    - (土井 -> 土井
    - 土井) -> 土井
  - pronounced_surfaceのうしろのカッコの処理
    - 2つの場合、1つめをpost_silent_surface、2つめをpronunciationとみなす。
    - 1つの場合、中身がすべてひらがなかカタカナであればpronunciationとし、そうでなければ、post_silent_surfaceとみなす。ただし、外国人名の場合は、post_silent_surfaceがカタカナになることがあるので、pronounced_surfaceがカタカナの場合は、後ろのカッコが1つかつすべてカタカナでもpost_silent_surfaceとみなす。


## コード
コードは以下のような感じです。
AthleteNameというデータクラスを作って、そのメソッドとしてパーサーを定義しました。from_textにstringを入力することでパースされたデータクラスのインスタンスが得られます。

```Python
@dataclass
class AthleteName:
    raw: str
    pre_silent_surface: str = ""
    pronounced_surface: str = ""
    post_silent_surface: str = ""
    pronunciation: str = ""

    @classmethod
    def from_text(cls, text: str) -> "AthleteName":
        normalized_raw = neologdn.normalize(text)
        pre_silent_surface, pronounced_surface, post_silent_surface, pronunciation = (
            cls._parse_name_text(normalized_raw)
        )
        return cls(
            raw=text,
            pre_silent_surface=pre_silent_surface,
            pronounced_surface=pronounced_surface,
            post_silent_surface=post_silent_surface,
            pronunciation=pronunciation,
        )

    @staticmethod
    def _parse_name_text(input_str) -> tuple[str, str, str, str]:
        """
        `(pre_silent_surface)pronounced_surface(post_silent_surface)(pronounciation)`
        という形式のtextをparseする。

        args:
            input_str (str): 入力テキスト

        returns:
            tuple[str, str, str, str]: pre_silent_surface, pronounced_surface, post_silent_surface, pronunciation
        """
        # 入力全体が最初または最後だけにカッコをもつ場合、カッコを削除
        if (
            input_str.startswith("(")
            and input_str.endswith(")")
            and input_str[1:-1].count("(") == input_str[1:-1].count(")") == 0
        ):
            input_str = input_str[1:-1]
        elif input_str.startswith("(") and ")" not in input_str:
            input_str = input_str[1:]
        elif input_str.endswith(")") and "(" not in input_str:
            input_str = input_str[:-1]

        # 正規表現パターンを定義
        pattern = r"(\(.*?\))?([^\(\)]+)(\([^\(\)]+\)){0,2}"

        match = re.fullmatch(pattern, input_str)
        if not match:
            raise ValueError(f"入力形式が正しくありません: {input_str}")

        # 各部分を取得
        pre = match.group(1)  # pre部分（0または1個）
        pronounced_surface = match.group(2)  # pronounced_surface部分（1個）
        post = re.findall(
            r"\([^\(\)]+\)", input_str[len(pre or "") + len(pronounced_surface) :]
        )  # post部分（0,1,2個）

        # preの括弧を除去して返す。空の場合は '' を返す
        pre_silent_surface = pre[1:-1] if pre else ""

        # postの処理
        post_silent_surface = ""
        pronunciation = ""
        if len(post) == 1:
            # 1つの場合
            post_content = post[0][1:-1]  # カッコを除去
            if re.fullmatch(
                r"[ぁ-んァ-ヶー・]+", post_content
            ):  # 全てがひらがなまたはカタカナ
                # pronounced_surfaceが外国人名らしき場合は、post_contentがpost_silent_surfaceである可能性を検討する
                # pronounced_surfaceがカナで、かつ、post_contentと異なる場合は、post_contentはpost_silent_surfaceであるとみなす
                if (
                    re.fullmatch(r"[ぁ-んァ-ヶー・]+", pronounced_surface)
                    and pronounced_surface != post_content
                ):
                    post_silent_surface = post_content
                else:
                    pronunciation = jaconv.hira2kata(post_content)
            else:
                post_silent_surface = post_content
        elif len(post) == 2:
            # 2つの場合
            post_silent_surface = post[0][
                1:-1
            ]  # 1つ目をpost_not_pronounced_surfaceとして扱う
            pronunciation = post[1][1:-1]  # 2つ目をpronunciationとして扱う

        return (
            pre_silent_surface,
            pronounced_surface,
            post_silent_surface,
            pronunciation,
        )
```

## テスト
テストしてみます。

```Python
def test_athlete_name_from_text():
    raw_text = "山田(やまだ)"
    name = AthleteName.from_text(raw_text)
    assert name.raw == raw_text
    assert name.pronounced_surface == "山田"
    assert name.pre_silent_surface == ""
    assert name.post_silent_surface == ""
    assert name.pronunciation == "ヤマダ"

    raw_text = "山田（やまだ）"
    name = AthleteName.from_text(raw_text)
    assert name.raw == raw_text
    assert name.pronounced_surface == "山田"
    assert name.pre_silent_surface == ""
    assert name.post_silent_surface == ""
    assert name.pronunciation == "ヤマダ"

    raw_text = "(山田)太郎"
    name = AthleteName.from_text(raw_text)
    assert name.raw == raw_text
    assert name.pronounced_surface == "太郎"
    assert name.pre_silent_surface == "山田"
    assert name.post_silent_surface == ""
    assert name.pronunciation == ""

    raw_text = "(山田)太郎(たろう)"
    name = AthleteName.from_text(raw_text)
    assert name.raw == raw_text
    assert name.pronounced_surface == "太郎"
    assert name.pre_silent_surface == "山田"
    assert name.post_silent_surface == ""
    assert name.pronunciation == "タロウ"

    raw_text = "(山田太郎)"
    name = AthleteName.from_text(raw_text)
    assert name.raw == raw_text
    assert name.pronounced_surface == "山田太郎"
    assert name.pre_silent_surface == ""
    assert name.post_silent_surface == ""
    assert name.pronunciation == ""

    raw_text = "(山田"
    name = AthleteName.from_text(raw_text)
    assert name.raw == raw_text
    assert name.pronounced_surface == "山田"
    assert name.pre_silent_surface == ""
    assert name.post_silent_surface == ""
    assert name.pronunciation == ""

    raw_text = "山田)"
    name = AthleteName.from_text(raw_text)
    assert name.raw == raw_text
    assert name.pronounced_surface == "山田"
    assert name.pre_silent_surface == ""
    assert name.post_silent_surface == ""
    assert name.pronunciation == ""

    raw_text = "ウィリー(ウィリアム・テル)"
    name = AthleteName.from_text(raw_text)
    assert name.raw == raw_text
    assert name.pronounced_surface == "ウィリー"
    assert name.pre_silent_surface == ""
    assert name.post_silent_surface == "ウィリアム・テル"
    assert name.pronunciation == ""
```

pytestを実行すると、無事通りました。

## 対応しなかったパターン

- 一つの人名が２行にまたがって書かれているケース
  - e.g. `ナニー(→\nナニー→)タ`
  - これは実際には「ナニー（→」と「ナニー→）タ」二分割されてそれぞれがパースされることになりますが、どちらもValueErrorになります。中途半端な箇所に閉じない、または開かないカッコがあるためです。ValueErrorになってくれるので、手動で修正すればよいです。

- 補足情報がついたうえで全体がカッコに囲まれている。
  - e.g.  `(陽(やん))`、`(森野 李(い))`
  - ValueErrorになります。補足情報がなければ全体のカッコとして削除されますが、中にカッコがあるためです。手動で修正します。
- チーム名が補足されている
  - e.g. `王（貞治）（読売）`、`王（読売）`
  - ValueErrorにならず、チーム名がpost_silent_surfaceかpronunciationのどちらかと認識されてしまいます。pronuncationがカナでないときは無条件に警告を出すルールをいれたら多少はマシもしれないのですが、「ジャイアンツ」だったらパスされてしまうので、根本的な解決は難しいです。まともに対応しようと思うと、チーム名の辞書を保つ必要がありますが、今回はそこまではやっていません。
- 読みの一部が補足されている。
  - e.g. `相沢晋（すすむ）`
  - ValueErrorにならず、pronunciationが「あいざわすすむ」ではなく「すすむ」になってしまいます。解決は難しいです。


# おわりに
野球選手名で歌われた替え歌歌詞に登場する補足情報付きの人名のパース方法を考えてみました。実際に使ってみたところ、ValueErrorが出ずに失敗するパターンがやっかいではありますが、別の工程のためにどのみち目視で全体を確認することがあって、そのときには気づけたので、なんとかなりました。

〇〇で歌ってみたの歌詞分析というかなりニッチな領域ではありますが、他の領域でも、人名の補足情報自体は、今回のようなパターンになることもありそうなので、何かしら参考になる部分がありましたら幸いです。