nikkie-ftnextの日記

イベントレポートや読書メモを発信

文字列 中 の 半角 スペース を 正規表現 を 使っ て 削除 する

はじめに

文字列中の半角スペースを正規表現を使って削除する、nikkieです。

先日、はんなりプログラミングの会のボーネンLT会にて文字列の正規化処理を話しました。
その中から、「文字列中の半角スペースの削除」をエントリ化しちゃいます!

※このエントリ中のコードは、Python 3.10.2で動作確認しています

目次

文字列の正規化処理LTのコンテキスト

形態素解析エンジンとして名を知られるMeCab
MeCabは辞書を指定して使えます。
MeCabの辞書の1つとして知られるのが、mecab-ipadic-neologd。
この辞書は新語に強い辞書です(が、2020年で更新が止まってしまったと認識しています)。

mecab-ipadic-neologdの辞書データを作る上で実施している文字列の正規化処理は以下のページ「解析前に行うことが望ましい文字列の正規化処理」で確認できます。

以下の部分が「文字列中の半角スペースの削除」に該当します。

「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」間に含まれる半角スペースは削除
「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」と「半角英数字」の間に含まれる半角スペースは削除

これは正規表現を使って実装されています(WikiにはPythonの実装例もあります)。
LTやこのエントリでは、それを読み解きます。

なお、mecab-ipadic-neologdを指定して形態素解析をするときには、このWikiの正規化処理を適用しておくとよりよいという理解です。

正規表現を使って半角スペース1つに揃えているとします

文字列の前後の空白文字は、引数を与えないstrip()メソッド1呼び出しで除けます(上で紹介したWikiにもあります)。

>>> "      前に半角スペース、後ろに全角スペースのあるテキスト   ".strip()
'前に半角スペース、後ろに全角スペースのあるテキスト'

文字列の中には

  • 半角スペースだけでなく全角スペース
  • スペース1つだけでなく、複数個の連続

もあるかもしれません。
Wikiにもあるのですが、これは正規表現で半角スペース1つに置換されているとします。

>>> import re
>>> # 0x20(SPACE)か0x3000(IDEOGRAPHIC SPACE)の1つ以上の繰り返しを、0x20(SPACE)に置換します
>>> re.sub("[  ]+", " ", "全角 や連続するスペース  を含んだ   文字列")
'全角 や連続するスペース を含んだ 文字列'

それでは、文字列中の半角スペースを削除する方法を見ていきます。
実装できると、最後の例は'全角や連続するスペースを含んだ文字列'となります。

正規表現を使って、文字列中の半角スペースを削除する実装

以下の半角スペースを削除します。

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

正規表現で文字の範囲を表す

Wikiにありますが、「半角英数字」と「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」は以下のように表すことができます。

>>> 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
...     )
... )

半角スペースを含む正規表現のパターン

正規表現パターンとしては3通りです。

  1. 「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」 + 半角スペース + 「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」
  2. 「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」 + 半角スペース + 「半角英数字」
  3. 「半角英数字」 + 半角スペース + 「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」

文字列のformatメソッドを使って表します。

>>> pattern1 = re.compile("([{}]) ([{}])".format(blocks, blocks))
>>> pattern2 = re.compile("([{}]) ([{}])".format(blocks, basic_latin))
>>> pattern3 = re.compile("([{}]) ([{}])".format(basic_latin, blocks))

これらは「一文字 + 半角スペース + 一文字」に該当します。

後方参照を使って置換する

Wikiの例ですが、"アルゴリズム C""アルゴリズムC"とする事を考えましょう。
pattern2、「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」 + 半角スペース + 「半角英数字」のケースですね。

「一文字 + 半角スペース + 一文字」だと「ム + 半角スペース + C」と該当します。

>>> # https://docs.python.org/ja/3/library/re.html#re.Pattern.search
>>> m = pattern2.search("アルゴリズム C")
>>> m
<re.Match object; span=(5, 8), match='ム C'>
>>> m.group(1)
'ム'
>>> m.group(2)
'C'

これを「ムC」としたいわけですよね。
後方参照というもので実現します。

>>> pattern2.sub(r"\1\2", "アルゴリズム C")
'アルゴリズムC'

