Norm@lizer(文字列の正規化処理について)

Event:

ボーネンLT会 はんなりプログラミングの会

Presented:

2022/12/16 nikkie

お前、誰よ

本編:自然言語処理の話です

  • 日本語は文の中の単語が区切られていない

  • 形態素解析 して

    • 単語分割=分かち書き

    • 品詞付与

「オープンソース 形態素解析エンジン」MeCab

言語, 辞書,コーパスに依存しない汎用的な設計

形態素解析に使う辞書

mecab-ipadic-NEologd を作る上で

Part 1️⃣ 正規化処理 クローズアップ

  • スペース削除

  • 置換

  • 全角・半角変換

先頭末尾 の半角スペース削除

>>> "      テキストの前".strip()
'テキストの前'
>>> "テキストの後      ".strip()
'テキストの後'

https://docs.python.org/ja/3/library/stdtypes.html#str.strip

文字を置換 re.sub(pattern, repl, string)

string 中に出現する最も左の重複しない pattern を置換 repl で置換することで得られる文字列を返します。

https://docs.python.org/ja/3/library/re.html#re.sub

❗️replが関数のときは、動きがちょっと異なります

文字を置換して揃える

  • ハイフンマイナスっぽい文字は - HYPHEN-MINUS

  • 長音記号っぽい文字は KATAKANA-HIRAGANA PROLONGED SOUND MARK

    • 1回以上連続する長音記号は1つにまとめる

文字を置換して揃える

>>> import re
>>> # ハイフンマイナスっぽい文字を揃える
>>> re.sub("[˗֊‐‑‒–⁃⁻₋−]+", "-", "o₋o")
'o-o'
>>> # 長音記号っぽい文字を揃える
>>> # & 1回以上連続する長音記号は1つにまとめる(replのーはpatternに含まれている)
>>> re.sub("[﹣-ー—―─━ー]+", "ー", "majika━")
'majikaー'
>>> re.sub("[﹣-ー—―─━ー]+", "ー", "スーパーーーー")
'スーパー'

空文字列に置換(=削除)

  • チルダっぽい文字は削除

>>> re.sub("[~∼∾〜〰~]", "", "わ〰い")
'わい'

全角・半角変換 unicodedata.normalize(form, unistr)

Unicode 文字列 unistr の正規形 form を返します。
form の有効な値は、'NFC'、'NFKC'、'NFD'、'NFKD' です。

https://docs.python.org/ja/3/library/unicodedata.html#unicodedata.normalize

全角英数字を半角に 置換できる

>>> import unicodedata
>>> unicodedata.normalize("NFKC", "012ABCxyz")
'012ABCxyz'

半角カタカナを全角に 置換できる

>>> hankaku = "ハンガク"
>>> len(hankaku)
5
>>> unicodedata.normalize("NFKC", hankaku)
'ハンガク'
>>> len(_)
4

全角英数字と半角カタカナについて処理

>>> import re
>>> import unicodedata
>>> # 全角英数字と半角カタカナを表す正規表現
>>> pattern = re.compile("([0-9A-Za-z。-゚]+)")
>>> "".join(
...     unicodedata.normalize("NFKC", x) if pattern.match(x) else x
...     for x in re.split(pattern, "012ABCxyzはハンガク")
... )
'012ABCxyzはハンガク'

補足情報🏃‍♂️

全角・半角変換 記号

  • 半角に揃えたい記号

  • 全角に揃えたい記号

以下の変換ルールとする

  • 以下の全角記号は半角に置換

    • /!”#$%&’()*+,−./:;<>?@[¥]^_`{|}

  • 以下の半角記号は全角に置換

    • 。、・=「」

今回紹介する実装

  1. 記号を 全角に揃える

  2. 半角に置換する記号だけ置換する

str.translate(table)

与えられた変換テーブルに基づいて文字列を構成する各文字をマッピングし、マッピング後の文字列のコピーを返します。

https://docs.python.org/ja/3/library/stdtypes.html#str.translate

