オブジェクトの広場は株式会社オージス総研グループのエンジニアによる技術発表サイトです

AI

はじめての自然言語処理

第4回 spaCy/GiNZA を用いた自然言語処理
技術部 アドバンストテクノロジセンター
鵜野 和也
2019年8月27日

前回は BERT についてその概要と使い方を紹介しました。今回は自然言語処理ライブラリである spaCy と spaCy をフロントエンドとする日本語NLPライブラリの GiNZA について紹介します。

1. 始めに

本記事では欧米で有名な自然言語処理ライブラリである spaCy とリクルートと国立国語研究所の共同研究成果である日本語NLPライブラリ GiNZA について紹介します。記事の前半では、spaCy と GiNZA の概要と日本語を処理する際の基本的な機能/操作について説明します。後半では、spaCy で提供される文章分類機能について、前回までに紹介した手法も含めて精度を比較してみます。

2. spaCy と GiNZA の概要

spaCy は Explosion AI 社の開発する Python/Cython で実装されたオープンソースの自然言語処理ライブラリで MIT ライセンスで利用が可能です。多くの言語をサポートし、学習済みの統計モデルと単語ベクトルが付属しています。また、研究用ではなくプロダクション環境での本番利用を念頭に開発されていることも NLTK や CoreNLP といった他の自然言語処理ライブラリと一線を画す点だとしています。

Universal Dependencies (UD) ついても触れておきましょう。Universal Dependencies は異なる言語間で共通化された依存構造アノテーション仕様です。spaCy で解析した品詞のタグや依存関係のラベルは、この Universal Dependencies に基づいている為、異なる言語でも spaCy で解析すれば、その後に続く処理を同一ロジックで対応できるという非常に大きな利点があります。

しかし最近まで spaCy の学習済みモデルには日本語に対応したものがなく、バックエンドに MeCab を用いた形態素解析ができる程度でした。その為、spaCy を利用して記述された自然言語処理のアプリケーションやライブラリでは日本語の文書を処理することができない状況が続いていました。

ここで、2019年4月にリクルートと国立国語研究所の研究成果である GiNZA が登場します。主な特徴をリクルート社のリリース*1から引用すると、

  1. 高度な自然言語処理をワンステップで導入完了
  2. 高速・高精度な解析処理と依存構造解析レベルの国際化に対応
  3. 国立国語研究所との共同研究成果の学習モデルを提供

とのことで、早い話が spaCy を日本語で利用できるようになった!pip install 一発でインストールできるので導入も簡単!!ということでよいかと思います。

spaCy と GiNZA の関係性について整理しておくと、spaCy のアーキテクチャは以下のような構造となっていて、図中の上段の、 自然言語の文字列を形態素に分割する Tokenizer, spaCy の統計モデルに相当する Language といった部分の実装を GiNZA が提供しているという建て付けになります。

spaCyの構造

上図左下の Doc は spaCy での処理結果を保持する中核のオブジェクトになり、形態素解析で分割された Token のシーケンスを保持しています。SpanDoc の一部分を切り出したスライスで、その要素は Token です。Token には品詞や依存関係の情報を保持する属性があり、詳しくは後述します。また、Vocab は統計モデルが保持している語彙の集合で、Lexeme が一つ一つの語彙(=単語)に相当し単語ベクトルを保持しています。

spaCy におけるテキスト処理の流れは以下のとおりです。複数のコンポーネントが自身の処理結果(Doc)を後段のコンポーネントにバケツリレーするパイプライン処理となっており、変更や拡張が容易な構造になっています。

spaCyのパイプライン

GiNZA は日本語のトークン化に SudachiPy を使用しており、GiNZAが提供する Tokenizer の実装(ginza.sudachi_tokenizer.SudachiTokenizer)は、 SudachiPy のラッパー的な立ち位置になります。また、Launguage の実装内容も非常にシンプルで、Tokenizer の実装として前述の SudachiTokenizer を使用すること、パイプラインの末端に ginza.japanese_corrector.JapaneseCorrector を追加することの二点がメインになっています。

ここで JapaneseCorrector について簡単に補足しておきます。 JapaneseCorrector はパイプラインの末端に配置されている為、このコンポーネントの処理が開始される時点で、品詞のタグ付けや依存関係のラベリングは一旦、完了しています。ただし、GiNZA の統計モデルの依存関係ラベリングは可能性品詞の依存関係ラベルに品詞情報を埋め込むようになっており、その埋め込み情報を用いて、 JapaneseCorrector が依存関係や品詞の補正、過剰分割されたトークンのまとめ上げを行っています。この辺りの話は SlideShare に解説*2がアップされているので、興味のある人は参照してみてください。

少し前置きが長くなりましたが、ここからは spaCy を用いた日本語の処理について簡単にご紹介していきたいと思います。

3. spaCy / GiNZA の導入

まずは、GiNZA の導入から始めましょう。以下のように pip install 一発でインストールは完了です。

$ pip install "https://github.com/megagonlabs/ginza/releases/download/latest/ginza-latest.tar.gz"

GiNZA 2.0.0 からは ginza コマンドが追加され、ginza コマンドに続けて日本語の文章を入力するとその解析結果が出力されます。

$ ginza
mode is C
disabling sentence separator
spaCy は Explosion AI 社の開発する Python/Cython で実装されたオープンソースの自然言語処理ライブラリです。
# text = spaCy は Explosion AI 社の開発する Python/Cython で実装されたオープンソースの自然言語処理ライブラリです。
1       spaCy   スペーシー      NOUN    名詞-普通名詞-一般      _       22      nsubj   _       BunsetuBILabel=B|BunsetuPositionType=SEM_HEAD|NP_B
2       は      は      ADP     助詞-係助詞     _       1       case    _       BunsetuBILabel=I|BunsetuPositionType=SYN_HEAD
3       Explosion       explosion       NOUN    名詞-普通名詞-一般      _       5       compound        _       BunsetuBILabel=B|BunsetuPositionType=CONT|NP_B
4       AI      AI      NOUN    名詞-普通名詞-一般      _       5       compound        _       BunsetuBILabel=I|BunsetuPositionType=CONT|NP_I
5       社      社      NOUN    名詞-普通名詞-助数詞可能        _       7       nmod    _       BunsetuBILabel=I|BunsetuPositionType=SEM_HEAD|SpaceAfter=No|NP_I
6       の      の      ADP     助詞-格助詞     _       5       case    _       BunsetuBILabel=I|BunsetuPositionType=SYN_HEAD|SpaceAfter=No
7       開発    開発    VERB    名詞-普通名詞-サ変可能  _       11      acl     _       BunsetuBILabel=B|BunsetuPositionType=SEM_HEAD|SpaceAfter=No
8       する    為る    AUX     動詞-非自立可能 _       7       aux     _       BunsetuBILabel=I|BunsetuPositionType=SYN_HEAD
9       Python  python  NOUN    名詞-普通名詞-一般      _       11      compound        _       BunsetuBILabel=B|BunsetuPositionType=CONT|SpaceAfter=No|NP_B
10      /       /       PUNCT   補助記号-一般   _       11      compound        _       BunsetuBILabel=I|BunsetuPositionType=CONT|SpaceAfter=No
11      Cython  cython  NOUN    名詞-普通名詞-一般      _       13      nmod    _       BunsetuBILabel=I|BunsetuPositionType=SEM_HEAD|NP_B
12      で      で      SCONJ   接続詞  _       11      case    _       BunsetuBILabel=I|BunsetuPositionType=SYN_HEAD|SpaceAfter=No
13      実装    実装    VERB    名詞-普通名詞-サ変可能  _       22      acl     _       BunsetuBILabel=B|BunsetuPositionType=SEM_HEAD|SpaceAfter=No
14      さ      為る    AUX     動詞-非自立可能 _       13      aux     _       BunsetuBILabel=I|BunsetuPositionType=FUNC|SpaceAfter=No
15      れ      れる    AUX     助動詞  _       13      aux     _       BunsetuBILabel=I|BunsetuPositionType=FUNC|SpaceAfter=No
16      た      た      AUX     助動詞  _       13      aux     _       BunsetuBILabel=I|BunsetuPositionType=SYN_HEAD|SpaceAfter=No
17      オープンソース  オープンソース  PROPN   名詞-固有名詞-一般      _       22      nmod    _       BunsetuBILabel=B|BunsetuPositionType=SEM_HEAD|SpaceAfter=No|NP_B
18      の      の      ADP     助詞-格助詞     _       17      case    _       BunsetuBILabel=I|BunsetuPositionType=SYN_HEAD|SpaceAfter=No
19      自然    自然    NOUN    名詞-普通名詞-一般      _       22      compound        _       BunsetuBILabel=B|BunsetuPositionType=CONT|SpaceAfter=No|NP_B
20      言語    言語    NOUN    名詞-普通名詞-一般      _       22      compound        _       BunsetuBILabel=I|BunsetuPositionType=CONT|SpaceAfter=No|NP_I
21      処理    処理    NOUN    名詞-普通名詞-サ変可能  _       22      compound        _       BunsetuBILabel=I|BunsetuPositionType=CONT|SpaceAfter=No|NP_I
22      ライブラリ      ライブラリー    NOUN    名詞-普通名詞-一般      _       0       root    _       BunsetuBILabel=I|BunsetuPositionType=ROOT|SpaceAfter=No|NP_I
23      です    です    AUX     助動詞  _       22      cop     _       BunsetuBILabel=I|BunsetuPositionType=SYN_HEAD|SpaceAfter=No
24      。      。      PUNCT   補助記号-句点   _       22      punct   _       BunsetuBILabel=I|BunsetuPositionType=CONT|SpaceAfter=No

