FORCIA CUBEフォルシアの情報を多面的に発信するブログ

spaCyのDependencyMatcherでレビュー文から情報を抽出してみる

2021.12.20

アドベントカレンダー2021 エンジニア テクノロジー

これは、自然言語処理 Advent Calendar 2021の20日目の記事です。

新卒2年目のエンジニア、吉成です。 普段はフォルシアのDXプラットフォーム部・技術研究所という2つの部署に所属し、web開発と自然言語処理の二足の草鞋を履いています。二兎を追う者は一兎をも得ずという言葉もありますが、今はひーひー言いながらも二兎を追えるエンジニアを目指しています。

ところで皆さん、依存構造解析してますか?

依存構造解析は自然言語処理の実応用において重要な基礎解析の1つです。文中のどの単語(あるいは句)がどの単語(句)に依存しているか、またそれらの単語(句)間はどんな関係を持っているのか(依存構造)を解析します。一般的に依存構造解析は、文を単語や形態素に分割したり、単語や形態素に品詞のラベルを付与したりする形態素解析と呼ばれる処理の後に行われます。

yoshinari_dependency_parsing.png

(画像:「部屋から見える夜景が美しかった。」の依存構造解析の結果)

上の図は、「部屋から見える夜景が美しかった。」という、ホテルのレビューを想定した自然言語文に対して依存構造を解析した結果です。単語の下に書いてあるNOUN,ADPなどの文字列はそれぞれ名詞、助詞といった品詞を表しています。また、依存関係を持つ単語と単語が矢印で繋がれており、それぞれの矢印には関係の種類を表すラベルが付与されています。ここではすべてのラベルを説明することはしませんが、例えば以下のような意味があります。

  • 夜景→見える acl:形容詞的修飾節
  • 美しかっ→夜景 nsubj:名詞句主語

依存構造解析は様々な場面で役立てられます。例えば、「料理が高級ホテルのように豪華で美味しかった。」という修飾的な語句のある文を「料理が美味しかった。」などのように要約することで、人間が大量の文章を理解するのを補助する文書要約システムや、「〇〇ホテルはいつ開業しますか?」という質問に対して「〇〇ホテルは12月20日に開業予定です。」というような文を元に「12月20日」と回答するような質問応答システムなどへの応用が考えられます。今回は簡単な例文を使って、依存構造解析を使ったレビュー文からの情報抽出にチャレンジしてみましょう!

準備

今回は依存構造解析を含む各種解析にGiNZAというPythonライブラリを使用します。GiNZAはMegagon Labsが開発したオープンソースの日本語NLPライブラリで、最先端の機械学習技術を組み込んだ自然言語処理のためのフレームワークであるspaCyと、ワークスアプリケーションズ徳島人工知能NLP研究所で開発されたオープンソース形態素解析器のPython実装SudachiPyを基盤技術としています。

今回の主役であり記事タイトルにもなっているDependencyMatcherはspaCyのAPIにあるものなので、本記事でDependencyMatcherの使い方を理解すればGiNZAによる日本語の自然言語処理だけでなく、spaCyで対応している他の言語の処理にも適用できます。

では早速、GiNZAをインストールしましょう。

pip install -U ginza ja-ginza-electra

ja-ginza-electraというのは、GiNZA用の最新の言語モデルで、従来の言語モデルよりも高い解析精度を持ちます。 ただしメモリ容量16GB以上推奨とのことなので、メモリの小さいマシンで処理を行う場合や解析精度よりも実行速度を重視したい場合には、ja-ginza-electraの代わりにja-ginzaをインストールしてください。

インストールが済んだら、ライブラリをimportして解析の準備を整えましょう。

import ginza
import spacy
nlp = spacy.load('ja_ginza_electra') # 初回はモデルをダウンロードするために時間がかかります
# nlp = spacy.load('ja_ginza')  # ja-ginzaをインストールした場合のみ

続いて使用するテキストデータを準備します。 お手元にデータがあればそれを使用しても構いませんが、今回はサンプルとしてあるホテルのレビューを想定した3文を用意しました。 レビューデータは実務における自然言語処理でよく扱われるデータの1つです。

txts = [
	"部屋から見える夜景が美しかった。", 
	"立地は悪いが食事が美味しい。", 
	"客室露天風呂は大人でも足がのばせてとても広かった。"
]

Matcherによるマッチング

