Sansan Builders Box

Sansanのものづくりを支える技術やデザイン、プロダクトマネジメントの情報を発信

【Techの道も一歩から】第15回「Pythonによる正規表現のまとめ」

f:id:s_yuka:20180928134040j:plain

こんにちは。 DSOC R&D グループの高橋寛治です。

今回は、何かと忘れて検索しがちな正規表現についてまとめたいと思います。

正規表現の文法やPythonコードによる具体例で書くことで、備忘録としたいと思います。

正規表現とは?

正規表現( Regular Expression )とは、ある文字列を表現する表記法のことです。

ある文字列を表現するという特性を用いて、文字列の一致や置換、検索に利用されています。

どのような表記法か一例を挙げます。 たとえば、 ^ab.de$ という正規表現は、 ab を先頭に任意の1文字があり de で終わる文字列を表します。 abcdeabade と一致しますが、 abdddeabde とは一致しません。

Pythonコードで、あえて愚直に表現すると、次のようになります。

def match(text):
    return text[:2] == "ab" and text[-2:] == "de" and len(text) ==5

これだと、少し正規表現が変わった際に大きくコードを変更する必要が出てきます。

そこに有限オートマトン( Finite Automaton, Finite State Machine )の考え方を適用していきます。 これは、有限個の状態と遷移、動作の組み合わせにより、あるシステムのふるまいをモデル化するものです。 状態遷移図で表現することができます。 先ほどの ^ab.de$ について、次に状態遷移図を示します。

f:id:kanjirz50:20181126181136p:plain

丸が 状態 、矢印が 遷移 、 矢印の上の文字は 遷移条件 を表します。 最初の i は始まりを表しており、最後の f の二重丸は 受理 を示します。 このオートマトンは、先ほどの正規表現 ^ab.de$ を表しており、a b 任意の一文字 d e という文字列が入ってきたときに受理、すなわち一致するということです。

さて、NFAやDFAを実装して正規表現の内部についてより理解を深めたいところですが、理論はこのあたりにしておいて、実際の使い方に入っていきます。

Pythonで提供されている正規表現モジュール

Pythonでは標準モジュール re が提供されています。

モジュールが提供する関数や使い方を例示していきます。

matchとマッチオブジェクト

正規表現モジュールをインポートして、 match メソッドを利用します。

文字列 r"ab.de" の先頭に r が付加されています。 これは raw string 記法で、エスケープシーケンスを展開せずそのまま適用します。 正規表現では基本的に raw string 記法を用います。

>>> import re
>>> pattern = r"ab.de"
>>> string = "abcde"
>>> re.match(pattern, string)
<_sre.SRE_Match object; span=(0, 5), match='abcde'>

string により与えられた文字列が、正規表現 pattern に先頭からマッチすれば、マッチオブジェクトのインスタンスが返されます。

マッチオブジェクトは、正規表現に合致した場合の結果を保持しており、常に True となります。 つまりマッチしない場合は、マッチオブジェクトではなく None が返されます。 マッチオブジェクトは、 True を持っており、後方参照を置換する expand メソッドやマッチしたグループを返す group メソッドを持ちます。 マッチした部分の先頭や末尾のインデックスを属性として持っており、文字列処理で便利です。

基本的な走査メソッド

まずは基本的な走査メソッドである search, match, fullmatch の違いを見比べます。

>>> import re
>>> re.search(r"ab.de", "abcde")
<_sre.SRE_Match object; span=(0, 5), match='abcde'>
>>> re.search(r"ab.de", "aaabcde")
<_sre.SRE_Match object; span=(2, 7), match='abcde'>
>>> re.match(r"ab.de", "abcde")
<_sre.SRE_Match object; span=(0, 5), match='abcde'>
>>> re.match(r"ab.de", "aaabcde")

>>> re.fullmatch(r"ab.de", "abcde")
<_sre.SRE_Match object; span=(0, 5), match='abcde'>
>>> re.fullmatch(r"ab.de", "aaabcde")