str.translate(table)

文字から文字への異なる形式のマッピングから変換マップを作成するために、 str.maketrans() が使えます。

https://docs.python.org/ja/3/library/stdtypes.html#str.translate

str.maketrans

>>> mapping = str.maketrans(
...     '!"#$%&\'()*+,-./:;<=>?@[¥]^_`{|}~。、・「」',
...     "!”#$%&’()*+,-./:;<=>?@[¥]^_`{|}〜。、・「」"
... )
>>> ord("#")
35
>>> mapping[ord("#")]  # Unicodeコードポイント間の変換を表す
65283
>>> chr(_)
'#'

変換マップを使って str.translate

>>> "!#".translate(mapping)
'!#'

記号を全角に揃えられる!

半角に置換する記号だけ置換する

再度の unicodedata.normalize

>>> unicodedata.normalize("NFKC", "!#")  # 半角に戻る
'!#'
>>> pattern = re.compile("([!”#$%&’()*+,-./:;<>?@[¥]^_`{|}〜]+)")
>>> "".join(
...     unicodedata.normalize("NFKC", x) if pattern.match(x) else x
...     for x in re.split(pattern, "!。#".translate(mapping))
... )
'!。#'

クォートだけ注意

>>> # 全角にしたクォートは半角に戻らない
>>> unicodedata.normalize("NFKC", "”%’")
'”%’'
>>> # re.subで半角に戻す
>>> re.sub('[’]', "'", _)  # RIGHT SINGLE QUOTATION MARK
"”%'"
>>> re.sub('[”]', '"', _)  # RIGHT DOUBLE QUOTATION MARK
'"%\''

スペースの削除

先頭と末尾の半角スペース以外も

  • 全角スペースは半角スペースに置換

  • 1つ以上の半角スペースは、1つの半角スペースに置換

s = re.sub('[  ]+', ' ', s)

文字列中 の半角スペースを削除!

  • 「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」間に含まれる場合

    • 消える例:「アイ の 歌声 を 聴か せ て」

  • 「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」と「半角英数字」の間に含まれる場合

    • 消える例:「アルゴリズム C」

正規表現で文字列中の半角スペース削除

>>> basic_latin = "\u0000-\u007F"  # 半角英数字
>>> # 「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」
>>> blocks = "".join(
...     (
...         "\u4E00-\u9FFF",  # CJK UNIFIED IDEOGRAPHS
...         "\u3040-\u309F",  # HIRAGANA
...         "\u30A0-\u30FF",  # KATAKANA
...         "\u3000-\u303F",  # CJK SYMBOLS AND PUNCTUATION
...         "\uFF00-\uFFEF",  # HALFWIDTH AND FULLWIDTH FORMS
...     )
... )

正規表現の後方参照

>>> pattern = re.compile("([{}]) ([{}])".format(blocks, basic_latin))
>>> m = pattern.search("アルゴリズム C")
>>> m.group(1)
'ム'
>>> m.group(2)
'C'
>>> # \1 は \g<1> と等価。グループ番号1がマッチした部分文字列
>>> pattern.sub(r"\1\2", "アルゴリズム C")  # 半角スペースを削除
'アルゴリズムC'

後方参照が見つかる限り、半角スペースを削除

>>> pattern = re.compile("([{}]) ([{}])".format(blocks, blocks))
>>> s = "アイ の 歌声 を 聴か せ て"
>>> while pattern.search(s):
...     s = pattern.sub(r"\1\2", s)
>>> s
'アイの歌声を聴かせて'

分かち書きが戻せた!

Part 2️⃣ 真似して作る正規化処理

部品化 した処理を 組合せる 実装を提案

完全に理解した!

  • 「解析前に行うことが望ましい文字列の正規化処理」が何をしているかは分かった

  • 自分だったらどう実装するか を考えてみる

  • どんなAPIにしようか、OSSを観察

🤗 tokenizers

🔍発見1:インターフェースの 統一

  • ベースクラス Normalizer

  • normalize_str(text) は text を正規化した文字列を返す