まずは依存構造解析をせず、トークン(単語, 形態素)単位でのマッチングで情報を抽出してみましょう。 spaCyにはMatcherという、トークン単位でのマッチングに適したAPIがあります。 最初に、Matcherを使ってこのホテルのレビューにどのような形容詞が使われているのか見てみましょう。

from spacy.matcher import Matcher

matcher = Matcher(nlp.vocab)	# 日本語の語彙の集合を渡してMatcherオブジェクトを作る
patterns = [
    [{"TAG": {"REGEX": "^形容詞"}}]	# ルールの定義。品詞タグが「形容詞」で始まるもの
]

matcher.add("adj", patterns)	# Matcherオブジェクトにルール名adjとしてルールを登録

s = "".join(txts)	# 3つの文をつなげて1つの文字列にする
doc = nlp(s) 		# 引数に与えられた文字列を文章として解析する
matches = matcher(doc)	# matchesの中にマッチング結果が入る

ルールはマッチすべきトークンの属性を集めたdictのlistです。 例えば次のルールは、「品詞タグの名前が形容詞で始まるもの」を表すルールです。

[{"TAG": {"REGEX": "^形容詞"}}]

TAGは品詞タグを指定することを意味します。TAGの代わりにLEMMAを使うことで見出し語に対する指定、TEXTを使うことで文書中単語の文字列そのものに対する指定も可能です。

{"REGEX": "^形容詞"}は品詞タグが^形容詞という正規表現に当てはまるもの(=すべての形容詞)という意味です。正規表現を使わない場合には文字列で"形容詞-一般"などと指定できます。 GiNZA内部で使われている形態素解析器SudachiPyの品詞タグの表現方法では、ハイフンつなぎでより詳細な品詞を表現しています。そのため「名詞」「形容詞」「動詞」といった大きな分類のみ使いたい場合は、正規表現で品詞タグを指定すると良いです。解析結果のdocをforループで回すとどの単語にどの品詞タグがついているか見ることができます。

ルールの書き方についてもっと詳しく学びたい方は公式ドキュメントや公式のUSAGEをご参照ください。

doc = nlp(txts[0])
for token in doc:
    print(token.text, token.tag_)
# 部屋 名詞-普通名詞-一般
# から 助詞-格助詞
# 見える 動詞-一般
# 夜景 名詞-普通名詞-一般
# が 助詞-格助詞
# 美しかっ 形容詞-一般
# た 助動詞
# 。 補助記号-句点

ルールを定義できたら、次にそれをMatcherオブジェクトに登録します。 matcher.add()の第1引数にルールの名前、第2引数にルールのlistを指定することで登録できます。 ルールのlistなので、複数の条件を与えることが可能です。 例えば、形容詞に加えて形容動詞(「親切だ」「不愉快だ」など)にもマッチングさせたい場合には、次のように書くことができます。

patterns = [
    [
        {"TAG": {"REGEX": "^形容詞"}}
    ],
    [   # 今回使用したツールの品詞体系における形容動詞相当の表現
         # 参考:https://ccd.ninjal.ac.jp/unidic/glossary
        {"TAG": "名詞-普通名詞-形状詞可能"},
        {"LEMMA": "だ"}
    ]
]
matcher.add("multiple patterns", patterns)

マッチング結果を見てみましょう。 公式ドキュメントを見てみると、返り値には(match_id, start, end)のようなタプルのリストが返ってくるようです。ルールにマッチした部分の文字列はもともとの文章を解析したdocに対してdoc[start:end]で取得できます。

for _, start, end in matches:
	print(doc[start:end].lemma_) # .lemma_ は見出し語を取得することを意味する

上記のfor文を実行すると、次のような結果が出力されます。

美しい
悪い
美味しい
広い

ホテルに泊まった人がどんなことを感じたのかはなんとなく伝わっていますが、なんとなく以上のことは分かりません。 特に、「悪い」については一体何が悪かったのか気になってしまいますね。

そこでルールを改良し、その形容詞に対する「何が」が分かるようにしてみましょう。 例えば、次のようなルール定義が考えられます。

patterns = [
    [
		{"TAG": {"REGEX": "^名詞"}},
		{"TEXT": {"IN": ["は", "が"]}}, #「は」か「が」のいずれかにマッチ
		{"TAG": {"REGEX": "^形容詞"}}]
    ]
]

これを先程のようにMatcherオブジェクトに登録し、マッチング結果を見ます。