違いがわかったでしょうか。 search は正規表現がマッチする最初の箇所をマッチオブジェクトで返します。 match は正規表現を先頭から適用し、適用した箇所をマッチオブジェクトで返します。 fullmatch は正規表現と文字列が完全に一致した場合に、マッチオブジェクトを返します。

次に search, findall, finditer を比べます。

>>> import re
>>> re.search(r"ab.de", "abcde_abdde")
<_sre.SRE_Match object; span=(0, 5), match='abcde'>
>>> re.findall(r"ab.de", "abcde_abdde")
['abcde', 'abdde']
>>> for m in re.findall(r"ab.de", "abcde_abdde"):
>>>     print(type(m))
<class 'str'>
<class 'str'>
>>> re.finditer(r"ab.de", "abcde_abdde")
<callable_iterator at 0x10749b400>
>>> for m in re.finditer(r"ab.de", "abcde_abdde"):
>>>     print(m)
<_sre.SRE_Match object; span=(0, 5), match='abcde'>
<_sre.SRE_Match object; span=(6, 11), match='abdde'>

search は先ほどの通りで、正規表現にマッチする最初の箇所をマッチオブジェクトで返します。 findall は正規表現にマッチする箇所すべてを str のリストで返します。 finditer は正規表現にマッチする箇所をマッチオブジェクトを返すイテレータで返します。

置換

>>> import re
>>> pattern = r"ab.de"
>>> repl = "SUB"
>>> string = "This abcde."
>>> re.sub(pattern, repl, string)
'This SUB.'

pattern にマッチする string 中の箇所を repl で置換します。

分割

>>> import re
>>> re.split(r"[、。]", "私は、海を眺めた。とてもきれいだった。")
['私は', '海を眺めた', 'とてもきれいだった', '']

正規表現 [、。] にマッチする箇所で分割されます。

コンパイル

Pythonでは正規表現パターンを正規表現オブジェクトとして保持する方法があります。 re.compile 関数により正規表現オブジェクトを得ることができます。

>>> import re
>>> re_obj = re.compile(r"ab.de")
>>> re_obj.match("abcde")
<_sre.SRE_Match object; span=(0, 5), match='abcde'>

compile 済みの正規表現にすることで、正規表現オブジェクト名.メソッド となりコードの可読性の向上に努めます。

>>> import re
>>> re_mobile_phone_number = re.compile(r"0[789]0-\d{4}-\d{4}")
>>> re_mobile_phone_number.match("090-1234-5678")
<_sre.SRE_Match object; span=(0, 13), match='090-1234-5678'>
>>> re_mobile_phone_number.match("03-1234-5678")

マッチオブジェクトの名前付け機能

マッチオブジェクトには groupdict メソッドがあり、これはグループに名前をつけて辞書として保持することができます。

>>> import re
>>> re_obj = re.search(r"(?P<姓>\w+) (?P<名>\w+)", "高橋 寬治")
>>> re_obj
<_sre.SRE_Match object; span=(0, 5), match='高橋 寬治'>
>>> re_obj.groupdict()
{'名': '寬治', '姓': '高橋'}

意外と使える機能なので覚えておくといいと思います。

正規表現の文法とPythonでのサンプル

Pythonで提供されている正規表現のメソッドや関数についてよく使うものの一例を記しました。 ここからは、正規表現の文法を Python のコードにしてまとめます。

import re

# 任意の一文字にマッチ「.」
assert re.search(r"a.c", "abc") is not None
assert re.search(r"a.c", "abbc") is None

# 先頭を表す「^」
assert re.search(r"^a.c", "abc") is not None
assert re.search(r"^a.c", "aabc") is None

# 末尾を表す「$」
assert re.search(r"a.c$", "aabc") is not None
assert re.search(r"a.c$", "abcc") is None

# 直前の正規表現を0回以上繰り返す「*」
assert re.search(r"ab*c", "abbbbc") is not None
assert re.search(r"ab*c", "ac") is not None
assert re.search(r"ab*c", "adc") is None