正規表現リテラルr"\1\2"\1\2が後方参照です。
re.subのドキュメント2に説明がありました。

\6 のような後方参照は、パターンのグループ 6 がマッチした部分文字列で置換されます。

(略)\g<number> は対応するグループ番号を使います。よって \g<2>\2 と等価ですが、(略)

r"\1\2"グループ1とグループ2を(半角スペースは含めずに)並べることになります。
searchしたときの例で"アルゴリズム C"では'ム C'がマッチし、グループ1が'ム'、グループ2が'C'でした。
r"\1\2"により'ムC'(半角スペースが削除された文字列)となります!

subメソッドではパターンに該当する部分を全て置換できます(詳細は後述)。

>>> pattern2.sub(r"\1\2", "アルゴ B リズム C")
'アルゴB リズムC'

残った半角スペースはpattern3(「半角英数字」 + 半角スペース + 「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」)に該当しますね。
つまり、3つのパターンについて後方参照を使って、半角スペースを除くことで、文字列中の半角スペースは除けます!!
これを実装したのが上で示したgistです。

subメソッドはパターンに該当する部分を全て置換できるけれど気をつけて!

以下の例では半角スペースを全て削除できていますよね。

>>> pattern3.sub(r"\1\2", "A アルゴ B リズム C 本")
'Aアルゴ Bリズム C本'
>>> pattern1.sub(r"\1\2", "アルゴ リズム 本")
'アルゴリズム本'

では「アイ の 歌声 を 聴か せ て」3ではどうでしょうか?

>>> pattern1.sub(r"\1\2", "アイ の 歌声 を 聴か せ て")
'アイの 歌声を 聴かせ て'

半角スペースが残りましたね😢
残った半角スペースに注目すると「の」「を」「せ」の右側と、両側に半角スペースのあった1文字の単語です。
これは「イ の」かつ「の 歌」と同時にマッチできないことから生じたと理解しました。
subメソッドで置き換えたときに、1文字の両側の半角スペースは片側しか置換できません。
なのでWikiではwhile文で実装していると理解しました4

>>> text = "アイ の 歌声 を 聴か せ て"
>>> while pattern1.search(text):
...     text = pattern1.sub(r"\1\2", text)
>>> text
'アイの歌声を聴かせて'

終わりに

正規表現を使って、文字列中の半角スペースを削除する方法についてアウトプットしました。
ここで示した実装を使って

  • 「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」の間の半角スペース
  • 「ひらがな・全角カタカナ・半角カタカナ・漢字・全角記号」と「半角英数字」の間の半角スペース

を削除できます!

ポイントは

  • Unicodeのコードポイントで文字の範囲を表し
  • 後方参照を使って半角スペースをなくした文字列に置き換える

の2点でしょうか。
この記事で段階を追ってまとめるにあたって、なぜwhile文を使っているかといった発見もありました。

これは言ってみれば分かち書きの逆処理ですよね。
正規表現を使えば実現できる!というのは目からウロコでした。

$ echo '歩き出そう Dreaming Way' | mecab -Owakati
歩き 出 そう Dreaming Way

(gistのコードを-iオプション付きで実行してから)

normalizer = RemoveWhitespaceNormalizer()
normalizer.normalize("歩き 出 そう Dreaming Way")
# '歩き出そうDreaming Way'

P.S. 「歩き出そう Dreaming Way」の出典🎀

歌詞をプログラミングの対象にするとめっちゃアガりますね。


  1. https://docs.python.org/ja/3/library/stdtypes.html#str.strip 「chars が省略されるか None の場合、空白文字が除去されます。」
  2. https://docs.python.org/ja/3/library/re.html#re.sub
  3. 大好きなアニメ映画です。12月は関東の映画館で見られるのでみんな観て! (12/31 川崎でも!)
  4. 見落としがあるかもしれませんが、pattern2, pattern3はsubメソッド呼び出しが常に1回(1文字の両側の半角スペースというケースがないため)、pattern1はsubメソッドの呼び出しが多くても2回(1文字の両側の半角スペースは2回のsubで除ける)なのでwhileを使わずに実装もできると思います