ginza コマンドを打った瞬間に SyntaxError: invalid syntax を食らった人は Python のバージョンを確認してみて下さい。GiNZA は Python 3.6 で動作確認しているとのことで、Python 3.5 の環境では Syntax Error になるようです。ただ、Python コードの中から spaCy を経由して使う分には本記事で試した範囲ではとりあえず大丈夫でした。

次は Python のコードから spaCy を使ってみましょう。

4. spaCy の基本

それでは、spaCy の基本的な使い方を紹介して行きます。まず、以下のように spaCy をインポートして、GiNZA のモデルをロードします。また、慣例としてロードしたモデルは nlp という変数名で保持します。

import spacy
nlp = spacy.load('ja_ginza')

4.1 パイプライン

GiNZA のモデルがロードできたところで、まずはパイプラインの内容を確認してみましょう。また、以降のコード例中のコメントは直前のコードからの出力を示すものとします。

for p in nlp.pipeline:
    print(p)

# ('tagger', <spacy.pipeline.pipes.Tagger object at 0x7f58893889b0>)
# ('parser', <spacy.pipeline.pipes.DependencyParser object at 0x7f587c8a04c8>)
# ('ner', <spacy.pipeline.pipes.EntityRecognizer object at 0x7f587c8a0528>)
# ('JapaneseCorrector', <ginza.japanese_corrector.JapaneseCorrector object at 0x7f588962a470>)    

パイプラインとしてデフォルトで、tagger, parse, ner, JapaneseCorrector の順で構成されていることが確認できます。tagger, parser, ner はそれぞれ、品詞タギング、依存関係ラベリング、固有表現抽出を担っています。

spaCy のドキュメントによれば、「tagger, parser, ner は独立で互いに他のコンポーネントが設定する属性を参照することはない為、順序を入れ替えたり一部を抜いたりして問題ないが、カスタムコンポーネントに関してはその限りではないかもしれない。」とのことです*3。前述のとおり、 JapaneseCorrector は前段の tagger, parser の解析結果に依存しているので、この3つは触らないのが無難でしょう。

それでは、spaCy の機能を順に確認していきましょう。

4.2 文章の解析

文章の解析は、nlp に解析したいテキストを渡すだけです。nlp(...) からは解析済みの Doc オブジェクトが返ってきます。 この時点で、トークン化、品詞のタギング、依存関係のラベリング、固有表現抽出の一通りの処理が終わっています。

doc = nlp('spaCy はオープンソースの自然言語処理ライブラリです。学習済みの統計モデルと単語ベクトルが付属しています。')

また大量の文書を処理する際は、以下のように pipe メソッドを使うと内部的にバッチ化されるので、より効率的な処理が可能です。

texts = ['本日は晴天なり。', '吾輩は猫である']
docs = list(nlp.pipe(texts))

さらに効率化したい時はパイプライン中の不要コンポーネントの無効化も手段の一つです。筆者がぱっと見たところ ner は抜いても大丈夫そうでした。パイプラインから特定のコンポーネントを除去するには以下のようにします。

docs = nlp.pipe(texts, disable=['ner'])

もしくは、コンポーネントを無効化した状態で統計モデルのパラメータ更新等の操作を行う場合は with ブロックを用いることもできます。この場合は with ブロックの中にいる間は指定したコンポーネントが無効化された状態で維持されます。

with nlp.disable_pipes("ner"):
    doc = nlp('本日は晴天なり。')

ここからは、解析済みの Doc オブジェクトを見ていきましょう。

4.3 Doc オブジェクト

Doc オブジェクトには便利なメソッドが定義されており、解析したテキストを文単位に分割したり、

doc =nlp('spaCy はオープンソースの自然言語処理ライブラリです。学習済みの統計モデルと単語ベクトルが付属しています。')
for s in doc.sents:
    print(s)

# spaCy はオープンソースの自然言語処理ライブラリです。
# 学習済みの統計モデルと単語ベクトルが付属しています。   

名詞句のみ抽出したりできます。

for np in doc.noun_chunks:
    print(np)

# spaCy
# オープンソース
# 自然言語処理ライブラリ
# 学習済み
# 統計モデル
# 単語ベクトル    

doc.sents, noun_chunks の戻り値は spaCy のアーキテクチャの図で登場した Span のジェネレータです。

そして、Doc は分割したToken を保持するイテレータになっています。

for token in doc:
  print(token.text)

# spaCy
# は
# オープン
# ソース
# の
# 自然
# 言語
# 処理
# ライブラリ
# です
# 。
# 学習
# 済み
# の
# 統計
# モデル
# と
# 単語
# ベクトル
# が
# 付属
# し
# て
# い
# ます
# 。  

Token の属性には品詞タグや依存関係ラベリングの結果が格納されていますので、次はそのあたりを確認してみましょう。

4.4 品詞タグと依存関係ラベリング

前述のとおり Doc オブジェクトに変換した時点で各トークンへの品詞タグ付けと依存関係のラベリングも出来ています。それぞれ、Tokenpos_, dep_ という属性に格納されています。 その他の属性として iDoc 中の Token のインデックス、 head には dep_ で示される依存関係の関連先の Token が保持されています。また、 pos, dep という _ なしの属性も用意されていますが、こちらは同じ情報のint表現になります。先ほどの Doc をもう一周してみましょう。