matcher = Matcher(nlp.vocab)
matcher.add("noun-adj", patterns)
doc = nlp(s) # s は txt に含まれる文をすべて join したもの
matches = matcher(doc)

for _, start, end in matches:
    print(doc[start:end].lemma_)

すると、次のように出力されます。

夜景が美しい
立地は悪い
食事が美味しい

なるほど。「悪い」と言われているのは立地で、夜景と食事は褒められているようです。

ですがここで、形容詞を抽出したときの結果をもう一度見てみましょう。

美しい
悪い
美味しい
広い

上の3つは何に対して言われているのか分かりましたが、何が「広い」と言われているのかは抽出できていません。 「広い」という単語が含まれていたのは以下の文でした。

客室露天風呂は大人でも足がのばせてとても広かった。

他の文と比べるとやや複雑な文ですね。 人間はこの文を見たときに「広いと言われているのは客室露天風呂(あるいは露天風呂)だな」とすぐに判断できますが、コンピュータにとっては自明ではありません。 「広かった」の直前の3単語は「のばせ」「て」「とても」で、単純に「広い」の直前を見れば良いというわけではありません。 助詞「は」「が」で判断する手もあります。実際、助詞「は」「が」を含む直前の文節が形容詞の意味の対象となっている場合も多くあります。ただし今回の場合、直前の「は」「が」がつく単語は「足」で、「足が広い」と言っているとは考えづらいですね。

そんなときに使えるのが今回のメインテーマ、依存構造解析です。

DepdencyMatcherによるマッチング

実はspaCyにはDepdencyMatcherという、依存構造を使ったマッチングのためのAPIもあります。 使い方はMatcherと似ていますが、ルールの書き方とマッチング結果の取り出し方が異なります。

文に対する依存構造は木構造をなしています。つまり、ただ1つだけ根となるトークンがあり、根以外のトークンはただ1つの係り先トークンを持っているということです。 この記事の最初の図も、よく見ていただくと「美しかっ」というトークンを根とする木構造になっています。 文に対する依存構造が木構造なので、依存構造を使ったルールも木構造になります。

依存構造を使ったルールはトークン単位でのマッチングを行うルールより少し複雑なので、最初にルールの例をお見せします。 ソースコードと木構造の図を対応付けながら見てみましょう。 今回の例はノードが2つだけですが、これも立派な木構造です。

patterns = [
    [
        {
            "RIGHT_ID": "adj"
            ,"RIGHT_ATTRS": {"TAG": {"REGEX": "^形容詞"}}
        }
        ,{
            "LEFT_ID": "adj"
            ,"REL_OP": ">"
            ,"RIGHT_ID": "noun"
            ,"RIGHT_ATTRS": {"TAG": {"REGEX": "^名詞"}, "DEP": "nsubj"}
        }
    ]
]

02yoshinari_dependency_pattern.png

(画像:DependencyMatcher用のシンプルなルールの図解)

図のように、DependencyMatcher用のルールは左を根として右側にノードを生やしていく木構造になっています。

Matcherのときと同様にpatternsがルールのlistになっていて、その中に1つ1つのルールがdictのlistとして表現されています。 dictはそれぞれ木構造の各辺に対応しています。ここでポイントになるのは、ノードではなく辺が1つのdictに対応していることです。 根ノードの左には何の条件指定もないダミーノードがくっついていて、ダミーノードと根ノードの間の辺も張らなければならない、と考えると分かりやすいです。 各項目の意味は次のようになっています。

  • LEFT_ID:辺の左側のノードのIDとなる文字列。設定する場合、これまでの要素のRIGHT_IDで登場した文字列でなければなりません。
  • RIGHT_ID:辺の右側のノードのIDとなる文字列。他のノードとかぶらない名前をつけます。
  • REL_OP:左右のノードの関係。>であれば右のノードが左のノードに直接依存していることを示し、<であれば左右逆(左のノードが右のノードに依存)です。依存以外の関係も柔軟に定義でき、例えば.は文の中で右のノードが左のノードの直前に来ることを、;は右のノードが左のノードの直後に来ることを意味します。
  • RIGHT_ATTR:辺の右のノードに対応するトークンの条件を記載します。Matcher用のルールと同様に品詞タグや見出し語などを直接指定できます。DEPを使えば依存関係の種類も指定できます。

専門的な説明にはなりますが、GiNZA/spaCyで日本語テキストを解析する際の依存関係の種類については次の論文に詳しくまとまっています。

