【 Python + Regular Expressions 】正規表現の 先読み ( look ahead; ?=expression ) 機能を使いこなそう! 投稿一覧へ戻る
Published 2020年9月10日21:37 by mootaro23
SUPPORT UKRAINE
- Your indifference to the act of cruelty can thrive rogue nations like Russia -
例えばファイルから読み込んだテキストに含まれるセンテンスの数を数える場合、'.'、'?'、'!' いずれかのリテラル文字で終わる場所を 1 つのセンテンスの最後と判断し、non-greedy にマッチさせるパターンを作成、findall() から返ってきたマッチリストの長さを取得します。
マッチオブジェクトを作成する際に findall() に指定している DOTALL フラグは、'.' 特殊文字において「改行文字 ( \n )」も含めるように指示するためです。
これによってセンテンスが行を跨いでいても対応可能になります。
これは期待通りに 5 を返してくれますが、もしテキストに「略字」や「小数点を含む数字」が含まれていた場合は期待通りには動いてくれません。
regular-expression システムは、現在のポジションの直後にある文字集団がパターンとマッチするか、を判断する 先読み ( look-ahead ) 機能 を備えています。
この look ahead パターン自体にマッチした文字集団は「消費」されません。
すなわち、「先読み」されたパターン部分は、続くマッチ検索において再び「検索文字列の一部」として検索対象になる、ということです。
この機能を利用することで、上の例でうまくいかなかったセンテンス数の数え上げを期待通りに実行することが可能になります。
これら 1 から 4 の条件をみたすように、先読み構文を利用してパターンを構築しましょう。
この例の string は 1 つ目と 2 つ目の string を合体させたものですからセンテンスは 8 つです。正解です!
また、ここでは findall() に渡すフラグとして re.MULTILINE を追加しています。
これによって、'$' 特殊文字が、「文字列の末尾」だけではなく「行末の改行文字 ( \n )」にもマッチする ようになります。
そのため、センテンスがぴったり行末で終わっていても、この look ahead (先読み) パターンがマッチするんです。
さて、パターン文字列が少し複雑ですね。1 つ 1 つ見ていきましょう。
くどいですが、1) から 3) がマッチしていたとしても、4) の look ahead 条件が満たされなければ regular-expression システムはマッチを返さず、4) の look ahead の部分から再度マッチを探し始めます。
look ahead パターンはマッチには含まれませんし、消費もされませんからね。
ここで、look ahead シンタックスを採用したパターンを利用してマッチしたサブ文字列を確認してみましょう。
マッチしたサブ文字列には改行文字 ( \n ) がそのまま含まれている場合もありますから 2 行にわたって出力されているものもありますが、結果は期待通り 3 つのセンテンスが取得できていますね。
改行が気になる方は、サブ文字列に含まれる改行文字をスペースに置き換えるような正規表現を適用するなりしてみてください!
マッチオブジェクトを作成する際に findall() に指定している DOTALL フラグは、'.' 特殊文字において「改行文字 ( \n )」も含めるように指示するためです。
これによってセンテンスが行を跨いでいても対応可能になります。
import re
string = """Here is a single sentence. Here is
another sentence, ending in a period.
And here is yet another. Is there any
questions? Good, go ahead!"""
pat = r'.*?[.?!]'
m_lst = re.findall(pat, string, flags=re.DOTALL)
print(len(m_lst))
# 5
string = """Here is a single sentence. Here is
another sentence, ending in a period.
And here is yet another. Is there any
questions? Good, go ahead!"""
pat = r'.*?[.?!]'
m_lst = re.findall(pat, string, flags=re.DOTALL)
print(len(m_lst))
# 5
これは期待通りに 5 を返してくれますが、もしテキストに「略字」や「小数点を含む数字」が含まれていた場合は期待通りには動いてくれません。
string = """I want to go U.S.A. It has
about 310.5 million people. 1, 2, 3, Go!"""
pat = r'.*?[.?!]'
m_lst = re.findall(pat, string, flags=re.DOTALL)
print(len(m_lst))
# 6 本当は 3 つのセンテンスしか含まれていないのにーー
about 310.5 million people. 1, 2, 3, Go!"""
pat = r'.*?[.?!]'
m_lst = re.findall(pat, string, flags=re.DOTALL)
print(len(m_lst))
# 6 本当は 3 つのセンテンスしか含まれていないのにーー
regular-expression システムは、現在のポジションの直後にある文字集団がパターンとマッチするか、を判断する 先読み ( look-ahead ) 機能 を備えています。
先読み ( look ahead ) 構文の書式:
(?=パターン)
(?=パターン)
この look ahead パターン自体にマッチした文字集団は「消費」されません。
すなわち、「先読み」されたパターン部分は、続くマッチ検索において再び「検索文字列の一部」として検索対象になる、ということです。
この機能を利用することで、上の例でうまくいかなかったセンテンス数の数え上げを期待通りに実行することが可能になります。
1: まず 1 つのセンテンスの先頭として「大文字 or 数字」を探します ( 数字で始まっているかもしれませんね。それにも対応しましょう )。
2: 続いて、non-greedy マッチとして [.!?] のいずれかのリテラル文字で終わるポジションまで読み進めます。
3: このとき、以下のいずれかの条件を満たしていればセンテンスの終端とみなします。
a) センテンス終了文字の直後はスペースであり、その直後に大文字 or 数字が続く
b) センテンス終了文字の直後は行末か文字列の終わりである
4: 3 における a も b も満たしていないセンテンス終端文字 ( 特にピリオド [.]) は、略字の一部、もしくは、小数点とみなします。
2: 続いて、non-greedy マッチとして [.!?] のいずれかのリテラル文字で終わるポジションまで読み進めます。
3: このとき、以下のいずれかの条件を満たしていればセンテンスの終端とみなします。
a) センテンス終了文字の直後はスペースであり、その直後に大文字 or 数字が続く
b) センテンス終了文字の直後は行末か文字列の終わりである
4: 3 における a も b も満たしていないセンテンス終端文字 ( 特にピリオド [.]) は、略字の一部、もしくは、小数点とみなします。
これら 1 から 4 の条件をみたすように、先読み構文を利用してパターンを構築しましょう。
string = """Here is a single sentence. Here is
another sentence, ending in a period.
And here is yet another. Is there any
questions? Good, go ahead! I want to
go U.S.A. It has about 310.5 million people. 1, 2, 3, Go!"""
pat = r'[A-Z0-9].*?[.?!](?=\s[A-Z0-9]|$)'
m_lst = re.findall(pat, string, flags=re.DOTALL | re.MULTILINE)
print(len(m_lst))
# 8
another sentence, ending in a period.
And here is yet another. Is there any
questions? Good, go ahead! I want to
go U.S.A. It has about 310.5 million people. 1, 2, 3, Go!"""
pat = r'[A-Z0-9].*?[.?!](?=\s[A-Z0-9]|$)'
m_lst = re.findall(pat, string, flags=re.DOTALL | re.MULTILINE)
print(len(m_lst))
# 8
この例の string は 1 つ目と 2 つ目の string を合体させたものですからセンテンスは 8 つです。正解です!
また、ここでは findall() に渡すフラグとして re.MULTILINE を追加しています。
これによって、'$' 特殊文字が、「文字列の末尾」だけではなく「行末の改行文字 ( \n )」にもマッチする ようになります。
そのため、センテンスがぴったり行末で終わっていても、この look ahead (先読み) パターンがマッチするんです。
さて、パターン文字列が少し複雑ですね。1 つ 1 つ見ていきましょう。
1) [A-Z0-9]: サブ文字列 (センテンス) の先頭は 'A' から 'Z' までの大文字か '0' から '9' までの数字のどれか 1 文字でなければなりません。
2) .*?: 0 個以上の任意の文字にマッチします。re.DOTALL フラグを指定していますから、この '.' 特殊文字は改行文字を含む全ての文字にマッチします。'?'、'*'、'+' といった繰り返し修飾子 ( quantifier ) 直後に ? 特殊文字を続けることで non-greedy マッチを要求しています。つまり、一番短いマッチで終了する、ということです。
3) [.?!]: サブ文字列 (センテンス) の終端文字を指定しています。上のパターンで non-greedy マッチを選択していますから、regular-expression システムはこれらの文字のいずれかを見つけた段階で停止します。
4) (?=\s[A-Z0-9]|$): これが look ahead のパートです。たとえここまでのパターンがマッチしていたとしても、そのマッチの後にこのパターンが来ていなければダメですよ、ということです。すなわち、1 つのスペースがありその後ろにアルファベットの大文字か数字が続くか、もしくは、行末、または、文字列の最後であること、というのがサブ文字列がセンテンスである条件です。
くどいですが、1) から 3) がマッチしていたとしても、4) の look ahead 条件が満たされなければ regular-expression システムはマッチを返さず、4) の look ahead の部分から再度マッチを探し始めます。
look ahead パターンはマッチには含まれませんし、消費もされませんからね。
ここで、look ahead シンタックスを採用したパターンを利用してマッチしたサブ文字列を確認してみましょう。
string = """I want to go U.S.A. It has
about 310.5 million people. 1, 2, 3, Go!"""
pat = r'[A-Z0-9].*?[.?!](?=\s[A-Z0-9]|$)'
m_lst = re.findall(pat, string, flags=re.DOTALL | re.MULTILINE)
for i in m_lst:
print(f"-> {i}")
# -> I want to go U.S.A.
# -> It has
# about 310.5 million people.
# -> 1, 2, 3, Go!
about 310.5 million people. 1, 2, 3, Go!"""
pat = r'[A-Z0-9].*?[.?!](?=\s[A-Z0-9]|$)'
m_lst = re.findall(pat, string, flags=re.DOTALL | re.MULTILINE)
for i in m_lst:
print(f"-> {i}")
# -> I want to go U.S.A.
# -> It has
# about 310.5 million people.
# -> 1, 2, 3, Go!
マッチしたサブ文字列には改行文字 ( \n ) がそのまま含まれている場合もありますから 2 行にわたって出力されているものもありますが、結果は期待通り 3 つのセンテンスが取得できていますね。
改行が気になる方は、サブ文字列に含まれる改行文字をスペースに置き換えるような正規表現を適用するなりしてみてください!
この記事に興味のある方は次の記事にも関心を持っているようです...
- People who read this article may also be interested in following articles ... -