for token in doc:
  print("token[%2d] = %-10s, pos:%-6s, dep:%-10s, head=%d" % (token.i, token.text, token.pos_, token.dep_, token.head.i))

# token[ 0] = spaCy     , pos:NOUN  , dep:nsubj     , head=8
# token[ 1] = は         , pos:ADP   , dep:case      , head=0
# token[ 2] = オープン      , pos:NOUN  , dep:compound  , head=3
# token[ 3] = ソース       , pos:NOUN  , dep:nmod      , head=8
# token[ 4] = の         , pos:ADP   , dep:case      , head=3
# token[ 5] = 自然        , pos:NOUN  , dep:compound  , head=8
# token[ 6] = 言語        , pos:NOUN  , dep:compound  , head=8
# token[ 7] = 処理        , pos:NOUN  , dep:compound  , head=8
# token[ 8] = ライブラリ     , pos:NOUN  , dep:ROOT      , head=8
# token[ 9] = です        , pos:AUX   , dep:cop       , head=8
# token[10] = 。         , pos:PUNCT , dep:punct     , head=8
# token[11] = 学習        , pos:NOUN  , dep:compound  , head=12
# token[12] = 済み        , pos:NOUN  , dep:nmod      , head=15
# token[13] = の         , pos:ADP   , dep:case      , head=12
# token[14] = 統計        , pos:NOUN  , dep:compound  , head=15
# token[15] = モデル       , pos:NOUN  , dep:nmod      , head=18
# token[16] = と         , pos:ADP   , dep:case      , head=15
# token[17] = 単語        , pos:NOUN  , dep:compound  , head=18
# token[18] = ベクトル      , pos:NOUN  , dep:nsubj     , head=20
# token[19] = が         , pos:ADP   , dep:case      , head=18
# token[20] = 付属        , pos:VERB  , dep:ROOT      , head=20
# token[21] = し         , pos:AUX   , dep:aux       , head=20
# token[22] = て         , pos:SCONJ , dep:mark      , head=20
# token[23] = い         , pos:AUX   , dep:aux       , head=20
# token[24] = ます        , pos:AUX   , dep:aux       , head=20
# token[25] = 。         , pos:PUNCT , dep:punct     , head=20

pos の属性値の定義はこちら*4dep の属性値の定義はこちら*5になります。 ADPnsubj など「なんだっけ?」っとなったときは、以下のようにして定義を確認できます(英語ですが)。

 spacy.explain("nsubj") 

 # 'nominal subject'

また、依存関係を可視化する機能もあります。jupyter notebook であれば以下のコードを実行するだけです。

from spacy import displacy
displacy.render(doc, style="dep", options={"compact":True})

依存関係の可視化

さて、先ほどのパイプラインの説明では、tagger, parser, ner とあって ner は抜いてもいいよと言ってしまいましたが、せっかくなので確認しておきましょう。

4.5 固有表現抽出

ner は Named Entity Recognition の略で固有表現抽出の機能になります。Doc 生成時に ner を無効化していなければ、以下のようにして Doc に含まれる固有表現を確認できます。

doc = nlp('2018年の夏にフランスに行った。ジベルニー村のジャン・クロード・モネの家で池に浮かぶ睡蓮を見た。')
for ent in doc.ents:
    print(ent.text, ent.start_char, ent.end_char, ent.label_)

# 2018年 0 5 DATE
# フランス 8 12 LOC
# ジベルニー村 17 23 LOC
# ジャン・クロード・モネ 24 35 PERSON   

固有表現抽出にも可視化機能がついています。

displacy.render(doc, style="ent")

固有表現抽出の可視化

抽出できる固有表現の種類ですが、上記の結果から推察するに、こちら*6だと思います。とは言っても GiNZA がどのようなデータで学習したか確認できてないので、はっきりしたことは言えません。ただ、どのような固有表現を抽出したいかは、要件次第のところもあります。GiNZA の git リポジトリに含まれる学習スクリプト*7を見ると、こちら*8train_data の形式で学習データを食わせてやれば良さそうなので、興味のある人は頑張ってみてください。spaCy のドキュメント*9によれば少なくとも数百件は必要とのことです。

4.6 単語ベクトル

単語ベクトルについても説明しておきます。使用する統計モデルに単語ベクトルが含まれている場合、 Tokenvector 属性で参照可能です。DocSpan も同様に vector 属性があり、保持する Token の単語ベクトルの平均を返す実装となっています。

token = doc[5]
print(token)
print(token.vector)

# フランス
# [-1.8482111e-01 -1.4538746e-01 -1.7175280e+00  4.6958008e-01
#  -4.5021373e-01 -1.0789571e+00 -5.5978662e-01  1.0635048e+00
#   8.0301279e-01 -4.6534860e-01  5.7746607e-01 -4.1957068e-01
#   4.3903831e-01  1.3740962e+00  1.7036986e+00 -1.4325552e+00
#  -1.3362174e+00 -2.6336935e+00 -2.2485626e-01  9.9312216e-01
#   5.6238598e-01  1.4189256e+00  4.6435905e-01 -1.5055668e+00
#  -7.9730475e-01 -1.1898736e+00  5.9205371e-01 -3.1073397e-01
#   1.5609291e+00  7.4679577e-01 -1.7725601e+00  7.5918156e-01
#  -8.3084297e-01 -1.0554338e+00 -4.9300814e-01 -1.8822047e+00
#   4.0689778e-01  1.2179197e+00  2.2390331e-01 -1.0111157e+00
#   5.0060362e-01  1.0345092e+00 -2.6290452e-01 -5.5334646e-01
#  -5.9242058e-01 -1.9505157e-01  1.1206743e+00  2.9740116e-01
#   5.9019405e-01  2.6311156e-01 -2.6286969e-01  9.0575097e-03
#   9.9736404e-01 -1.8221676e+00  5.6215721e-01  2.2554176e-01
#   8.1922668e-01 -1.1669250e+00 -1.5753677e+00 -5.9075058e-01
#  -3.0490223e-03 -2.1165743e-01  1.6681631e+00 -5.4196209e-01
#   9.2630595e-01  6.1172742e-01 -4.8828131e-01  5.5616724e-01
#   1.5083147e+00  8.3841938e-01 -3.6842281e-01 -2.4212871e-01
#  -1.3510980e-01 -3.6804101e-01 -1.6985967e+00  2.8625383e+00
#   2.9273264e+00 -1.4754202e+00  2.1591339e-01 -3.6818910e+00
#  -1.4435844e+00  7.7275890e-01 -5.2068698e-01 -2.1262252e-01
#   1.6012682e+00  5.6622529e-01  5.0263792e-01 -6.4896834e-01
#   8.2310814e-01 -5.0944788e-03 -2.6299505e+00  2.7581868e+00
#  -1.9695247e+00 -9.5479596e-01  1.0261489e-01 -2.2975194e-01
#  -1.9426610e-01  3.1085134e-01  1.0542326e+00 -1.2336625e+00]

また、Doc, Span, Token には similarity() メソッドがあり、ベクトル間のコサイン類似度を計算することができます。

token.similarity(doc)

# 0.5096586489787479

単語によっては単語ベクトルが存在しないものもあり、その場合は 0ベクトルが返されます。

token = doc[10]
print(token)
print(token.vector)

# ジベルニー
# [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
#  0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
#  0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
#  0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
#  0. 0. 0. 0.]

ただし、GiNZA の統計モデルに付属する単語ベクトルは以下のとおり、語彙数で12万語弱、単語ベクトルの次元数で 100次元なので本格的に使うには少し心もとないかもしれません。