浅原 正幸, 金山 博, 宮尾 祐介, 田中 貴秋, 大村 舞, 村脇 有吾, 松本 裕治, Universal Dependencies 日本語コーパス, 自然言語処理, 2019, 26巻, 1号, p.3-36, https://www.jstage.jst.go.jp/article/jnlp/26/1/26_3/_article/-char/ja.

慣れないうちは依存関係の種類を指定することが難しく感じる方も多いかもしれません。 しかしながら、実際に様々な文を解析にかけながら参照してみると徐々に分かってくると思います。 解析結果は例えば次のように可視化できるので活用してください。

s = "部屋から見える夜景が美しかった"
doc = nlp(s)
displacy.render(doc) # jupyter上で可視化する場合はjupyter=Trueを指定する

次のような画像が出力されます。

03yoshinari_dependency_parsing (1).png

また、各ノードには必須の項目があります。

  • 根ノードを定義する dict:RIGHT_ID RIGHT_ATTR
  • 辺を定義する dict:LEFT_ID REL_OP RIGHT_ID RIGHT_ATTR

右のノードがどんな単語でも良い、という場合もありますが、その場合はRIGHT_ATTRに空のdict{}を設定しておきます。

もっと複雑なルールを書きたい場合には公式ドキュメントや公式のUSAGEをご参照ください。

さて、ルールの書き方の説明が長くなってしまいましたが、上記のルールでマッチングをしてみましょう。

from spacy.matcher import DependencyMatcher

matcher = DependencyMatcher(nlp.vocab)
patterns = [
    [
        {
            "RIGHT_ID": "adj"
            ,"RIGHT_ATTRS": {"TAG": {"REGEX": "^形容詞"}}
        }
        ,{
            "LEFT_ID": "adj"
            ,"REL_OP": ">"
            ,"RIGHT_ID": "noun"
            ,"RIGHT_ATTRS": {"TAG": {"REGEX": "^名詞"}, "DEP": "nsubj"}
        }
    ]
]

matcher.add("adj_noun_pair", patterns)
# txts = [
# 	"部屋から見える夜景が美しかった。", 
# 	"立地は悪いが食事が美味しい。", 
# 	"客室露天風呂は大人でも足がのばせてとても広かった。"
# ]
# s = "".join(txts)
doc = nlp(s)
matches = matcher(doc)

DependencyMatcherのマッチング結果は、マッチングIDと、ルールとの対応付け(alignments)のタプルとして返されます。 alignmentsはルールの各要素で定義された辺の右側のノードにあたるトークンのindexが格納されています。 そのため、次のようにして結果を取り出します。

for _, alignments in matches:
    print([doc[alignment].lemma_ for alignment in alignments])
# ['美しい', '夜景']
# ['悪い', '立地']
# ['美味しい', '食事']
# ['広い', '露天風呂']

各マッチング結果について、最初にIDadjに対応するトークンのindexが、次にIDnounに対応するトークンのindexが格納されていることがわかります。

また、

客室露天風呂は大人でも足がのばせてとても広かった。

について、広いのが露天風呂であるということも拾えています。 欲を言うと広いのは「露天風呂」というより「客室露天風呂」としてほしい場面もあると思いますが、複合名詞を抽出できるようにする対応もDependencyMatcherのルールを改良することで可能になります。ご興味のある方はぜひ考えてみてください。

まとめ

本記事では、GiNZAとspaCyのDependencyMatcherを用いて日本語のレビューから情報を抽出する方法について、簡単な例を用いて説明しました。 また、DependencyMatcherの便利さを示すため、Matcherによる情報抽出も行いました。 GiNZAもspaCyも、実務で自然言語処理を行う際には非常に便利なライブラリです。 今回紹介した機能に限らず、GiNZA/spaCyを使いこなして世の中のテキストデータを有効に活用していきましょう!

この記事を書いた人

吉成 未菜里

DXプラットフォーム部と技術研究所に所属する新卒2年目エンジニア。
実は抹茶の味のお菓子が苦手なのですが、spaCyのMatcherはよく使っています。

フォルシアではフォルシアに興味をお持ちいただけた方に、社員との面談のご案内をしています。
採用応募の方、まずはカジュアルにお話をしてみたいという方は、お気軽に下記よりご連絡ください。


採用お問い合わせフォーム 募集要項

※ 弊社社員に対する営業行為などはお断りしております。ご希望に沿えない場合がございますので予めご了承ください。