spaCyでスカイツリーと海、どっちが高いか判定する【自然言語処理, Python】
目次
- spaCyでスカイツリーと海の意味をとらえる
- spaCyとは?
- スカイツリーと海の高さとは?
- 今回の内容は意味解析なのか?
- スクリプトの実行結果
- スクリプトの設計
- スクリプトのソースコード
- ソースコードの解説
- おわりに
- 参考
spaCyでスカイツリーと海の意味をとらえる
私たちは日常的に日本語などの言語を使っていますが、これらの言語は「自然言語」と呼ばれています。
この自然言語を計算機的に解析することを「自然言語処理」と言います。
Pythonには自然言語処理を行えるライブラリとしてspaCyがあります。
今回はこのspaCyを使ってスカイツリーと海はどっちが高いのか判定してみたいと思います。
具体的には↓を見ていきたいと思います。
spaCyとは?
スカイツリーと海の高さとは?
スクリプトの実行結果
スクリプトの設計
スクリプトのソースコード
ソースコードの解説
spaCyとは?
spaCy(スパイシー)はPythonで使える自然言語処理ライブラリで、オープンソース、MITライセンスで開発されています。
さまざまな言語の学習済み統計モデルを簡単に使うことが出来ます。
そしてGiNZA(ギンザ)はリクルートと国立国語研究所が共同開発した日本語に特化した自然言語処理ライブラリです。
こちらもオープンソース、MITライセンスで開発されています。
このGiNZAが提供するモデルをspaCyでロードすることで、spaCyからGiNZAの機能を使うことができます。
spaCyによる日本語の解析では公式サポートのspacy.lang.ja
を使うか、こちらのGiNZAを使うという選択肢があります。
スカイツリーと海の高さとは?
「高いスカイツリーと海が美しい」という文があります。
これの「高い」は「スカイツリー」と「海」のどちらに係(かか)っているのでしょうか?
我々は日本語を意識せずに使うことができるので、「高い」は「スカイツリー」に係っていると瞬間的に理解できます。
しかし自然言語処理では、↑のような人間が行う解析は機械には難しいとされています。
これは「スカイツリーは高いけど、海に高さは無い」という常識が我々に備わっているのに対して、解析する機械にはこのような常識が備わっていないためです。
そのため機械は「高いスカイツリーと海」という文の「高い」は「スカイツリー」と「海」の両方に係っていると判断してしまいます。
このような文の意味を理解して、適切な単語の係り方を選択することを自然言語処理では「意味解析」と言います。
今回の内容は意味解析なのか?
自然言語処理には複数の工程がありますが、意味解析はその工程の後半の解析です。
字句解析
構文解析
意味解析 <- これ
文脈解析
意味解析はまだ世界中で研究中の解析のため、実用性のあるプログラムを作ることが困難とされています。
今回の記事では意味解析「っぽい」ことはやっていますが、間違ってる所があったらツッコミお願いします。
(^ _ ^) | 意味解析のサンプルが少ないんだよね |
(・ v ・) | 我にもっと意味解析を |
スクリプトの実行結果
今回作成するスクリプトは単体テストで動作検証を行っています。
スクリプトのソースコードをsample.py
などに保存し、python -m unittest sample
とコマンドラインから実行すると↓のようにテストが実行されます。
. ---------------------------------------------------------------------- Ran 1 test in 0.848s OK
↑の表示はすべてのテストケースが通過していることを表しています。
実行したテストケースは↓になります。
class Test(unittest.TestCase): def eq(self, a, b): an = Analyzer() c = an.analyze(a) self.assertEqual(c, b) def test_analyze(self): # スカイツリーは高いが海が高いは変 # その辺りの解釈の解決 self.eq('高いスカイツリーと海が美しい', '高い = スカイツリー') self.eq('高い海とスカイツリーが美しい', '高い = スカイツリー') self.eq('高い海と大地が美しい', None) # 該当する単語なし self.eq('見てみなよ、高いスカイツリーと海が美しいだろ?', '高い = スカイツリー') # 海は広いがスカイツリーが広いは変 # その辺りの解釈の解決 self.eq('広いスカイツリーと海が美しい', '広い = 海') self.eq('広い海とスカイツリーが美しい', '広い = 海') self.eq('広いスカイツリーとお猪口が美しい', None) # 該当する単語なし # スカイツリーと海は両方とも美しい self.eq('美しいスカイツリーと海', '美しい = スカイツリー,海') self.eq('美しい海とスカイツリー', '美しい = 海,スカイツリー') # 微妙なケース # 結果は「美しい = スカイツリー,海\n広い = 海」が正しいが、 # 依存構造の走査がうまくいかず2つ目の「スカイツリー」や「海」が1つ目の「美しい」に係ってしまう # これは2つ目の「海」と言う単語から「広い」と「美しい」の両方に辿れるのだが、 # 優先順位を付けていないので↓のような結果になる # 距離が近い単語を優先して検索出来ればこのバグはフィックスできるかもしれない self.eq('美しいスカイツリーと海、そして広いスカイツリーと海', '美しい = スカイツリー,海,スカイツリー,海')
↑のテストケースを見ると、「高いスカイツリーと海が美しい」という文では「高い = スカイツリー」と出力されています。
これは解析器が「高いのはスカイツリーね」と理解している結果と言えそうです。
ただ「高い海とスカイツリーが美しい」では、これは文章的には「高い」は「海」に係っているのですが、今回作成する解析器はこれを「高い = スカイツリー」と出力します。つまり「高い海」というのを無視しているわけです。「高い海」というのは意味的にはおかしい表現ですが、文章の表現的には確定した表現です。つまり発話者は意図的に「高い海」と発話してるわけです。しかし解析器はこれを理解していません。この辺は疑問符が付く結果と言えます。
「広いスカイツリーと海が美しい」では「広い = 海」と出力され、解析器は「広い」のは「海」と理解しています。これも同上の疑問符が付きます。
「美しいスカイツリーと海」では「美しい = スカイツリー,海」と出力され、解析器は「美しい」は「スカイツリー」と「海」の両方に係ると判断しています。
スクリプトの設計
スクリプトの動作検証は単体テストで行います。
単体テストはPythonの標準ライブラリである「unittest
」を使います。
解析のメインの処理はAnalyzer
というクラスに実装します。
Analyzer
にはanalyze()
というメソッドがあり、これが解析のエントリーポイントとなるメソッドです。
analyze()
は内部で引数をspaCyで解析し、トークン列にします。
これを文ごとに取り出して1文ずつanalyze_sent()
で解析します。
analyze_sent()
ではトークン列を走査し、名詞か固有名詞のトークンを見つけたらanalyze_key_token()
でそのトークンを解析します。
analyze_key_token()
内ではクラスの意味データーベースを参照してトークンを解析します。
意味データベースと言うのは、ただの辞書です。
意味と言うのは単語の持つ「高さ」「広さ」「美しさ」です。
これらの意味を考慮して、形容詞と意味がマッチしているか判断します。
たとえば「高いスカイツリー」であれば「高い」は「高さ」になり、「スカイツリー」の「高さ」の意味は「高い」になります。そのため「高いスカイツリー」という組み合わせはマッチします。
いっぽう、「高い海」というのは「高い」は「高さ」で、「海」の「高さ」の意味は定義されていません。そのためこの組み合わせは成立しません。
意味データベースはクラスの属性imi_db
に定義されています。これはただの辞書ですが、単語の意味が保存されています。
# 意味データベース # 単語の意味が保存されている self.imi_db = { 'スカイツリー': { # スカイツリーと言う単語は '高さ': '高い', # 高いし '広さ': None, # 広さは無いけど '美しさ': '美しい', # 美しい }, '海': { # 海と言う単語は '高さ': None, # 高さは無いけど '広さ': '広大', # 広大で '美しさ': '美しい', # 美しい }, }
また形容詞を意味のキーに変換するにはrelated_imi_keys
という辞書を使います。
これは「高い」という形容詞から「高さ」というキーを導くためのマップです。
# 関連する意味キー # 「高い」という単語から「高さ」という意味を連想させる self.related_imi_keys = { '高い': '高さ', '広い': '広さ', '美しい': '美しさ', }
意味データベースを参照して形容詞が名詞または固有名詞に適切な表現だったら、その結果を辞書に保存します。
そして最終的にその辞書を整形して、「高い = スカイツリー」などのような出力にします。
スクリプトのソースコード
今回作成したスクリプトのソースコードは↓になります。
""" スカイツリーと海がどっちが高いか解析するスクリプト License: MIT Created at: 2021/02/06 """ import spacy import unittest # GiNZAのモデルをロード nlp = spacy.load('ja_ginza') class Analyzer: def __init__(self): # 意味データベース # 単語の意味が保存されている self.imi_db = { 'スカイツリー': { # スカイツリーと言う単語は '高さ': '高い', # 高いし '広さ': None, # 広さは無いけど '美しさ': '美しい', # 美しい }, '海': { # 海と言う単語は '高さ': None, # 高さは無いけど '広さ': '広大', # 広大で '美しさ': '美しい', # 美しい }, } # 関連する意味キー # 「高い」という単語から「高さ」という意味を連想させる self.related_imi_keys = { '高い': '高さ', '広い': '広さ', '美しい': '美しさ', } def analyze(self, text): """ 引数textを解析して返答を生成する """ doc = nlp(text) # spaCyで解析する for sent in doc.sents: # 文を取り出す result = self.analyze_sent(sent) # 文ごとに解析する if result: # 解析に成功した return self.format_result(result) # 結果を文字列に整形して返す return None # 解析に失敗した def format_result(self, d): """ 引数dを文字列に整形する dは { 高い: [スカイツリー, エッフェル塔] } のようなフォーマットで保存されている これを文字列「高い = スカイツリー,エッフェル塔」のようにする """ s = '' for key in d.keys(): s += key + ' = ' + ','.join(d[key]) + '\n' s = s.rstrip() return s def analyze_sent(self, sent): """ 引数sentを解析する """ d = {} for tok in sent: # 名詞(NOUN)か固有名詞(PROPN)を見つけたら if tok.pos_ == 'NOUN' or tok.pos_ == 'PROPN': self.analyze_key_token(d, tok) # 解析開始 return d def analyze_key_token(self, d, tok): """ 引数tokを解析して結果を引数dに保存する """ # ADJ(形容詞)を子要素から探す adj = self.find_children(tok, 'ADJ') if not adj: return # ADJが見つからない # 形容詞をキーにしてrelated_imi_keysからキーを探す imi_key = self.find_imi_key(adj.text) if not imi_key: return # 見つからない # 単語をキーにして意味を探す imi = self.find_imi(tok.text) if not imi: return # 見つからない # 意味キーで意味を参照する value = imi[imi_key] if not value: return # 意味が見つからない # 形容詞をキーにして辞書に単語を保存 if adj.text not in d.keys(): d[adj.text] = [tok.text] else: d[adj.text].append(tok.text) def find_children(self, tok, pos_): """ tokの子要素から再帰的にpos_にヒットする単語を探す """ if tok.pos_ == pos_: return tok # 見つかった for child in tok.lefts: # 左の子要素から探す found = self.find_children(child, pos_) # 再帰検索 if found: return found # 見つかった for child in tok.rights: # 右の子要素から探す found = self.find_children(child, pos_) # 再帰検索 if found: return found # 見つかった return None # 見つからなかった def find_imi_key(self, key): """ related_imi_keysを引数keyで参照する """ if key in self.related_imi_keys.keys(): return self.related_imi_keys[key] return None def find_imi(self, key): """ imi_dbを引数keyで参照する """ if key in self.imi_db.keys(): return self.imi_db[key] return None class Test(unittest.TestCase): def eq(self, a, b): an = Analyzer() c = an.analyze(a) self.assertEqual(c, b) def test_analyze(self): # スカイツリーは高いが海が高いは変 # その辺りの解釈の解決 self.eq('高いスカイツリーと海が美しい', '高い = スカイツリー') self.eq('高い海とスカイツリーが美しい', '高い = スカイツリー') self.eq('高い海と大地が美しい', None) # 該当する単語なし self.eq('見てみなよ、高いスカイツリーと海が美しいだろ?', '高い = スカイツリー') # 海は広いがスカイツリーが広いは変 # その辺りの解釈の解決 self.eq('広いスカイツリーと海が美しい', '広い = 海') self.eq('広い海とスカイツリーが美しい', '広い = 海') self.eq('広いスカイツリーとお猪口が美しい', None) # 該当する単語なし # スカイツリーと海は両方とも美しい self.eq('美しいスカイツリーと海', '美しい = スカイツリー,海') self.eq('美しい海とスカイツリー', '美しい = 海,スカイツリー') # 微妙なケース # 結果は「美しい = スカイツリー,海\n広い = 海」が正しいが、 # 依存構造の走査がうまくいかず2つ目の「スカイツリー」や「海」が1つ目の「美しい」に係ってしまう # これは2つ目の「海」と言う単語から「広い」と「美しい」の両方に辿れるのだが、 # 優先順位を付けていないので↓のような結果になる # 距離が近い単語を優先して検索出来ればこのバグはフィックスできるかもしれない self.eq('美しいスカイツリーと海、そして広いスカイツリーと海', '美しい = スカイツリー,海,スカイツリー,海')
ソースコードの解説
ソースコードの解説は↓からになります。
必要モジュールのインポート
スクリプトの冒頭で必要モジュールをインポートしておきます。
今回は自然言語処理でspaCyを使うためspacy
を。
それから単体テスト用にunittest
をインポートします。
import spacy import unittest
GiNZAのモデルをロード
spacy.load()
でja_ginza
を指定するとGiNZAのモデルをロードすることができます。
これの返り値はginza.Japanese
になります。
返り値には慣例的にnlp
と命名します。
このnlp
にテキストを渡すと解析を行うことができます。
# GiNZAのモデルをロード nlp = spacy.load('ja_ginza')
Analyzerの作成
Analyzer
クラスを作成します。
今回のメインの処理はこのクラスのメソッドに実装します。
__init__()
で意味データベースなどを初期化しておきます。
理屈ではこの意味データベースを充実させれば解析器の対応できる単語が増えますが、これを手作業で整えるのはかなり大変かと思います。
class Analyzer: def __init__(self): # 意味データベース # 単語の意味が保存されている self.imi_db = { 'スカイツリー': { # スカイツリーと言う単語は '高さ': '高い', # 高いし '広さ': None, # 広さは無いけど '美しさ': '美しい', # 美しい }, '海': { # 海と言う単語は '高さ': None, # 高さは無いけど '広さ': '広大', # 広大で '美しさ': '美しい', # 美しい }, } # 関連する意味キー # 「高い」という単語から「高さ」という意味を連想させる self.related_imi_keys = { '高い': '高さ', '広い': '広さ', '美しい': '美しさ', }
analyze()でテキストを解析する
analyze()
メソッドは引数text
を解析して、結果の文字列を生成します。
内部ではnlp()
にtext
を渡してdoc
にしています。
doc
はspacy.tokens.doc.Doc
です。これはトークン列を抽象化したオブジェクトで、spaCyの解析結果がこのオブジェクトの中に詰まっています。
doc.sents
を参照すると文を1文ずつ取り出すことができます。これをfor
文で回し1文(sent
)を取り出しています。
このsent
をanalyze_sent()
で解析し、その結果が真であればformat_result()
で結果を整形して返しています。
def analyze(self, text): """ 引数textを解析して返答を生成する """ doc = nlp(text) # spaCyで解析する for sent in doc.sents: # 文を取り出す result = self.analyze_sent(sent) # 文ごとに解析する if result: # 解析に成功した return self.format_result(result) # 結果を文字列に整形して返す return None # 解析に失敗した
analyze_sent()で文を解析する
analyze_sent()
は引数sent
を解析して結果を辞書で返します。
このsent
はspacy.tokens.span.Span
です。
これをfor
文で回すと、トークンを取り出すことができます。これはtok
のことで、spacy.tokens.token.Token
です。
トークンの属性pos_
にはそのトークンの品詞が保存されています。この値が「NOUN」だったら名詞で、「PROPN」だったら「固有名詞」になります。
「スカイツリー」や「海」という単語はこのいずれかにマッチします。つまりそれらの単語が現れたらanalyze_key_token()
が呼ばれます。
def analyze_sent(self, sent): """ 引数sentを解析する """ d = {} for tok in sent: # 名詞(NOUN)か固有名詞(PROPN)を見つけたら if tok.pos_ == 'NOUN' or tok.pos_ == 'PROPN': self.analyze_key_token(d, tok) # 解析開始 return d
format_result()で結果を生成
解析の結果は辞書に保存しますが、format_result()
はその辞書を文字列に整形します。
辞書のキーを左辺にして、値を右辺に並べます。
すると「美しい = スカイツリー,海」のような文字列に整形されます。
def format_result(self, d): """ 引数dを文字列に整形する dは { 高い: [スカイツリー, エッフェル塔] } のようなフォーマットで保存されている これを文字列「高い = スカイツリー,エッフェル塔」のようにする """ s = '' for key in d.keys(): s += key + ' = ' + ','.join(d[key]) + '\n' s = s.rstrip() return s
analyze_key_token()でキートークンを解析する
analyze_key_token()
はコア部分です。
ここでは引数tok
(これは「スカイツリー」や「海」など)から品詞がADJ
のトークンをfind_children()
で探します。
形容詞が見つかったらそのtext
をキーにしてfind_imi_key()
でrelated_imi_keys
を参照します。
意味キーが見つかったらtok
のtext
をもとに意味データベースをfind_imi()
で参照します。つまり単語の意味を参照します。
トークンのtext
属性には元の文章のそのままの表記が保存されています。
これの意味から意味キーを元に「高い」とか「美しい」とかの意味を取得します。
意味が参照出来たら引数d
に形容詞をキーにしてトークンのtext
を保存します。
def analyze_key_token(self, d, tok): """ 引数tokを解析して結果を引数dに保存する """ # ADJ(形容詞)を子要素から探す adj = self.find_children(tok, 'ADJ') if not adj: return # ADJが見つからない # 形容詞をキーにしてrelated_imi_keysからキーを探す imi_key = self.find_imi_key(adj.text) if not imi_key: return # 見つからない # 単語をキーにして意味を探す imi = self.find_imi(tok.text) if not imi: return # 見つからない # 意味キーで意味を参照する value = imi[imi_key] if not value: return # 意味が見つからない # 形容詞をキーにして辞書に単語を保存 if adj.text not in d.keys(): d[adj.text] = [tok.text] else: d[adj.text].append(tok.text)
find_children()でトークンを探す
find_children()
は引数tok
の依存構造を辿り、引数pos_
にマッチする品詞を持つトークンを返します。
見つからなかったらNone
を返します。
トークンのlefts
属性、それからrights
属性にはそれぞれトークンの依存構造上における左の子要素と右の子要素が入っています。
これらの属性を再帰的に検索することで、トークンの子要素から目的のトークンを探すことができます。
依存構造とは構文解析の結果生成される木構造のことです。これは単語間の係り受けの関係の構造で、この木構造を辿ることでどの単語がどの単語に係っているのかを調べることができます。
def find_children(self, tok, pos_): """ tokの子要素から再帰的にpos_にヒットする単語を探す """ if tok.pos_ == pos_: return tok # 見つかった for child in tok.lefts: # 左の子要素から探す found = self.find_children(child, pos_) # 再帰検索 if found: return found # 見つかった for child in tok.rights: # 右の子要素から探す found = self.find_children(child, pos_) # 再帰検索 if found: return found # 見つかった return None # 見つからなかった
find_imi_key()で意味キーを探す
find_imi_key()
は引数key
をキーにしてrelated_imi_keys
を参照します。
値があればその値を返し、値が無ければNone
を返します。
def find_imi_key(self, key): """ related_imi_keysを引数keyで参照する """ if key in self.related_imi_keys.keys(): return self.related_imi_keys[key] return None
find_imi()で意味を探す
find_imit()
は引数key
をキーにしてimi_db
を参照します。
意味があればその意味を返し、意味がなければNone
を返します。
def find_imi(self, key): """ imi_dbを引数keyで参照する """ if key in self.imi_db.keys(): return self.imi_db[key] return None
テストケースを書く
テストケースを書きます。
unittest.TestCase
を継承したクラスを作り、test_
で始まるメソッドを定義します。
このtest_
で始まるメソッドがテスト実行時に実行されます。
eq()
はユーティリティーで、引数a
を解析器で解析し、その結果c
を引数b
と比較します。
比較に失敗したらテストが失敗します。
テストケースの量を見て頂くとわかりますが、あまりねちっこいテストはしていません。
class Test(unittest.TestCase): def eq(self, a, b): an = Analyzer() c = an.analyze(a) self.assertEqual(c, b) def test_analyze(self): # スカイツリーは高いが海が高いは変 # その辺りの解釈の解決 self.eq('高いスカイツリーと海が美しい', '高い = スカイツリー') self.eq('高い海とスカイツリーが美しい', '高い = スカイツリー') self.eq('高い海と大地が美しい', None) # 該当する単語なし self.eq('見てみなよ、高いスカイツリーと海が美しいだろ?', '高い = スカイツリー') # 海は広いがスカイツリーが広いは変 # その辺りの解釈の解決 self.eq('広いスカイツリーと海が美しい', '広い = 海') self.eq('広い海とスカイツリーが美しい', '広い = 海') self.eq('広いスカイツリーとお猪口が美しい', None) # 該当する単語なし # スカイツリーと海は両方とも美しい self.eq('美しいスカイツリーと海', '美しい = スカイツリー,海') self.eq('美しい海とスカイツリー', '美しい = 海,スカイツリー') # 微妙なケース # 結果は「美しい = スカイツリー,海\n広い = 海」が正しいが、 # 依存構造の走査がうまくいかず2つ目の「スカイツリー」や「海」が1つ目の「美しい」に係ってしまう # これは2つ目の「海」と言う単語から「広い」と「美しい」の両方に辿れるのだが、 # 優先順位を付けていないので↓のような結果になる # 距離が近い単語を優先して検索出来ればこのバグはフィックスできるかもしれない self.eq('美しいスカイツリーと海、そして広いスカイツリーと海', '美しい = スカイツリー,海,スカイツリー,海')
おわりに
今回はspaCyを使ってスカイツリーと海はどっちが高いのかを解析してみました。
解析自体は非常に単純な方法ですが、汎用性があるかどうかは不明です。
(^ _ ^) | スカイツリーは広いな大きいな |