nlp.vocab.vectors.shape

# (117951, 100)

単語ベクトルの入れ替え

というわけで、単語ベクトルを入れ替える方法をご紹介します。形態素解析器による単語分割境界の違いを考えると、できれば SudachiPy で分かち書きして学習した単語ベクトルを使いたいところですが、今回はお手軽に Facebook が fasttext で学習した 300次元のベクトルを使ってみたいと思います。

まずは、単語ベクトルダウンローダの chakin と単語ベクトルの読み込みに使う gensim をインストールしてもらって、

$ pip install chakin gensim

以下のようにすると、 chakin でダウンロードできるベクトルを検索できるので、

import chakin
chakin.search(lang='Japanese')

#                          Name  Dimension  ...  Language                 Author
# 6                fastText(ja)        300  ...  Japanese               Facebook
# 22  word2vec.Wiki-NEologd.50d         50  ...  Japanese  Shiroyagi Corporation

6 を選んでダウンロードです。

chakin.download(number=6, save_dir='./')

# Test: 100% ||                                      | Time:  0:01:53  10.7 MiB/s
# './cc.ja.300.vec.gz'

ダウンロードしたベクトルは gensim で読み込めます。

from gensim.models import KeyedVectors
wv = KeyedVectors.load_word2vec_format('./cc.ja.300.vec.gz', binary=False) 

後は以下のようにして差し替えします。

import spacy
nlp = spacy.load('ja_ginza')
nlp.vocab.reset_vectors(width=wv.vectors.shape[1])
for word in wv.vocab.keys():
    nlp.vocab[word]
    nlp.vocab.set_vector(word, wv[word])

語彙数と次元数が増えたことを確認できます。

nlp.vocab.vectors.shape

# (2113837, 300)

では、もう一度先ほどの文章を解析してみましょう。ちゃんと 300次元で「ジベルニー」にも有効なベクトルが設定されたようです。

doc = nlp('2018年の夏にフランスに行った。ジベルニー村のジャン・クロード・モネの家で池に浮かぶ睡蓮を見た。')
tok = doc[10]
print(tok)
print(tok.vector.shape)
print(tok.vector)

# ジベルニー
# (300,)
# [ 4.030e-02  5.760e-02  6.030e-02 -1.640e-02  1.930e-02 -2.260e-02
#  -5.230e-02 -3.390e-02 -7.340e-02 -1.540e-02  1.530e-02  7.730e-02
#   4.300e-03 -2.060e-02 -3.110e-02  3.300e-02 -2.440e-02  1.800e-02
#   1.260e-02  7.000e-03  1.920e-02  1.470e-02 -4.820e-02  1.010e-01
#   3.940e-02  2.170e-02 -3.330e-02  3.820e-02  5.530e-02  3.530e-02
#   ... -- 8<-- snip --8<-- ...

この時点で、単語ベクトルを差し替えたりして既存の taggerparser に影響ないかと心配になりました。 文章を解析するときはメソッドの名前からして predict が動くんだろう、というわけで taggerpredict のロジックを追ってみました(*10, *11, *12, *13, *14)。 どうやら、 predict 時は thinc.extra.load_nlp.VECTORS にロードされているキャッシュを参照するようです。

統計モデルをロードした直後の既存の tagger の設定と thinc.extra.load_nlp.VECTORS の内容は以下の通りです。pretrained_vectorsja_nopn.vector が設定されており、この値をキーとして、thinc.extra.load_nlp.VECTORS にロードされているデータが使われます。

import pprint
pp = pprint.PrettyPrinter(indent=4)
tagger = nlp.pipeline[0][1]
pp.pprint(tagger.cfg)

# OrderedDict([   ('cnn_maxout_pieces', 2),
#                ('deprecation_fixes', {'vectors_name': 'ja_nopn.vectors'}),
#                ('pretrained_vectors', 'ja_nopn.vectors')])

from thinc.extra.load_nlp import VECTORS
pp.pprint(VECTORS)

# {   ('cpu', 'ja_nopn.vectors'): array([[-1.5094382e-01, -8.1547551e-02, -2.6536912e-01, ...,
#         1.5129465e-01,  4.7888958e-01, -3.3078361e-01],
#       [-1.3570154e-01,  3.1898614e-02,  1.4843714e-01, ...,
#         1.8364359e-02,  3.1024510e-01, -1.6428173e-01],
#       [-1.3984448e-01, -6.9165421e-01,  2.9166898e-01, ...,
#         1.6117430e-01,  6.5966105e-01,  2.9314500e-01],
#       ...,
#       [-5.1974124e-01,  1.7703209e+00, -4.2954564e-01, ...,
#        -2.8516525e-01, -5.4751170e-01,  1.0399977e+00],
#       [ 1.6092832e-01, -8.6975023e-02, -1.3531526e+00, ...,
#         5.8156943e-01,  4.8446590e-01,  1.3305596e+00],
#       [ 1.7371757e+00,  5.6867097e-03, -9.0093315e-01, ...,
#        -9.5127529e-01,  2.1251873e-04,  9.1981864e-01]], dtype=float32)}

ベクトルを差し替えた後の tagger の設定と thinc.extra.load_nlp.VECTORS は以下のとおりで、そのままです。これで単語ベクトルの差し替えが既存の統計モデルに影響を与えないことが確認できました。

tagger = nlp.pipeline[0][1]
pp.pprint(tagger.cfg)
# OrderedDict([   ('cnn_maxout_pieces', 2),
#                ('deprecation_fixes', {'vectors_name': 'ja_nopn.vectors'}),
#                ('pretrained_vectors', 'ja_nopn.vectors')])


from thinc.extra.load_nlp import VECTORS
pp.pprint(VECTORS)

# {   ('cpu', 'ja_nopn.vectors'): array([[-1.5094382e-01, -8.1547551e-02, -2.6536912e-01, ...,
#         1.5129465e-01,  4.7888958e-01, -3.3078361e-01],
#       [-1.3570154e-01,  3.1898614e-02,  1.4843714e-01, ...,
#         1.8364359e-02,  3.1024510e-01, -1.6428173e-01],
#       [-1.3984448e-01, -6.9165421e-01,  2.9166898e-01, ...,
#         1.6117430e-01,  6.5966105e-01,  2.9314500e-01],
#       ...,
#       [-5.1974124e-01,  1.7703209e+00, -4.2954564e-01, ...,
#        -2.8516525e-01, -5.4751170e-01,  1.0399977e+00],
#       [ 1.6092832e-01, -8.6975023e-02, -1.3531526e+00, ...,
#         5.8156943e-01,  4.8446590e-01,  1.3305596e+00],
#       [ 1.7371757e+00,  5.6867097e-03, -9.0093315e-01, ...,
#        -9.5127529e-01,  2.1251873e-04,  9.1981864e-01]], dtype=float32)}

さて、せっかく単語ベクトルを差し替えたので、その効果の程を確認したいところです。という訳で文書分類をしてみましょう。

5. 文章分類

この連載では Rasa NLU, BERT に続いて3回目の文章分類です。spaCy には文章の分類器として、bow, simple_cnn, ensemble が用意されていますが、差し替えた単語ベクトルの効果を試すべく、simple_cnn でやってみましょう。今回使用したデータセットで Rasa NLU や BERT でも実験を行っていますので精度の比較もしてみたいと思います。

まず、GPU が使える環境であれば、spaCy を GPU 対応にしたものに入れ替え、環境を再起動しておいてください。

