morikomorou’s blog

自分が学んだことなどの備忘録的なやつ

【python】言語処理100本ノック2020を解く(第5章前半)


はじめに

前回の続きで言語処理100本ノック解いていきたいと思います。

今回は第5章です。係り受け解析の章やっていきます。
今回も長くなりそうなので前半後半に分けます




第5章: 係り受け解析

日本語Wikipediaの「人工知能」に関する記事からテキスト部分を抜き出したファイルがai.ja.zipに収録されている. この文章をCaboChaやKNP等のツールを利用して係り受け解析を行い,その結果をai.ja.txt.parsedというファイルに保存せよ.このファイルを読み込み,以下の問に対応するプログラムを実装せよ.

事前準備

まずはCaboChaというソフトを使って係り受け解析を行い、その結果をファイルに出力します。
前回の章でMecabをpythonのバインダを使って使用しましたが、あいにく自分のpythonのバージョンが64bitなので、32bitバージョンしかないCaboChaが使えません。
CaboChaは内部でMecabを使うため、Mecabも32bitバージョンで統一する必要があります。
仕方がないので、前章で使用したMecabはアンインストールして、どちらも32bitバージョンでインストールしなおしました。

インストールしたら、それぞれパスを通して、コマンドラインで以下打ち込めば解析&出力完了です

cabocha -f1 -o ai.ja.txt.parsed ai.ja.txt 

最初の数行を見てみましょう

* 0 -1D 1/1 0.000000
人工	名詞,一般,*,*,*,*,人工,ジンコウ,ジンコー
知能	名詞,一般,*,*,*,*,知能,チノウ,チノー
EOS
EOS
* 0 17D 1/1 0.388993
人工	名詞,一般,*,*,*,*,人工,ジンコウ,ジンコー
知能	名詞,一般,*,*,*,*,知能,チノウ,チノー
* 1 17D 2/3 0.613549
(	記号,括弧開,*,*,*,*,(,(,(
じん	名詞,一般,*,*,*,*,じん,ジン,ジン
こうち	名詞,一般,*,*,*,*,こうち,コウチ,コーチ
のう	助詞,終助詞,*,*,*,*,のう,ノウ,ノー
、	記号,読点,*,*,*,*,、,、,、
、	記号,読点,*,*,*,*,、,、,、
* 2 3D 0/0 0.758984
AI	名詞,一般,*,*,*,*,*
* 3 17D 1/5 0.517898
〈	記号,括弧開,*,*,*,*,〈,〈,〈
エーアイ	名詞,固有名詞,一般,*,*,*,*
〉	記号,括弧閉,*,*,*,*,〉,〉,〉
)	記号,括弧閉,*,*,*,*,),),)
と	助詞,格助詞,引用,*,*,*,と,ト,ト
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
、	記号,読点,*,*,*,*,、,、,、

*から始まる行がそれ以降の形態素の係り受け解析結果となっています。
EOSは文章の終わりを示してます。

40. 係り受け解析結果の読み込み(形態素)

形態素を表すクラスMorphを実装せよ.このクラスは表層形(surface),基本形(base),品詞(pos),品詞細分類1(pos1)をメンバ変数に持つこととする.さらに,係り受け解析の結果(ai.ja.txt.parsed)を読み込み,各文をMorphオブジェクトのリストとして表現し,冒頭の説明文の形態素列を表示せよ.

ほぼ同じことを前章でやってるので、クラスにまとめるだけですね。
*がついていない部分のみ読み込んで形態素をリストに入れていくだけです。
1文の終わりはEOS\nなので、それをトリガーに文の切り替えをします。

class Morph:
    def __init__(self, txt):
        col1 = txt.split('\t')
        col2 = col1[1].rstrip().split(',')
        self.surface = col1[0]
        self.base = col2[6]
        self.pos = col2[0]
        self.pos1 = col2[1]
    
    def __str__(self):
        return 'surface: {}, base: {}, pos: {}, pos1: {}'.format(self.surface, self.base, self.pos, self.pos1)
    
    def __repr__(self):
        return self.surface


file_path = './input/section5/ai.ja.txt.parsed'
morphs = []
morph = []
with open(file_path, 'r', encoding='utf-8') as f:
    for row in f:
        if row.startswith('*'):
            continue
        elif row == 'EOS\n':
            if len(morph):
                morphs.append(morph)
                morph = []
        else:
            morph.append(Morph(row))
