2018年07月16日

言語処理100本ノックでPython入門 #59 - S式を解析して名詞句を取り出す

  

今日は、言語処理100本ノック 2015の第6章・問題58を解きます。

いよいよ第6章最後の問題です。
今回は、なかなか手強い問題でした。

■ 問題


59. S式の解析
Stanford Core NLPの句構造解析の結果(S式)を読み込み,文中のすべての名詞句(NP)を表示せよ.入れ子になっている名詞句もすべて表示すること.


■ S式を解析する

S式を簡単に解析できる機能はPythonでは標準で用意されていないっぽいので、仕方がないので自分で解析することにします。

ただし、名詞句を取り出すのに特化したものとしています。解析しながら名詞句を組み立てていくという感じ。

今回は、pyrthonのソースファイルを2つに分割します。 まずは、S式を解析し名詞句を取り出す部分である、NPExtractor.pyファイル。 ソースコードを示します。

import copy

# 文字列をTokenに分解し、列挙する
class Tokenizer:
    def __init__(self, exp):
        self.exp = exp.replace('\n', '')
        self.curix = 0
        self.curr = ''
        self.prev = None
        self.gen = self.getTokens()

    def nextChar(self):
        if self.curix < len(self.exp):
            c = self.exp[self.curix]
            self.curix += 1
            return c
        return 0

    def getTokens(self):
        c = self.nextChar()
        token = ''
        while c != 0:
            if c == '(':
                yield c
            elif c == ')':
                if token != '':
                    yield token
                    token = ''
                yield c
            elif c == ' ':
                if token != '':
                    yield token
                    token = ''
            else:
                token += c
            c = self.nextChar()
        if token != '':
            yield token
        yield None

    def moveNext(self):
        if self.prev != None:
            r = copy.copy(self.prev)
            self.prev = None
            return r
        if self.curr != None:
            self.curr = next(self.gen)
        return self.curr

    # 一つ前に戻す (ただし連続しては呼び出せない)
    def movePrev(self):
        self.prev = self.curr

# Node.parseで利用するコンテキスストクラス
class Context:
    def __init__(self, exp):
        self.tokenizer = Tokenizer(exp)
        self.nplist = []

#<SExpression> :: ( <part>T <sentence> )
#<sentence> :: <word> | { ( <part> <sentence> ) }
#<part> :: ROOT | S | NP | VP | PP | ....

# <SExpression>を表すクラス
class NPExtractor:
    def parse(self, context):
        curr = context.tokenizer.moveNext()
        if curr == '(':
            # <part>を取り出す 取り出したpartは使わない
            context.tokenizer.moveNext()
            # <sentense>のパース
            node = Sentence()
            node.parse(context, False)
            # ) を取り出す
            curr = context.tokenizer.moveNext()
            if curr != ')':
                raise Exception
        else:
            raise Exception
        return ''

# <sentence>を表すクラス
class Sentence:
    def parse(self, context, isNp):
        phrase = []
        # 先読みする
        curr = context.tokenizer.moveNext()
        if curr != '(':
            # <word>の処理 読み取った単語を返す
            return curr
        # { ( <part> <sentence> )  の処理
        while curr == '(':
            # <part>を取り出す
            part = context.tokenizer.moveNext()
            # <sentense>のパース
            node = Sentence()
            w = node.parse(context, part == 'NP')
            # 現在の () の中の句はphraseに追加
            # ∵ (NP (JJ Many) (NNS challenges)) の Many challenges を記録する必要があるから
            phrase.append(w)
            if part == 'NP' and w != '':
                # 名詞句ならば、nplistにも記憶する
                # このpart が  (NP (JJ Many) (NNS challenges)) の NPならば、
                # w には、'Many challenges' が入っている
                context.nplist.append(w)
            # ) の処理
            curr = context.tokenizer.moveNext()
            if curr != ')':
                raise Exception
            # 次を取り出す
            curr = context.tokenizer.moveNext()
        # 先読みした分を戻す
        context.tokenizer.movePrev()
        if isNp:
            # parseが呼び出された時点で処理しているものがNPならば、phraseにある単語を連結し文字列化する
            # 先頭と最後の不要なものを取り除く かなり使わ伎だが...
            while phrase and (phrase[-1] == ',' or phrase[-1] == '' or phrase[-1] == '.'):
                phrase.pop()
            while phrase and (phrase[0] == ',' or phrase[0] == '' or phrase[0] == '.'):
                phrase.pop(0)
            return ' '.join(phrase)
        return ''

このソースファイルには、4つのクラス(Tokenizer、Context、NPExtractor、Sentence)が定義されています。

はじめは、NPExtractor、Sentenceの親クラスであるNode抽象クラスを定義したのですが、よくよく考えたら不要なので削除しました。

何をやっているクラスなのかはコメントを読んでください。 Tokenizer、Sentence の2つのクラスは、NPExtractorの下請けクラスと思ってもらって構いません。

NPExtractorクラスのparseメソッドを呼び出すと、contextで示した一つのS式を解析し、contextオブジェクトのnplistに名詞句のリストを設定していきます。


■ 取り出した名詞句をファイルに出力する

このNPExtractorクラスを呼び出すメインのソースが以下のコードです。
import re
from xml.etree import ElementTree
from NPExtractor import NPExtractor, Context

class NounPhrases:
    def __init__(self, filepath):
        xdoc = ElementTree.parse(filepath)
        root = xdoc.getroot()
        self.parses = root.findall('document/sentences/sentence/parse')

    def extract(self):
        with open('result59.txt', 'w', encoding='utf8') as w:
            for parse in self.parses:
                ctx = Context(parse.text)
                exp = NPExtractor()
                exp.parse(ctx)
                for p in ctx.nplist:
                    s = re.sub('-LRB-', '(', p)
                    s = re.sub('-RRB-',')', s)
                    w.write(s + '\n')

def main():
    nps = NounPhrases('nlp.txt.xml')
    nps.extract()

if __name__ == '__main__':
    main()
こちらでは、XMLファイルからS式(複数)を抜き出し、それをひとつづつNPExtractor.parseを利用して名詞句を取り出しています。 取り出した結果はファイルに出力しています。

今回初めてソースファイルを分割したのですが、
from NPExtractor import NPExtractor, Context
で、同一フォルダのNPExtractor.pyからNPExtractor, Contextをimportして利用できるようにしています。


■結果


結果の一部を掲載します。
Natural language
processing
Natural language processing
Wikipedia
the free encyclopedia
Natural language processing
NLP
Natural language processing
a field
computer science
a field
artificial intelligence
linguistics
the interactions
computers
human ( natural ) languages
computers and human ( natural ) languages
the interactions
linguistics
a field , artificial intelligence , and linguistics
such
NLP
the area

S式解析して名詞句を組み立てる部分ですが、一部、以下のような表示になってしまうので、もうすこし工夫が必要かもしれません。
Moore 's Law

the `` patient ''

general learning algorithms -


余談ですが、2つ目の、``って、大元の英文のテキストファイル「nlp.txt」には無い文字です。
それが、Stanford Core NLPで、nlp.txt.xmlを作成すると、なぜか、ダブルクォーテーションの「”」が「``」に置き換わってしまうんですよね。

たぶん、開始と終了のクォーテーションを明確に分けるためだとは思うんですが...  元のテキストに戻すような処理を書かないといけない時はちょっと面倒です。