$ pip install -U spacy[cuda100]==2.1.6

GPUを利用する設定を有効にしましょう。

import spacy
spacy.require_gpu()
# => True

ただし、 先ほどのように差し替えた単語ベクトルを参照するには少しコツが要ります。以下のように、Thinc*15get_spacy() 関数で統計モデルをロードしてください。

from thinc.extra.load_nlp import get_spacy
nlp = get_spacy("ja_ginza")

こうすることで、Thinc が管理するキャッシュに統計モデルを乗せ、その参照( nlp )を入手しておくことで、spaCy の文書分類器である TextCategorizer が内部的にロードする単語ベクトルを書き換えられるようにします*16

前準備が終わったのでここからようやく学習です。精度を比較したいので、連載の第3回で使用した Livedoor ニュースコーパスのデータを用いました。

import pandas as pd
train_df = pd.read_csv("train.tsv", delimiter='\t')
dev_df   = pd.read_csv("dev.tsv", delimiter='\t')
test_df  = pd.read_csv("test.tsv", delimiter='\t')

参考までに、各TSVファイルの中身は以下の通りです。

$ head -5 train.tsv

# label text
# movie-enter   大島優子がここからどう破滅していくのか? 『闇...snip...
# movie-enter   インタビュー:クリスチャン・ベール「演じること...snip...
# kaden-channel ブラックマジックデザイン、HyperDeck SSD レコー...snip...
# kaden-channel センター試験終了! 受験生ファンに眞鍋かをりが...snip...

件数はこうなっています。

$ wc -l *.tsv
#    1474 dev.tsv
#    1474 test.tsv
#    4422 train.tsv
#    7370 total

それでは、データを加工していきます。

train_texts = train_df['text']
train_cats = train_df['label']
dev_texts = dev_df['text']
dev_cats = dev_df['label']
test_texts = test_df['text']
test_cats = test_df['label']

labels = train_df['label'].unique()

import copy
def conv_cat(cats, labels):
  d = {}
  for l in labels:
    d[l] = False
  temps = [copy.deepcopy(d) for i in range(len(cats))]
  for i, label in enumerate(cats):
    temps[i][label] = True
  return temps

train_cats = conv_cat(train_cats, labels)
dev_cats = conv_cat(dev_cats, labels)
test_cats = conv_cat(test_cats, labels)

train_texts = train_texts.values
dev_texts = dev_texts.values
test_texts = test_texts.values

*_cats の中身がわかりにくいかもしれませんが、こんな形です。

print(train_cats[0])

# {'dokujo-tsushin': False,
#  'it-life-hack': False,
#  'kaden-channel': False,
#  'livedoor-homme': False,
#  'movie-enter': True,
#  'peachy': False,
#  'smax': False,
#  'sports-watch': False,
#  'topic-news': False}

学習ループの中で文章の解析を行うと非効率なので事前にすべて解析を済ませておきます。動かしてみると分かりますが想像以上に時間がかかります。この点は今後の改善を期待したいところです*17

train_docs = list(nlp.pipe(train_texts, disable=['ner']))
dev_docs = list(nlp.pipe(dev_texts, disable=['ner']))
test_docs = list(nlp.pipe(test_texts, disable=['ner']))

解析済みの Doc とラベル情報を束ねてタプルのリストにして学習データの準備ができました。

train_data = list(zip(train_docs, [{"cats": cats} for cats in train_cats]))

タプルの中身はこんな感じです。

print(train_data[0][0][:20])
print(train_data[0][1])

# 大島優子がここからどう破滅し いくのか? 『闇金ウシジマくん』特報解禁“闇金
# {'cats': {'movie-enter': True, 'kaden-channel': False, 'dokujo-tsushin': False, 'it-life-hack': False, 'livedoor-homme': False, 'sports-watch': False, 'smax': False, 'topic-news': False, 'peachy': False}}

学習データの準備が整ったので、まずはパイプラインの末端に分類器を追加します。architecture には使用する分類モデルのアーキテクチャを指定できるので、前述のとおり、simple_cnn を指定しています。

if "textcat" not in nlp.pipe_names:
  textcat = nlp.create_pipe("textcat", config={"exclusive_classes": True, "architecture": "simple_cnn"})
  nlp.add_pipe(textcat, last=True)

次は分類器に分類対象となるラベルを登録します。

for label in train_df['label'].unique():
  textcat.add_label(label)
  print("Add label %s." % (label))

# Add label movie-enter.
# Add label kaden-channel.
# Add label dokujo-tsushin.
# Add label it-life-hack.
# Add label livedoor-homme.
# Add label sports-watch.
# Add label smax.
# Add label topic-news.
# Add label peachy.  

評価関数も準備します。

from sklearn.metrics import precision_recall_fscore_support
from sklearn.metrics import classification_report
def evaluate(tokenizer, textcat, docs, cats, verbose=False):
    y_true = [max(cat.items(), key=lambda x:x[1])[0] for cat in cats]
    y_pred = []
    for i, doc in enumerate(textcat.pipe(docs)):
        prediction = max(doc.cats.items(), key=lambda x:x[1])[0]
        y_pred.append(prediction)
    if verbose == False:
      p, r, f1 = precision_recall_fscore_support(y_true, y_pred, average="micro")[:3]    
      return {"textcat_p": p, "textcat_r": r, "textcat_f": f1}
    else:
      return classification_report(y_true, y_pred)

単語ベクトルの計算を GPU で行うため、CuPy アレイに変換しておきます。

import cupy
with cupy.cuda.Device(0):
    nlp.vocab.vectors.data = cupy.asarray(nlp.vocab.vectors.data)

5.1 単語ベクトル差し替えなしの状態で学習

では、まずは単語ベクトルを差し替えていない素の状態で学習をしてみましょう。ポイントは #NOTE の行で TextCategorizer の設定に pretrained_vector を入れることで、先ほど確認した GiNZA に同梱されている100次元の単語ベクトルを使用して学習することになります*18

import random
from spacy.util import minibatch, compounding

other_pipes = [pipe for pipe in nlp.pipe_names if pipe != "textcat"]
n_iter = 20

with nlp.disable_pipes(*other_pipes):  # only train textcat
    textcat = nlp.pipeline[-1][-1]
    optimizer = textcat.begin_training(pretrained_vectors='ja_nopn.vectors') # NOTE
    print("Training the model...")
    print("{:^5}\t{:^5}\t{:^5}\t{:^5}".format("LOSS", "P", "R", "F"))
    batch_sizes = compounding(4.0, 32.0, 1.001)
    num_samples = len(train_data)
    for i in range(n_iter):
        losses = {}
        # batch up the examples using spaCy's minibatch
        random.shuffle(train_data)
        batches = minibatch(train_data, size=batch_sizes)
        processed = 0
        for i, batch in enumerate(batches):
            texts, annotations = zip(*batch)
            nlp.update(texts, annotations, sgd=optimizer, drop=0.2, losses=losses)
            processed += len(batch)
            percentage = processed / num_samples * 100.0
        with textcat.model.use_params(optimizer.averages):
            # evaluate on the dev data split off in load_data()
            scores = evaluate(nlp.tokenizer, textcat, dev_docs, dev_cats)
        print(
            "{0:.3f}\t{1:.3f}\t{2:.3f}\t{3:.3f}".format(  # print a simple table
                losses["textcat"],
                scores["textcat_p"],
                scores["textcat_r"],
                scores["textcat_f"],
            )
        )