if len(morph):
    morphs.append(morph)

for morph in morphs[1]:
    print(morph)

以下出力です。(長いので数行だけピックアップ)

{'surface': '人工', 'base': '人工', 'pos': '名詞', 'pos1': '一般'}
{'surface': '知能', 'base': '知能', 'pos': '名詞', 'pos1': '一般'}
{'surface': '(', 'base': '(', 'pos': '記号', 'pos1': '括弧開'}
{'surface': 'じん', 'base': 'じん', 'pos': '名詞', 'pos1': '一般'}
{'surface': 'こうち', 'base': 'こうち', 'pos': '名詞', 'pos1': '一般'}
{'surface': 'のう', 'base': 'のう', 'pos': '助詞', 'pos1': '終助詞'}
{'surface': '、', 'base': '、', 'pos': '記号', 'pos1': '読点'}
{'surface': '、', 'base': '、', 'pos': '記号', 'pos1': '読点'}
{'surface': 'AI', 'base': '*\n', 'pos': '名詞', 'pos1': '一般'}
{'surface': '〈', 'base': '〈', 'pos': '記号', 'pos1': '括弧開'}
{'surface': 'エーアイ', 'base': '*\n', 'pos': '名詞', 'pos1': '固有名詞'}
{'surface': '〉', 'base': '〉', 'pos': '記号', 'pos1': '括弧閉'}
{'surface': ')', 'base': ')', 'pos': '記号', 'pos1': '括弧閉'}
{'surface': 'と', 'base': 'と', 'pos': '助詞', 'pos1': '格助詞'}
{'surface': 'は', 'base': 'は', 'pos': '助詞', 'pos1': '係助詞'}
{'surface': '、', 'base': '、', 'pos': '記号', 'pos1': '読点'}

41. 係り受け解析結果の読み込み(文節・係り受け)

40に加えて,文節を表すクラスChunkを実装せよ.このクラスは形態素(Morphオブジェクト)のリスト(morphs),係り先文節インデックス番号(dst),係り元文節インデックス番号のリスト(srcs)をメンバ変数に持つこととする.さらに,入力テキストの係り受け解析結果を読み込み,1文をChunkオブジェクトのリストとして表現し,冒頭の説明文の文節の文字列と係り先を表示せよ.本章の残りの問題では,ここで作ったプログラムを活用せよ.

先ほど無視していた*から始まる行から情報を取得していきます。
*の行は、スペース区切りで以下の情報が入っています

  • 文節番号
  • 係り先の文節番号(係り先なし:-1)(語尾にD)
  • 主辞の形態素番号/機能語の形態素番号
  • 係り関係のスコア(大きい方が係りやすい)

なので、*の行を読み込む際にChunkクラスを生成し、dstに係り先の文節番号を入れます。
srcsは係り元なので、一文すべてのChunkを取得し終えてからでないとわからないので空リストで初期化しておきます。
一文すべてのChunkクラスのリストが完成した段階で、ひとつづつ探索し、係り先のChunkクラスのsrcsに係り元の文節番号をappendしていく流れです。

class Chunk:
    def __init__(self, morphs, dst):
        self.morphs = morphs
        self.dst = dst
        self.srcs = []


sentences = []
chunks = []
morphs = []
with open(file_path, 'r', encoding='utf-8') as f:
    for row in f:
        if row.startswith('*'):
            if len(morphs):
                chunks.append(Chunk(morphs, dst))
            morphs = []
            cols = row.split()
            dst = int(cols[2].rstrip('D'))
        elif row != 'EOS\n':
            morphs.append(Morph(row))
        else:
            if len(morphs):
                chunks.append(Chunk(morphs, dst))
            morphs = []
            if len(chunks):
                for i, chunk in enumerate(chunks):
                    dst = chunk.dst
                    if dst != -1:
                        chunks[dst].srcs.append(i)
                sentences.append(chunks)
            chunks = []

for chunk in sentences[1]:
    print(vars(chunk))

出力は以下。

