前回は 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から引用すると、
- 高度な自然言語処理をワンステップで導入完了
- 高速・高精度な解析処理と依存構造解析レベルの国際化に対応
- 国立国語研究所との共同研究成果の学習モデルを提供
とのことで、早い話が spaCy を日本語で利用できるようになった!pip install
一発でインストールできるので導入も簡単!!ということでよいかと思います。
spaCy と GiNZA の関係性について整理しておくと、spaCy のアーキテクチャは以下のような構造となっていて、図中の上段の、 自然言語の文字列を形態素に分割する Tokenizer
, spaCy の統計モデルに相当する Language
といった部分の実装を GiNZA が提供しているという建て付けになります。
上図左下の Doc
は spaCy での処理結果を保持する中核のオブジェクトになり、形態素解析で分割された Token
のシーケンスを保持しています。Span
は Doc
の一部分を切り出したスライスで、その要素は Token
です。Token
には品詞や依存関係の情報を保持する属性があり、詳しくは後述します。また、Vocab
は統計モデルが保持している語彙の集合で、Lexeme
が一つ一つの語彙(=単語)に相当し単語ベクトルを保持しています。
spaCy におけるテキスト処理の流れは以下のとおりです。複数のコンポーネントが自身の処理結果(Doc
)を後段のコンポーネントにバケツリレーするパイプライン処理となっており、変更や拡張が容易な構造になっています。
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
オブジェクトに変換した時点で各トークンへの品詞タグ付けと依存関係のラベリングも出来ています。それぞれ、Token
の pos_
, dep_
という属性に格納されています。 その他の属性として i
は Doc
中の 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
の属性値の定義はこちら4、 dep
の属性値の定義はこちら5になります。 ADP
や nsubj
など「なんだっけ?」っとなったときは、以下のようにして定義を確認できます(英語ですが)。
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を見ると、こちら8 の train_data
の形式で学習データを食わせてやれば良さそうなので、興味のある人は頑張ってみてください。spaCy のドキュメント9によれば少なくとも数百件は必要とのことです。
4.6 単語ベクトル
単語ベクトルについても説明しておきます。使用する統計モデルに単語ベクトルが含まれている場合、 Token
の vector
属性で参照可能です。Doc
と Span
も同様に 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<-- ...
この時点で、単語ベクトルを差し替えたりして既存の tagger
や parser
に影響ないかと心配になりました。 文章を解析するときはメソッドの名前からして predict
が動くんだろう、というわけで tagger
の predict
のロジックを追ってみました(10, 11, 12, 13, 14)。
どうやら、 predict
時は thinc.extra.load_nlp.VECTORS
にロードされているキャッシュを参照するようです。
統計モデルをロードした直後の既存の tagger
の設定と thinc.extra.load_nlp.VECTORS
の内容は以下の通りです。pretrained_vectors
に ja_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 15 の get_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
の構造
今回は TextCategorizer
の architecture
に simple_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
はパラメータ更新中のみフラット化して元に戻す処理です。可変長の文章を取り扱う為、そのあたりの計算効率の為のものかと思われます。
Tok2Vec
を抜けた後の処理は言葉で説明しづらいので、こちらも図にしています。各トークン毎に算出した特徴量を、flatten_add_length
で1バッチ分をひと固まりにまとめます。この際、バッチに含まれる各シーケンスのトークン数は可変であるため、各シーケンスのトークン数も併せて計算しておき、後続の平均処理で使用します。Mean Pooling
で特徴量の各次元毎にシーケンスの前後方向で平均して Softmax
で各分類の確信度にします。Softmax
の前後でテンソルのシェイプが変わっていますが、これは Thinc の Softmax
は重みによる線形写像を含んだ処理となっている為です。
こうして構造を確認すると大半の処理が 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文字の方がよさそうですね22。SHAPE
も記号以外は数字が"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
では学習をしてみましょう。architecture
に bow
を指定します。
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)
:TextCategorizer
のsimple_cnn
を使用。単語ベクトル差し替えなしsimple_cnn(fasttext)
:TextCategorizer
のsimple_cnn
を使用。単語ベクトルを fasttext の 300次元ベクトルに差し替えbow
:TextCategorizer
のbow
を使用。Rasa NLU
: 本連載第二回のcv_neologd
の構成。MeCab(neologd)でパースした BOW を intentclassifiertenserflow_embedding で分類。bert-japanese
: 本連載第三回のbert-japanese
を用いた文書分類の実験結果より。
6. さいごに
今回は spaCy/GiNZA についてその概要を紹介し、文書分類について今までに紹介した他の手法との比較を含めて実験を行いました。 なのですが、私が本当にお伝えしたかったのは、今回書いたような話ではありません。次回は Universal Dependencies に基づいた自然言語処理ライブラリで日本語が使えるようになった意義を伝えるべく、キーフレーズ抽出処理手法の紹介と欧米の言語を対象に実装された spaCy ベースのライブラリを GiNZA を使って日本語で利用してみたいと思います。
-
https://www.slideshare.net/MegagonLabs/nlp2019-ginza-139011245 ↩
-
https://spacy.io/usage/processing-pipelines#pipeline-components-order ↩
-
https://github.com/megagonlabs/ginza/blob/develop/ginza_util/train_ner.py ↩
-
https://github.com/explosion/spaCy/blob/23ec07debdd568f09c7c83b10564850f9fa67ad4/spacy/pipeline/pipes.pyx#L407 ↩
-
https://github.com/explosion/spaCy/blob/23ec07debdd568f09c7c83b10564850f9fa67ad4/spacy/_ml.py#L528 ↩
-
https://github.com/explosion/spaCy/blob/23ec07debdd568f09c7c83b10564850f9fa67ad4/spacy/_ml.py#L346 ↩
-
https://github.com/explosion/thinc/blob/750a28b37f6c279bd18743c7039316028de7268a/thinc/neural/_classes/static_vectors.py#L43 ↩
-
https://github.com/explosion/thinc/blob/750a28b37f6c279bd18743c7039316028de7268a/thinc/extra/load_nlp.py#L22 ↩
-
Thinc は spaCy が内部的に使用している深層学習ライブラリです。spaCy 同様に Explosion AI 社の開発です。https://github.com/explosion/thinc ↩
-
この辺りのコードは込み入っており改変が入りそうなので説明は省きます。 ↩
-
もちろん、MeCab で分かち書きするのに比べ、多くの処理を行っているので当然といえば当然ではあるのですが。 ↩
-
こちらを参考にしています。 https://spacy.io/usage/training#example-textcat ↩
-
https://github.com/explosion/spaCy/blob/ad09b0d6f39b221be413143cbfa99b9ac0aab205/spacy/pipeline/pipes.pyx#L902 ↩
-
まだまだ実験的な試みとのことなので今回は詳しく扱いません。 ↩
-
ちなみに、
nlp.create_pipe()
に'subword_features': False
を渡すことで、この辺りの機能を無効化できるのですが、精度にはあまり大きな影響はありませんでした。 ↩ -
「デフォルト」とドキュメントに書かれると「その設定はどこで変えるの?」となりますが、定義はここだと思います。どうも設定で変更できる雰囲気ではないですね。。。 https://github.com/explosion/spaCy/blob/1711b5eb62aa0e19d4b4f88a902189454306caec/spacy/lang/lex_attrs.py#L186 ↩
-
https://github.com/explosion/spaCy/blob/23ec07debdd568f09c7c83b10564850f9fa67ad4/spacy/_ml.py#L627 ↩
-
本記事が公開されるころにはクローズしているかもしれません。https://github.com/explosion/thinc/issues/103 ↩