# Training the model...
# LOSS    P       R       F  
# 17.466    0.858   0.858   0.858
# 0.853 0.900   0.900   0.900
# 0.154 0.912   0.912   0.912
# 0.042 0.920   0.920   0.920
# 0.015 0.923   0.923   0.923
# 0.006 0.925   0.925   0.925
# 0.004 0.924   0.924   0.924
# 0.003 0.925   0.925   0.925
# 0.003 0.924   0.924   0.924
# 0.006 0.923   0.923   0.923
# 0.001 0.922   0.922   0.922
# 0.001 0.923   0.923   0.923
# 0.001 0.923   0.923   0.923
# 0.001 0.923   0.923   0.923
# 0.001 0.923   0.923   0.923
# 0.001 0.922   0.922   0.922
# 0.001 0.924   0.924   0.924
# 0.001 0.924   0.924   0.924
# 0.001 0.924   0.924   0.924
# 0.000 0.925   0.925   0.925

with textcat.model.use_params(optimizer.averages):
    report = evaluate(nlp.tokenizer, textcat, test_docs, test_cats, verbose=True)
    print("test loss = %5.3f\n" % (losses["textcat"]))
    print(report)

# test loss = 0.000
# 
#                 precision    recall  f1-score   support
# 
# dokujo-tsushin       0.94      0.87      0.90       178
#   it-life-hack       0.92      0.91      0.91       172
#  kaden-channel       0.94      0.97      0.96       176
# livedoor-homme       0.81      0.78      0.80        95
#    movie-enter       0.92      0.96      0.94       158
#         peachy       0.87      0.89      0.88       174
#           smax       0.93      0.98      0.95       167
#   sports-watch       0.94      0.97      0.96       190
#     topic-news       0.95      0.89      0.92       163
#
#       accuracy                           0.92      1473
#      macro avg       0.91      0.91      0.91      1473
#   weighted avg       0.92      0.92      0.92      1473    

5.2 単語ベクトルを差し替えて学習

次は単語ベクトルを差し替えて実験します。まずは TextCategorizer を作り直します。

nlp.remove_pipe('textcat')

if "textcat" not in nlp.pipe_names:
  textcat = nlp.create_pipe("textcat", config={"exclusive_classes": True, "architecture": "simple_cnn"})
  nlp.add_pipe(textcat, last=True)

for label in train_df['label'].unique():
  textcat.add_label(label)
  print("Add label %s." % (label))

この状態で単語ベクトルを差し替えます。また、 Vocab.reset_vectors() を実行すると単語ベクトルの名称もクリアされるので、明示的に再設定しておきます。

nlp.vocab.reset_vectors(width=wv.vectors.shape[1])
for word in wv.vocab.keys():
    nlp.vocab[word]
    nlp.vocab.set_vector(word, wv[word])

nlp.vocab.vectors.name = 'ja_ginza' 

単語ベクトルを差し替えたので、再び CuPy アレイに変換しておきます。

import cupy
with cupy.cuda.Device(0):
    nlp.vocab.vectors.data = cupy.asarray(nlp.vocab.vectors.data)

それでは、もう一度学習します。pretrained_vectors には ja_ginza を渡します。

import random
from spacy.util import minibatch, compounding

other_pipes = [pipe for pipe in nlp.pipe_names if pipe != "textcat"]
n_iter = 20

with nlp.disable_pipes(*other_pipes):  # only train textcat
    textcat = nlp.pipeline[-1][-1]
    optimizer = textcat.begin_training(pretrained_vectors='ja_ginza') # NOTE
    print("Training the model...")
    print("{:^5}\t{:^5}\t{:^5}\t{:^5}".format("LOSS", "P", "R", "F"))
    batch_sizes = compounding(4.0, 32.0, 1.001)
    num_samples = len(train_data)
    for i in range(n_iter):
        losses = {}
        # batch up the examples using spaCy's minibatch
        random.shuffle(train_data)
        batches = minibatch(train_data, size=batch_sizes)
        processed = 0
        for i, batch in enumerate(batches):
            texts, annotations = zip(*batch)
            nlp.update(texts, annotations, sgd=optimizer, drop=0.2, losses=losses)
            processed += len(batch)
            percentage = processed / num_samples * 100.0
        with textcat.model.use_params(optimizer.averages):
            # evaluate on the dev data split off in load_data()
            scores = evaluate(nlp.tokenizer, textcat, dev_docs, dev_cats)
        print(
            "{0:.3f}\t{1:.3f}\t{2:.3f}\t{3:.3f}".format(  # print a simple table
                losses["textcat"],
                scores["textcat_p"],
                scores["textcat_r"],
                scores["textcat_f"],
            )
        )

# Training the model...
# LOSS    P       R       F  
# 6.939 0.897   0.897   0.897
# 0.406 0.914   0.914   0.914
# 0.067 0.917   0.917   0.917
# 0.019 0.916   0.916   0.916
# 0.006 0.919   0.919   0.919
# 0.002 0.919   0.919   0.919
# 0.001 0.919   0.919   0.919
# 0.001 0.920   0.920   0.920
# 0.000 0.921   0.921   0.921
# 0.001 0.920   0.920   0.920
# 0.001 0.919   0.919   0.919
# 0.001 0.921   0.921   0.921
# 0.001 0.922   0.922   0.922
# 0.001 0.920   0.920   0.920
# 0.003 0.918   0.918   0.918
# 0.002 0.917   0.917   0.917
# 0.001 0.916   0.916   0.916
# 0.001 0.921   0.921   0.921
# 0.000 0.921   0.921   0.921
# 0.001 0.921   0.921   0.921

with textcat.model.use_params(optimizer.averages):
    report = evaluate(nlp.tokenizer, textcat, test_docs, test_cats, verbose=True)
    print("test loss = %5.3f\n" % (losses["textcat"]))
    print(report)

# test loss = 0.001
# 
#                 precision    recall  f1-score   support
# 
# dokujo-tsushin       0.95      0.89      0.92       178
#   it-life-hack       0.92      0.89      0.91       172
#  kaden-channel       0.95      0.93      0.94       176
# livedoor-homme       0.90      0.82      0.86        95
#    movie-enter       0.90      0.96      0.93       158
#         peachy       0.88      0.93      0.90       174
#           smax       0.94      0.98      0.96       167
#   sports-watch       0.94      0.97      0.96       190
#     topic-news       0.93      0.91      0.92       163
# 
#       accuracy                           0.93      1473
#      macro avg       0.92      0.92      0.92      1473
#   weighted avg       0.93      0.93      0.93      1473

手間をかけて単語ベクトルを差し替えましたが、F1スコアの重み付き平均で、 0.92 -> 0.93 とほとんど差がありません。 本当に差し替えたベクトルで学習できているのか、不審に思い単語ベクトルを乱数に置き換えたところ、 0.89 でした。精度が落ちているので差し替えたベクトルを参照してはいるようです。

しかし、単語ベクトルを乱数にしているのに F1スコアで 0.89 が出る理由はなんでしょうか。もう少し確認してみましょう。

simple_cnn の構造

今回は TextCategorizerarchitecturesimple_cnn を使いました。TextCategorizer のソースコードを確認すると simple_cnn を使う場合は、ネットワークの先頭に Tok2Vec を入れるようです*19。そして、Tok2Vec の該当するコードがちょうど *12 で示した辺りです。