{'morphs': [人工, 知能], 'dst': 17, 'srcs': []}
{'morphs': [(, じん, こうち, のう, 、, 、], 'dst': 17, 'srcs': []}
{'morphs': [AI], 'dst': 3, 'srcs': []}
{'morphs': [〈, エーアイ, 〉, ), と, は, 、], 'dst': 17, 'srcs': [2]}
{'morphs': [「, 『, 計算], 'dst': 5, 'srcs': []}
{'morphs': [(, ), 』, という], 'dst': 9, 'srcs': [4]}
{'morphs': [概念, と], 'dst': 9, 'srcs': []}
{'morphs': [『, コンピュータ], 'dst': 8, 'srcs': []}
{'morphs': [(, ), 』, という], 'dst': 9, 'srcs': [7]}
{'morphs': [道具, を], 'dst': 10, 'srcs': [5, 6, 8]}
{'morphs': [用い, て], 'dst': 12, 'srcs': [9]}
{'morphs': [『, 知能, 』, を], 'dst': 12, 'srcs': []}
{'morphs': [研究, する], 'dst': 13, 'srcs': [10, 11]}
{'morphs': [計算, 機, 科学], 'dst': 14, 'srcs': [12]}
{'morphs': [(, ), の], 'dst': 15, 'srcs': [13]}
{'morphs': [一, 分野, 」, を], 'dst': 16, 'srcs': [14]}
{'morphs': [指す], 'dst': 17, 'srcs': [15]}
{'morphs': [語, 。], 'dst': 34, 'srcs': [0, 1, 3, 16]}
{'morphs': [「, 言語, の], 'dst': 20, 'srcs': []}
{'morphs': [理解, や], 'dst': 20, 'srcs': []}
{'morphs': [推論, 、], 'dst': 21, 'srcs': [18, 19]}
{'morphs': [問題, 解決, など, の], 'dst': 22, 'srcs': [20]}
{'morphs': [知的, 行動, を], 'dst': 24, 'srcs': [21]}
{'morphs': [人間, に], 'dst': 24, 'srcs': []}
{'morphs': [代わっ, て], 'dst': 26, 'srcs': [22, 23]}
{'morphs': [コンピューター, に], 'dst': 26, 'srcs': []}
{'morphs': [行わ, せる], 'dst': 27, 'srcs': [24, 25]}
{'morphs': [技術, 」, 、, または, 、], 'dst': 34, 'srcs': [26]}
{'morphs': [「, 計算, 機], 'dst': 29, 'srcs': []}
{'morphs': [(, コンピュータ, ), による], 'dst': 31, 'srcs': [28]}
{'morphs': [知的, な], 'dst': 31, 'srcs': []}
{'morphs': [情報処理, システム, の], 'dst': 33, 'srcs': [29, 30]}
{'morphs': [設計, や], 'dst': 33, 'srcs': []}
{'morphs': [実現, に関する], 'dst': 34, 'srcs': [31, 32]}
{'morphs': [研究, 分野, 」, と, も], 'dst': 35, 'srcs': [17, 27, 33]}
{'morphs': [さ, れる, 。], 'dst': -1, 'srcs': [34]}

42. 係り元と係り先の文節の表示

係り元の文節と係り先の文節のテキストをタブ区切り形式ですべて抽出せよ.ただし,句読点などの記号は出力しないようにせよ.

先ほど作成したsentencesというリストには1文ずつChunkクラスをまとめたリストが入っています。
1文分のリストを取り出し、各Chunkクラスにおいて自身の形態素をすべてつなげたもの(modifier)と、係り先の形態素をすべてつなげたもの(modifiee)を作成し、タブ区切りで出力するだけです。

def print_mods(sentence):
    for chunk in sentence:
        if chunk.dst != -1:
            modifier = ''.join([x.surface if x.pos != '記号' else '' for x in chunk.morphs])
            modifiee = ''.join([x.surface if x.pos != '記号' else '' for x in sentence[chunk.dst].morphs])
            print(modifier, modifiee, sep='\t')

print_mods(sentences[1])

出力は以下。

人工知能	語
じんこうちのう	語
AI	エーアイとは
エーアイとは	語
計算	という
という	道具を
概念と	道具を
コンピュータ	という
という	道具を
道具を	用いて
用いて	研究する
知能を	研究する
研究する	計算機科学
計算機科学	の
の	一分野を
一分野を	指す
指す	語
語	研究分野とも
言語の	推論
理解や	推論
推論	問題解決などの
問題解決などの	知的行動を
知的行動を	代わって
人間に	代わって
代わって	行わせる
コンピューターに	行わせる
行わせる	技術または
技術または	研究分野とも
計算機	コンピュータによる
コンピュータによる	情報処理システムの
知的な	情報処理システムの
情報処理システムの	実現に関する
設計や	実現に関する
実現に関する	研究分野とも
研究分野とも	される