# 直前の正規表現を1回以上繰り返す「+」
assert re.search(r"ab+c", "abbbbc") is not None
assert re.search(r"ab+c", "ac") is None

# 直前の正規表現が0回または1回の繰り返し「?」
assert re.search(r"ab?c", "ac") is not None
assert re.search(r"ab?c", "abc") is not None
assert re.search(r"ab?c", "adc") is None

# 非貪欲マッチ(できるかぎり少ない文字数でマッチ)「*?」
assert re.search(r"<.*>", "<a><b>")[0] == "<a><b>"
assert re.search(r"<.*?>", "<a><b>")[0] == "<a>"

# 直前の正規表現をm回繰り返し「{m}」
assert re.search(r"ab{3}c", "abbbc") is not None
assert re.search(r"ab{3}c", "abc") is None

# 直前の正規表現をm回からn回までの繰り返し「{m,n}」
assert re.search(r"ab{3,5}c", "abbbc") is not None
assert re.search(r"ab{3,5}c", "abbbbc") is not None
assert re.search(r"ab{3,5}c", "abbbbbc") is not None
assert re.search(r"ab{3,5}c", "abc") is None

# 直前の正規表現をm回からn回までの繰り返しの非貪欲マッチ「{m,n}?」
assert re.search(r"a{3,5}?", "aaa")[0] == "aaa"
assert re.search(r"a{3,5}?", "aaaa")[0] == "aaa"
assert re.search(r"a{3,5}?", "aaaaa")[0] == "aaa"

# 文字の集合「[]」
assert re.search(r"a[efg]c", "aec") is not None
assert re.search(r"a[efg]c", "afc") is not None
assert re.search(r"a[efg]c", "agc") is not None
assert re.search(r"a[efg]c", "abc") is None
# ハット「^」で否定
assert re.search(r"a[^efg]c", "abc")
assert re.search(r"a[^efg]c", "aec") is None

# 正規表現のグループ化「()」と、いずれかの正規表現にマッチ「|」
assert re.search(r"a(ef|gh)c", "aefc") is not None
assert re.search(r"a(ef|gh)c", "aghc") is not None
assert re.search(r"a(ef|gh)c", "aec") is None

# Pythonの拡張記法
# 「(?P<name>)」 で名前 name によりアクセス可能
assert re.search(r"(?P<姓>\w+) (?P<名>\w+)", "高橋 寬治").groupdict() == {'名': '寬治', '姓': '高橋'}
# 拡張記法で、Ignore caseなど指定可能。
assert re.search(r"(?i)ABC", "abc") is not None
assert re.search(r"ABC", "abc") is None

# 肯定先読み「(?=)」(直後にdeのあるabcにマッチ)
assert re.search(r"abc(?=de)", "abcde")[0] == "abc"
assert re.search(r"abc(?=de)", "abcd") is None

# 否定先読み「(?!)」(直後にdeのないabcにマッチ)
assert re.search(r"abc(?!de)", "abcfg")[0] == "abc"
assert re.search(r"abc(?!de)", "abcde") is None

# 肯定後読み「(?<=)」(直前にabcがあるdeにマッチ)
assert re.search(r"(?<=abc)de", "abcde")[0] == "de"
assert re.search(r"(?<=abc)de", "bcde") is None

# 否定後読み「(?<!)」(直前にabcがないdeにマッチ)
assert re.search(r"(?<!abc)de", "bcde")[0] == "de"
assert re.search(r"(?<!abc)de", "abcde") is None

# 後方参照「\1」(一致したグループの正規表現を後ろで利用)
assert re.search(r"(a.c)de\1", "abcdeabc")[0] == "abcdeabc"
assert re.search(r"(a.c)de\1", "abcdeadc") is None
assert re.search(r"(a.c)de\1", "adcdeadc")[0] == "adcdeadc"

# 特殊シーケンスの一例
# 数値「\d」
assert re.search(r"\d+", "123")[0] == "123"
assert re.search(r"\d+", "12a")[0] == "12"
# ユニコード文字「\w」
assert re.search(r"\w+", "123abc")[0] == "123abc"
assert re.search(r"\w+", "123a bc")[0] == "123a"
# 空白文字「\s」
assert re.search(r"a\sb", "a b")
assert re.search(r"a\sb", "ab") is None