332|        norm = HashEmbed(width, embed_size, column=cols.index(NORM), name="embed_norm")
333|        if subword_features:
334|            prefix = HashEmbed(
335|                width, embed_size // 2, column=cols.index(PREFIX), name="embed_prefix"
336|            )
337|            suffix = HashEmbed(
338|                width, embed_size // 2, column=cols.index(SUFFIX), name="embed_suffix"
339|            )
340|            shape = HashEmbed(
341|                width, embed_size // 2, column=cols.index(SHAPE), name="embed_shape"
342|            )
343|        else:
344|            prefix, suffix, shape = (None, None, None)
345|        if pretrained_vectors is not None:
346|            glove = StaticVectors(pretrained_vectors, width, column=cols.index(ID))
347|
348|            if subword_features:
349|                embed = uniqued(
350|                    (glove | norm | prefix | suffix | shape)
351|                    >> LN(Maxout(width, width * 5, pieces=3)),
352|                    column=cols.index(ORTH),
353|                )

NORM, PREFIX, SUFFIX, SHAPE, ORTH あたりが分かりにくいかもしれませんが、Token の属性に対応するのだと思います。学習には解析済みの Doc を投入していますので、Doc の各Token の各属性を参照、HashEmbed クラスでそれぞれ埋め込み表現にします(prefix, suffix, shape)。 さらに pretrained_vectors を指定している場合は StaticVectors 内で単語ベクトルをロードした上で写像して埋め込み表現にします(glove)。 最後に、glove, prefix, suffix, shape を連結して Layer Normalization と Maxout を掛けた上で畳み込んだものが Tok2Vec の変換結果となります。

日本語の説明ではわかりにくいので simple_cnn のネットワーク構造を図にしてみました。uniqued は一種のキャッシュです。自然言語では同じトークンが複数回出現するケースが多いため、何度も同じ計算をする手間を省くためのものです。この後、convolution の枠で囲んだ畳み込み処理が複数回続きます(デフォルトは4回)。ExtractWindow が畳み込みの本体で前後のトークンのここまでの計算値と合わせて特徴量を抽出します。畳み込み処理を迂回する経路は前回の BERT でも登場した残差接続です。with_flatten はパラメータ更新中のみフラット化して元に戻す処理です。可変長の文章を取り扱う為、そのあたりの計算効率の為のものかと思われます。

simple_cnn の構造

Tok2Vec を抜けた後の処理は言葉で説明しづらいので、こちらも図にしています。各トークン毎に算出した特徴量を、flatten_add_length で1バッチ分をひと固まりにまとめます。この際、バッチに含まれる各シーケンスのトークン数は可変であるため、各シーケンスのトークン数も併せて計算しておき、後続の平均処理で使用します。Mean Pooling で特徴量の各次元毎にシーケンスの前後方向で平均して Softmax で各分類の確信度にします。Softmax の前後でテンソルのシェイプが変わっていますが、これは Thinc の Softmax は重みによる線形写像を含んだ処理となっている為です。

simple_cnn の構造2

こうして構造を確認すると大半の処理が Tok2Vec で行われていることが分かります。 じつは spaCy version 2.1 から Tok2Vec の事前学習機能が導入されており、大量文書で事前学習済みの Tok2Vec を用いて、TextCategorizer を含めたパイプラインの各コンポーネントをファインチューニングすることが可能になっています。この辺りは BERT と同様の考え方になります*20

話がだいぶ脱線してしまいました。そういう訳で simple_cnn は単語ベクトル以外の複数の情報を参照して学習している為、単語ベクトルが最終的な分類結果に与える影響が薄くなっているようです。

ついでに、NORM, PREFIX, SUFFIX, SHAPE にどんな値が入っているのか確認してみましょう。欧米の言語を使うといい感じかもしれないですが、日本語の場合は少し微妙な雰囲気です*21

for t in train_docs[0][:5]:
  print("norm: %s, prefix:%s, suffix:%s, shape:%s" % (t.norm_, t.prefix_, t.suffix_, t.shape_))

# norm: 大島, prefix:大, suffix:大島, shape:xx
# norm: 優子, prefix:優, suffix:優子, shape:xx
# norm: が, prefix:が, suffix:が, shape:x
# norm: ここ, prefix:こ, suffix:ここ, shape:xx
# norm: から, prefix:か, suffix:から, shape:xx

SUFFIX はデフォルトで末尾3文字です。英語は “ing”, “ed”, “er”, “est” の表現があるのでこれで良いのでしょうが、日本語では末尾1文字の方がよさそうですね*22SHAPE も記号以外は数字が"d"、大文字が"X"、小文字が"x"になるようですが、漢字、カタカナ、ひらがなを区別してくれると嬉しいかもしれません。

最後に、TextCategorizer による分類には bow を指定できるので、こちらの結果も見てみましょう。

5.3 bow による分類

まずは、bow を指定した時のソースを見てみましょう*23。 627行目辺りを見る分には単純に Bag of Words のベクトルを作って全結合層を使って分類をするようです。ORTH は「単語ID」くらいに思っておいてください。

622| def build_bow_text_classifier(
623|     nr_class, ngram_size=1, exclusive_classes=False, no_output_layer=False, **cfg
624| ):
625|     with Model.define_operators({">>": chain}):
626|         model = with_cpu(
627|             Model.ops, extract_ngrams(ngram_size, attr=ORTH) >> LinearModel(nr_class)
628|         )
629|         if not no_output_layer:
630|             model = model >> (cpu_softmax if exclusive_classes else logistic)
631|     model.nO = nr_class
632|     return model

では学習をしてみましょう。architecturebow を指定します。

nlp.remove_pipe('textcat')

if "textcat" not in nlp.pipe_names:
  textcat = nlp.create_pipe("textcat", config={"exclusive_classes": True, "architecture": "bow"})
  nlp.add_pipe(textcat, last=True)

for label in train_df['label'].unique():
  textcat.add_label(label)
  print("Add label %s." % (label))

学習ループを回します。今回は pretrained_vectors の指定は不要です。

import random
from spacy.util import minibatch, compounding

other_pipes = [pipe for pipe in nlp.pipe_names if pipe != "textcat"]
n_iter = 20

with nlp.disable_pipes(*other_pipes):  # only train textcat
    textcat = nlp.pipeline[-1][-1]
    optimizer = textcat.begin_training() # NOTE
    print("Training the model...")
    print("{:^5}\t{:^5}\t{:^5}\t{:^5}".format("LOSS", "P", "R", "F"))
    batch_sizes = compounding(4.0, 32.0, 1.001)
    num_samples = len(train_data)
    for i in range(n_iter):
        losses = {}
        # batch up the examples using spaCy's minibatch
        random.shuffle(train_data)
        batches = minibatch(train_data, size=batch_sizes)
        processed = 0
        for i, batch in enumerate(batches):
            texts, annotations = zip(*batch)
            nlp.update(texts, annotations, sgd=optimizer, drop=0.2, losses=losses)
            processed += len(batch)
            percentage = processed / num_samples * 100.0
            #if i % 20 == 0:
            #  print("  %5.2f %% of epoch done. batch size = %d" % (percentage, len(batch)))
        with textcat.model.use_params(optimizer.averages):
            # evaluate on the dev data split off in load_data()
            scores = evaluate(nlp.tokenizer, textcat, dev_docs, dev_cats)
        #print("{:^5}\t{:^5}\t{:^5}\t{:^5}".format("LOSS", "P", "R", "F"))
        print(
            "{0:.3f}\t{1:.3f}\t{2:.3f}\t{3:.3f}".format(  # print a simple table
                losses["textcat"],
                scores["textcat_p"],
                scores["textcat_r"],
                scores["textcat_f"],
            )
        )