43. 名詞を含む文節が動詞を含む文節に係るものを抽出

名詞を含む文節が,動詞を含む文節に係るとき,これらをタブ区切り形式で抽出せよ.ただし,句読点などの記号は出力しないようにせよ.

先ほどとほぼ同じで、名詞に動詞がかかる場合のみ抽出します。

def print_mods_n2v(sentence):
    for chunk in sentence:
        if chunk.dst != -1:
            modifier_pos = [x.pos for x in chunk.morphs]
            modifiee_pos = [x.pos for x in sentence[chunk.dst].morphs]
            if '名詞' in modifier_pos and '動詞' in modifiee_pos:
                modifier = ''.join([x.surface if x.pos != '記号' else '' for x in chunk.morphs])
                modifiee = ''.join([x.surface if x.pos != '記号' else '' for x in sentence[chunk.dst].morphs])
                print(modifier, modifiee, sep='\t')

print_mods_n2v(sentences[1])

出力は以下です。

道具を	用いて
知能を	研究する
一分野を	指す
知的行動を	代わって
人間に	代わって
コンピューターに	行わせる
研究分野とも	される

44. 係り受け木の可視化

与えられた文の係り受け木を有向グラフとして可視化せよ.可視化には,Graphviz等を用いるとよい.

Graphviz初めて使いましたが、簡単にこういう有向グラフが書けるのいいですね。
文節番号は異なるのに、形態素が全く一緒のものがあったりしたので、文節番号も最後に着けておくことにします。

from graphviz import Digraph

def plot_tree(sentence):
    g = Digraph(format='png', filename='./input/section5/ans44')
    g.attr('node', fontname="Meiryo") # 日本語対応
    for i, chunk in enumerate(sentence):
        if chunk.dst != -1:
            modifier = ''.join([x.surface if x.pos != '記号' else '' for x in chunk.morphs]) + '({})'.format(i)
            modifiee = ''.join([x.surface if x.pos != '記号' else '' for x in sentence[chunk.dst].morphs]) + '({})'.format(chunk.dst)
            g.edge(modifier, modifiee)
    g.view()

plot_tree(sentences[2])

出力は以下です。


45. 動詞の格パターンの抽出

今回用いている文章をコーパスと見なし,日本語の述語が取りうる格を調査したい. 動詞を述語,動詞に係っている文節の助詞を格と考え,述語と格をタブ区切り形式で出力せよ. ただし,出力は以下の仕様を満たすようにせよ.

  • 動詞を含む文節において,最左の動詞の基本形を述語とする
  • 述語に係る助詞を格とする
  • 述語に係る助詞(文節)が複数あるときは,すべての助詞をスペース区切りで辞書順に並べる

「ジョン・マッカーシーはAIに関する最初の会議で人工知能という用語を作り出した。」という例文を考える. この文は「作り出す」という1つの動詞を含み,「作り出す」に係る文節は「ジョン・マッカーシーは」,「会議で」,「用語を」であると解析された場合は,次のような出力になるはずである.

作り出す で は を

このプログラムの出力をファイルに保存し,以下の事項をUNIXコマンドを用いて確認せよ.

  • コーパス中で頻出する述語と格パターンの組み合わせ
  • 「行う」「なる」「与える」という動詞の格パターン(コーパス中で出現頻度の高い順に並べよ)

やや煩雑ですが、1文の中のChunkクラスを探索し、その形態素の中に動詞が含まれていれば、その係り元の助詞をすべて抜き出してくるような操作です。
出現頻度の部分は前章でやったので割愛です。

particles = []
for sentence in sentences:
    for chunk in sentence:
        for morph in chunk.morphs:
            if morph.pos == '動詞':
                if len(chunk.srcs):
                    modifier = []
                    for src in chunk.srcs:
                        modifier += [x.base for x in sentence[src].morphs if x.pos == '助詞']
                    if len(modifier):
                        modifier = sorted(list(set(modifier))) # 辞書順で並べ替え&重複削除
                        particles.append(morph.base + '\t' + ' '.join(modifier))
                break