🔍発見2:正規化処理の 部品化

  • NFKC :Unicode正規化処理だけするNormalizer

  • StripReplace など

  • Normalizer クラスを継承しているので、どれも normalize_str を呼び出せばよい

Sequence がにくい

  • いくつかの正規化処理をまとめた正規化処理 を表す

  • Normalizer を継承しているので normalize_str を呼び出せばよい

今回は Protocol で(素振り)

Normalizer プロトコル

@runtime_checkable
class Normalizer(Protocol):
    def normalize(self, text: str) -> str:
        ...

補足情報🏃‍♂️

normalize メソッドがあれば(継承していなくても)Normalizer

>>> class C:
...   def normalize(self, text: str) -> str:
...     return "spam"
>>> issubclass(C, Normalizer), isinstance(C(), Normalizer)
(True, True)
>>> class D:
...   ...
>>> issubclass(D, Normalizer)
False
>>> class E:
...   def normalize(self):  # シグネチャはプロトコルと一致しない
...     return "e"
>>> issubclass(E, Normalizer), isinstance(E(), Normalizer)
(True, True)

具体 Normalizer たち

class Strip(Normalizer):
    def normalize(self, text: str) -> str:
        return text.strip()
class Replace(Normalizer):
    def __init__(self, pattern, repl) -> None:
        self.pattern = pattern
        self.repl = repl

    def normalize(self, text: str) -> str:
        return re.sub(self.pattern, self.repl, text)

Normalizerたちをまとめ上げる Sequence

class Sequence(Normalizer):
    """
    >>> sut = Sequence([Lowercase(), NFKC()])
    >>> sut.normalize("NikkiE")
    'nikkie'
    >>> sut.normalize("".join([chr(0X30D5), chr(0X309A), chr(0X30ED)]))
    'プロ'
    """

    def __init__(self, normalizers: AbcSequence[Normalizer]) -> None:
        self.normalizers = normalizers

    def normalize(self, text: str) -> str:
        normalized = text
        for normalizer in self.normalizers:
            normalized = normalizer.normalize(normalized)
        return normalized

文字列中の半角スペースを削除するNormalizer

class RemoveExtraSpaces(Normalizer):
    BLOCKS = "".join(
        (
            "\u4E00-\u9FFF",  # CJK UNIFIED IDEOGRAPHS
            "\u3040-\u309F",  # HIRAGANA
            "\u30A0-\u30FF",  # KATAKANA
            "\u3000-\u303F",  # CJK SYMBOLS AND PUNCTUATION
            "\uFF00-\uFFEF",  # HALFWIDTH AND FULLWIDTH FORMS
        )
    )
    BASIC_LATIN = "\u0000-\u007F"

    def __init__(self):
        self.normalizer = Sequence(
            [
                Replace("[  ]+", " "),
                RemoveSpaceBetween(self.BLOCKS, self.BLOCKS),
                RemoveSpaceBetween(self.BLOCKS, self.BASIC_LATIN),
                RemoveSpaceBetween(self.BASIC_LATIN, self.BLOCKS),
            ]
        )

    def normalize(self, text: str) -> str:
        return self.normalizer.normalize(text)

ふるまいは同じです!🙌

def normalize_neologd(s):
    normalizer = NeologdNormalizer()
    return normalizer.normalize(s)


if __name__ == "__main__":
    assert "0123456789" == normalize_neologd("0123456789")
    assert "ABCDEFGHIJKLMNOPQRSTUVWXYZ" == normalize_neologd(
        "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    )

まとめ🌯: Norm@lizer

  • mecab-ipadic-NEologdの文字列の正規化処理を理解し、真似た

  • 正規表現や str.translate を駆使して正規化している

  • 🤗/tokenizersを参考にし、処理を部品化した実装を提案

ご清聴ありがとうございました!

さよなら2022年!!!🐯

mecab-ipadic-NEologd、学びをありがとう!

Appendix 関連アウトプット

EOF