# Training the model...
# LOSS    P       R       F  
# 8.439 0.929   0.929   0.929
# 0.196 0.939   0.939   0.939
# 0.028 0.943   0.943   0.943
# 0.007 0.941   0.941   0.941
# 0.003 0.942   0.942   0.942
# 0.001 0.945   0.945   0.945
# 0.001 0.948   0.948   0.948
# 0.000 0.949   0.949   0.949
# 0.001 0.948   0.948   0.948
# 0.001 0.946   0.946   0.946
# 0.001 0.944   0.944   0.944
# 0.001 0.946   0.946   0.946
# 0.001 0.947   0.947   0.947
# 0.000 0.947   0.947   0.947
# 0.000 0.948   0.948   0.948
# 0.000 0.950   0.950   0.950
# 0.000 0.948   0.948   0.948
# 0.001 0.950   0.950   0.950
# 0.000 0.948   0.948   0.948
# 0.001 0.948   0.948   0.948

with textcat.model.use_params(optimizer.averages):
    report = evaluate(nlp.tokenizer, textcat, test_docs, test_cats, verbose=True)
    print("test loss = %5.3f\n" % (losses["textcat"]))
    print(report)

# test loss = 0.001
#
#                 precision    recall  f1-score   support
# 
# dokujo-tsushin       0.96      0.88      0.91       178
#   it-life-hack       0.95      0.96      0.95       172
#  kaden-channel       0.97      0.97      0.97       176
# livedoor-homme       0.89      0.82      0.85        95
#    movie-enter       0.93      0.97      0.95       158
#         peachy       0.89      0.91      0.90       174
#           smax       0.98      1.00      0.99       167
#   sports-watch       0.92      0.98      0.95       190
#     topic-news       0.97      0.92      0.95       163
# 
#       accuracy                           0.94      1473
#      macro avg       0.94      0.94      0.94      1473
#   weighted avg       0.94      0.94      0.94      1473    

結局 bow のほうが良い精度がでてしまいました。いろいろ調べたのに。。。

5.5 そして ensemble

じつは ensemble も試してます。単語ベクトルを参照する場合は、以下のようにpretrained_dims に単語ベクトルの次元数を指定します。この辺りのパラメータ指定についてはソースで確認しているので、もう少しドキュメントが充実すると嬉しいですね。

if "textcat" not in nlp.pipe_names:
  textcat = nlp.create_pipe("textcat", config={"exclusive_classes": True, "architecture": "ensemble", "pretrained_dims": 300})
  nlp.add_pipe(textcat, last=True)

ところが、学習途中で検証を行う時点で落ちてしまいます。既知の問題*24のようなのでしばらくしたら再挑戦してみます。

今回と同じデータセットを使って、前々回の Rasa NLU, 前回の bert-japanese でも実験を行ったので、最後に比較してみましょう。

5.6 精度比較

それでは、比較結果です。bert-japanese がわずかに抜けていますが、それ以外は似たり寄ったりですね。二番手に spaCy の bow が入りましたが、Bag of words を用いてシンプルに分類するよりも、単語ベクトルを使ったほうが精度がでるイメージを持っていたので少し意外な結果になりました。

ただし simple_cnn は埋め込みベクトルの次元数やハッシュ埋め込みのテーブルサイズなど紹介しきれなかったパラメータもあるので、そのあたりを頑張れば結果は変わってくるかもしれません。でも、何も考えずに Bag of Words で 0.94 でるんであれば、「これでいいかも」という気分になってしまいますね。

文書分類の精度比較

  • simple_cnn(org) : TextCategorizersimple_cnn を使用。単語ベクトル差し替えなし
  • simple_cnn(fasttext) : TextCategorizersimple_cnn を使用。単語ベクトルを fasttext の 300次元ベクトルに差し替え
  • bow : TextCategorizerbow を使用。
  • Rasa NLU : 本連載第二回の cv_neologd の構成。MeCab(neologd)でパースした BOW を intentclassifiertenserflow_embedding で分類。
  • bert-japanese : 本連載第三回の bert-japanese を用いた文書分類の実験結果より。

6. さいごに

今回は spaCy/GiNZA についてその概要を紹介し、文書分類について今までに紹介した他の手法との比較を含めて実験を行いました。 なのですが、私が本当にお伝えしたかったのは、今回書いたような話ではありません。次回は Universal Dependencies に基づいた自然言語処理ライブラリで日本語が使えるようになった意義を伝えるべく、キーフレーズ抽出処理手法の紹介と欧米の言語を対象に実装された spaCy ベースのライブラリを GiNZA を使って日本語で利用してみたいと思います。

1: https://www.recruit.co.jp/newsroom/2019/0402_18331.html
2: https://www.slideshare.net/MegagonLabs/nlp2019-ginza-139011245
3: https://spacy.io/usage/processing-pipelines#pipeline-components-order
4: https://spacy.io/api/annotation#pos-tagging
5: https://spacy.io/api/annotation#dependency-parsing
6: https://spacy.io/api/annotation#named-entities
7: https://github.com/megagonlabs/ginza/blob/develop/ginza_util/train_ner.py
8: https://spacy.io/usage/linguistic-features#updating
9: https://spacy.io/usage/training#ner
10: https://github.com/explosion/spaCy/blob/23ec07debdd568f09c7c83b10564850f9fa67ad4/spacy/pipeline/pipes.pyx#L407
11: https://github.com/explosion/spaCy/blob/23ec07debdd568f09c7c83b10564850f9fa67ad4/spacy/_ml.py#L528
12: https://github.com/explosion/spaCy/blob/23ec07debdd568f09c7c83b10564850f9fa67ad4/spacy/_ml.py#L346
13: https://github.com/explosion/thinc/blob/750a28b37f6c279bd18743c7039316028de7268a/thinc/neural/_classes/static_vectors.py#L43
14: https://github.com/explosion/thinc/blob/750a28b37f6c279bd18743c7039316028de7268a/thinc/extra/load_nlp.py#L22
15: Thinc は spaCy が内部的に使用している深層学習ライブラリです。spaCy 同様に Explosion AI 社の開発です。https://github.com/explosion/thinc
16: この辺りのコードは込み入っており改変が入りそうなので説明は省きます。
17: もちろん、MeCab で分かち書きするのに比べ、多くの処理を行っているので当然といえば当然ではあるのですが。
18: こちらを参考にしています。 https://spacy.io/usage/training#example-textcat
19: https://github.com/explosion/spaCy/blob/ad09b0d6f39b221be413143cbfa99b9ac0aab205/spacy/pipeline/pipes.pyx#L902
20: まだまだ実験的な試みとのことなので今回は詳しく扱いません。
21: ちなみに、nlp.create_pipe()'subword_features': False を渡すことで、この辺りの機能を無効化できるのですが、精度にはあまり大きな影響はありませんでした。
22: 「デフォルト」とドキュメントに書かれると「その設定はどこで変えるの?」となりますが、定義はここだと思います。どうも設定で変更できる雰囲気ではないですね。。。 https://github.com/explosion/spaCy/blob/1711b5eb62aa0e19d4b4f88a902189454306caec/spacy/lang/lex_attrs.py#L186
23: https://github.com/explosion/spaCy/blob/23ec07debdd568f09c7c83b10564850f9fa67ad4/spacy/_ml.py#L627
24: 本記事が公開されるころにはクローズしているかもしれません。https://github.com/explosion/thinc/issues/103