with open('./input/section5/ans45.txt', 'w', encoding='utf-8') as f:
    f.write('\n'.join(particles))

# コーパス中で頻出する述語と格パターンの組み合わせ
from collections import Counter
c = Counter(particles)
for k, v in c.most_common(20):
    print(k, v)

# 「行う」「なる」「与える」という動詞の格パターン
print('------------行う----------------')
for k, v in c.most_common():
    if k.startswith('行う'):
        print(k, v)
print('------------なる----------------')
for k, v in c.most_common():
    if k.startswith('なる'):
        print(k, v)
print('------------与える----------------')
for k, v in c.most_common():
    if k.startswith('与える'):
        print(k, v)

出力は下記のとおりです。

する	を 49
する	が 19
する	と 15
する	に 15
する	は を 12
する	に を 10
する	で を 9
よる	に 9
行う	を 8
する	が に 8
呼ぶ	と 6
基づく	に 6
する	と は 6
する	と を 5
ある	が 5
する	に は を 5
する	が を 5
用いる	を 4
する	て を 4
する	て と 4
------------行う----------------
行う	を 8
行う	て に 1
行う	に を 1
行う	が て で に は 1
行う	は を 1
行う	に 1
行う	に により を 1
行う	て に を 1
行う	て に は 1
行う	から 1
行う	で に を 1
行う	で を 1
行う	て を 1
行う	に まで を 1
行う	が で は 1
行う	が で に は 1
行う	は を をめぐって 1
行う	まで を 1
------------なる----------------
なる	が と 3
なる	に は 3
なる	に 2
なる	と 2
なる	で と など は 1
なる	が て と 1
なる	から で と 1
なる	が に 1
なる	は 1
なる	が に は 1
なる	で に は 1
なる	から が て で と は 1
なる	に は も 1
なる	て として に は 1
なる	が で と に は 1
なる	が と にとって は 1
なる	て に は 1
なる	も 1
なる	で は 1
------------与える----------------
与える	が に 1
与える	が など に 1
与える	に は を 1

46. 動詞の格フレーム情報の抽出

45のプログラムを改変し,述語と格パターンに続けて項(述語に係っている文節そのもの)をタブ区切り形式で出力せよ.45の仕様に加えて,以下の仕様を満たすようにせよ.

  • 項は述語に係っている文節の単語列とする(末尾の助詞を取り除く必要はない)
  • 述語に係る文節が複数あるときは,助詞と同一の基準・順序でスペース区切りで並べる

「ジョン・マッカーシーはAIに関する最初の会議で人工知能という用語を作り出した。」という例文を考える. この文は「作り出す」という1つの動詞を含み,「作り出す」に係る文節は「ジョン・マッカーシーは」,「会議で」,「用語を」であると解析された場合は,次のような出力になるはずである.

作り出す で は を 会議で ジョンマッカーシーは 用語を

45のプログラムをベースに、助詞をキー、助詞を含む文節を値として作成しておき、それを使って出力の末尾に係り元の文節を並べていく感じです。

def get_info(sentence):
    particles = []
    for chunk in sentence:
        for morph in chunk.morphs:
            if morph.pos == '動詞':
                if len(chunk.srcs):
                    modifier = []
                    mod_dic = {}
                    particle = ''
                    for src in chunk.srcs:
                        for x in sentence[src].morphs:
                            if x.pos != '記号':
                                particle += x.surface
                            if x.pos == '助詞':
                                modifier.append(x.surface)
                                mod_dic[x.surface] = particle
                                particle = ''
                        particle = ''
                    if len(modifier):
                        modifier = sorted(list(set(modifier)))
                        particles.append(morph.base + '\t' + ' '.join(modifier) + '\t' + ' '.join([mod_dic[x] for x in modifier]))
                break
    return particles

particles = get_info(sentences[1])
for particle in particles:
    print(particle)

出力は下記の通り

用いる	を	道具を
する	て を	用いて 知能を
指す	を	一分野を
代わる	に を	人間に 知的行動を
行う	て に	代わって コンピューターに
する	と も	研究分野と も




おわりに

長いので5章前半はこれで終わりです。
修飾、被修飾関係が一発で解析できるのは感動です。