実際の利用例

正規表現の確認方法

私が活用する正規表現の確認方法をいくつか提示します。

まずは正規表現を可視化する方法です。 Webサイトやエディタのプラグインで提供されています。

Regulexというサイトをよく利用します。

次のように可視化でき、一目瞭然となります。

また、正規表現はテストするといいと思います。 先ほどのPythonでの例では説明なく assert を利用していましたが、開発段階ではアサーションするのがいいかと思います。

assert 式は与えられた条件が False の場合に例外を送出します。 この仕組みを使って、意図した文字列が抽出されているかをアサーションします。

# 正例
>>> assert re.search(r"ab.de", "abcde")[0] == "abcde"
# 例外が出る例
>>> assert re.search(r"ab.de", "abde")[0] == "abde"

後半の例外は assert のエラーではなく、None に対してスライスしようとしていて、例外が送出されています。

検証時には assert とし、 開発時には運用のために unittest を書くのがいいかと思います。

アルファベット判定

Python の strisalpha というすべての文字列が英字かどうかを判定するメソッドがあるのですが、ここでの英字はUnicode文字でLetterと定義されているもののことです。 ひらがなや漢字がLetterになっているため、Trueとなってしまいます。

正規表現で英字のみかどうかを判定します。

def isalpha(text):
    return re.fullmatch(r"[a-zA-Z]+", text)

このように fullmatch メソッドと [a-zA-Z] が1文字以上連続という正規表現で、与えられた文字列が英字のみかどうかを判定するメソッドができました。

括弧内の文字を削除

前処理の一つとして、括弧と括弧内の文字を削除したい場合があると思います。 次のものは簡易ですが、全角の括弧に囲まれた文字列の連続を削除します。

>>> re.sub(r"(\w+)", "", "これは(実は)テストです。")
これはテストです。

すべてのパターンを網羅するのは難しいので、簡単なパターンで荒削りできれば前処理としては十分かと思います。 実際のテキストでは括弧が閉じずに飽きっぱなしで、正規表現周りでトラブルが発生するということもあります。

HTMLからタイトルを抽出

たとえば AWS Lambda のようにアプリケーションが利用できるストレージが極端に小さいという制約がある場合、不要なパッケージは減らしたくなります。

たとえば HTML からタイトルの文字列を抽出したいというときには次の表現で、ある程度抽出できます。

>>> re.search(r"(?i)<title>(.*?)</title>", "hoge\n <title>タイトルです</title>").group(1)

ユニコード文字プロパティを使う

日本語処理においてはユニコード文字プロパティが利用可能な regex モジュールがオススメです。 標準モジュール re と同等のインターフェイスを持つため、 import regex as re とすることで、違いを意識せずに利用できます。

ユニコード文字プロパティとは、文字の集合に名前がつけられたものです。 漢字は \p{Han} 、ひらがなは \p{Hiragana} というふうに付与されています。

>>> import regex as re
>>> re.search(r"\p{Han}+", "This is 日本語.")[0]
日本語

正規表現を使いこなして楽に処理

正規表現は複雑なように見えますが、普段の前処理や抽出処理で利用する際の正規表現は意外とシンプルです。

使い方を丸暗記することは難しいですが、使い方やどのような機能があったかを覚えておくだけで、無駄なコーディングを減らすことができると思います。

執筆者プロフィール

高橋寛治 Sansan株式会社 Data Strategy & Operation Center R&Dグループ研究員

阿南工業高等専門学校卒業後に、長岡技術科学大学に編入学。同大学大学院電気電子情報工学専攻修了。在学中は、自然言語処理の研究に取り組み、解析ツールの開発や機械翻訳に関連する研究を行う。大学院を卒業後、2017年にSansan株式会社に入社。現在はキーワード抽出など自然言語処理を生かした研究に取り組んでいる。

© Sansan, Inc.