spaCyで英語の文をトークナイズする方法を丁寧に

はじめに

本記事では,spaCyを用いて英文をトークナイズする方法を紹介します.

トークナイズに関する情報を比較的詳しく解説するとともに,公式ドキュメントやソースコードへの導線を張ることを目的にしています.

本記事に書いてあることは,公式ドキュメントの以下のページに書いてあることとほとんど変わりません.公式ドキュメントほど信頼性が高いものはありませんので,先にこちらを読まれるのをお勧めします.

Linguistic Features · spaCy Usage Documentation

目次

モジュールのインポート

インポートは一行です.

import spacy

モデルの作成

まずは,トークナイズを行うモデルを読み込みます.これはspacy.load(モデル名)で書けます.今回は英文を対象とするので,英語のモデルとして一般的に使われているen_core_web_smを読み込んでいます.

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

もしエラーが出る場合は,en_core_web_smのモデルがインストールされていないので,

python -m spacy download en_core_web_sm

でインストールしてください.

モデルは英語以外にも用意されています.各言語に対応するモデルは以下の公式ドキュメントを参考にしてください.

Models · spaCy Models Documentation

英文のトークナイズとDocオブジェクト

読み込んだモデルは関数のように扱うことができます.入力は文字列で,出力はspaCy独自のDocオブジェクトになります.

Docオブジェクトの詳細: Doc · spaCy API Documentation

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

sent = "I don't like apple."
doc = nlp(sent)
print(type(doc))
'''
出力:
<class 'spacy.tokens.doc.Doc'>
'''

Docオブジェクトは,トークナイズ後の単語列に関する情報が含まれていて,リストのような振る舞いをします.例えば,0番目を参照すると,先頭単語に関する情報が得られます.よって,一般的なリストのように,indexによる参照と,for文による先頭単語からの参照が可能です.

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

sent = "I don't like apple."
doc = nlp(sent)
# for文で参照
for token in doc:
    print(type(token), token)

# indexで参照
print(doc[0])
'''
出力:
<class 'spacy.tokens.token.Token'> I
<class 'spacy.tokens.token.Token'> do
<class 'spacy.tokens.token.Token'> n't
<class 'spacy.tokens.token.Token'> like
<class 'spacy.tokens.token.Token'> apple
<class 'spacy.tokens.token.Token'> .
I
'''

このように参照したオブジェクトは,print()するとトークナイズ後の単語がそのまま出力されます.上の例では,don't → do n'tapple. → apple .のようにトークナイズされていることが分かります.ただし,型は文字列ではなく,spaCy独自のTokenオブジェクトになります.

よって,単にトークナイズ後の表層系だけ欲しい場合は

import spacy

def tokenizer(nlp, sent):
    doc = nlp(sent)
    return ' '.join([tok.text for tok in doc])

nlp = spacy.load('en_core_web_sm')
sent = "I don't like apple."
print(tokenizer(nlp, sent))
'''
出力:
I do n't like apple .
'''

のように多少自作しなければなりません..textについては次の節で説明します.

Tokenオブジェクトの機能

spaCy独自のTokenオブジェクトは,様々な情報を持っています.単語を表す文字列の情報はもちろん,その単語の品詞や係り受けなどの情報を持ちます.

以下では公式ドキュメントが紹介している代表的な属性を紹介します.

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

sent = "I don't like apple."
doc = nlp(sent)

# 3番目のトークン
token = doc[2]
print('.text:    ', token.text, type(token.text), sep='\t')
print('.lemma_:  ', token.lemma_, type(token.lemma_), sep='\t')
print('.pos_:    ', token.pos_, type(token.pos_), sep='\t')
print('.tag_:    ', token.tag_, type(token.tag_), sep='\t')
print('.dep_:    ', token.dep_, type(token.dep_), sep='\t')
print('.shape:   ', token.shape_, type(token.shape_), sep='\t')
print('.is_alpha:', token.is_alpha, type(token.is_alpha), sep='\t')
print('.is_stop: ', token.is_stop, type(token.is_stop), sep='\t')
'''
出力:
.text:          n't     <class 'str'>
.lemma_:        not     <class 'str'>
.pos_:          ADV     <class 'str'>
.tag_:          RB      <class 'str'>
.dep_:          neg     <class 'str'>
.shape:         x'x     <class 'str'>
.is_alpha:      False   <class 'bool'>
.is_stop:       False   <class 'bool'>
'''
  • .textは,単語そのものです.

  • .lemma_は,単語の原形です.例えば,go, went, goingのlemmaは全てgoです.

  • .pos_は,品詞です.詳しくは.pos_の品詞タグ早見表の節で説明しますが,17種類あります.

  • .tag_ は,より詳細な品詞です.例えば,.pos_では単にNOUN(名詞)と表されるものが,.tag_ではNNS(複数形の名詞)のように表されるようなことです.タグの一覧は以下を参照してください.

    spaCy/tag_map.py at 3ddb799f27578d3eed39ded12fc812609106b26c · explosion/spaCy · GitHub

  • .dep_は,構文の依存関係を表します.詳しくは構文依存関係についての節を参照してください.

  • .shape_は,単語の形を表します.具体的には,各文字について,大文字はX,小文字はx,数字はdに変換して表示します.記号はそのままです.例えば,aA2'!$#&%xXd'!$#&%となります.

  • .is_alphaは,単語がアルファベットだけで構成されているかを判定します.数字や記号が一つでも混じっているとFalseになります.

  • .is_stopは,ストップワードかどうか判定します.英語におけるストップワードの単語集合は,以下を参照してください.(他の言語についても,同じようにソースを見に行けば単語集合を得られます.)

    spaCy/stop_words.py at 3ddb799f27578d3eed39ded12fc812609106b26c · explosion/spaCy · GitHub

これ以外にもたくさんの属性が用意されています.詳しくは以下を参照してください.

Token · spaCy API Documentation

.pos_の品詞タグ早見表

品詞タグは17種類あります.Universal POS tagsを利用しています.

縦長になるので横に並列して書くことにします.

品詞タグ 意味 品詞タグ 意味
ADJ 形容詞 PART 助詞
ADP 設置詞 PRON 代名詞
ADV 副詞 PROPN 固有名詞
AUX 助動詞 PUNCT 句読点
CONJ 接続詞 SCONJ 連結詞
DET 限定詞 SYM シンボル
INTJ 間投詞 VERB 動詞
NOUN 名詞 X その他(主に未知語)
NUM 数詞

参考: Universal POS tags

.dep_: 構文依存関係について

構文依存関係は.dep_で参照できる情報で,係り受けを表すものです.例えば,動詞の目的語を特定できます.

公式ドキュメントでは明確に言及にしている記述が見つけられませんでしたが,おそらくUniversal Dependenciesを用いていると思います.これについては,数が非常に多いため,一つ一つ解説することは避けます.参考1(英語,公式),参考2(日本語,ブログ記事)を参照してください.

参考1: Universal Dependencies

参考2: 自然言語処理におけるPOSタグと係り受けタグ一覧 - Qiita

係り受け解析の可視化

係り受け解析の結果は,spaCyのモジュールdisplacyを使うことで可視化できます.このモジュールは,spaCy2.0以降に実装されています.具体的には,displacy.serve()関数を使います.入力として<class 'spacy.tokens.doc.Doc'>型のトークナイズした直後のオブジェクトを与えると,ローカルサーバの上で図が閲覧できます.

import spacy
from spacy import displacy

nlp = spacy.load('en_core_web_sm')
sent = "I don't like apple."
doc = nlp(sent)
displacy.serve(doc, style="dep")
'''
出力:
Using the 'dep' visualizer
Serving on http://0.0.0.0:5000 ...
'''

これを実行するとServing on http://0.0.0.0:5000 ...と表示されるので,ブラウザでlocalhost:5000にアクセスすると,係り受けの可視化図が閲覧できます.この図には,各単語の係り受け.dep_の情報と,その品詞.pos_の情報が含まれています.

f:id:gotutiyan:20200926184545p:plain

おわりに

今回はspaCyによる英文トークナイズについて紹介しました.思ったよりざっくりとした記事になった気もしますが,参考になれば幸いです.他に面白そうな機能を見つけたら随時更新するかもしれません.

間違いなどあればご指摘お願いいたします.

多クラス分類における混同行列と評価指標を丁寧に

はじめに

本記事では多クラス分類(マルチクラス分類)における評価値の考え方・計算方法について説明します.具体的には,最も重要な混同行列の作成方法から始まり,適合率(Precision),再現率(Recall),F1スコア,マクロ平均,およびマイクロ平均を扱います.

想定読者

  • 混同行列(FPとかTPとか)よく分かんね〜という方
  • 2値分類の混同行列は分かるけど多クラス分類だとイメージ湧かね〜という方

目次

混同行列

多クラス分類における評価指標を理解するには,混同行列を理解することが重要です.むしろ,混同行列さえ理解できれば冒頭で挙げた評価値は全て理解できます.

いきなりですが,混同行列は「真のクラス」を一つ決めて初めて作成できます.例えば,クラスA,Bを予測する2値分類を考えると,「クラスAを真とした混同行列」,「クラスBを真とした混同行列」が作れます.つまり,混同行列はクラスの数だけ作れるのです.この考えは,多クラス分類へ応用する際に非常に重要になります.

ただ多くの場合,混同行列が解説されるときには,暗に真のクラスを決めた後の状況を仮定しています.よって,真のクラスという認識はあまりなく,混同行列がクラスの数だけ作れるということに驚く人もいるかもしれません.

以降は,そういった感覚に基づいて,混同行列の理解を深めます.

TPとかFNとかって何

まずはTPなどの記号の意味をおさらいします.

  • TP: True Positive,真のものを真と予測した(正解)

  • FP: False Positive,偽のものを真と予測した(間違い)

  • TN: True Negative,偽のものを偽と予測した(正解)

  • FN: False Negative,真のものを偽と予測した(間違い)

4つだけなので覚えるのも良いですが,この表記はTrue/FalsePositive/Negativeの組み合わせであることに注目すると,理解しやすいと思います.

True/Falseは,予測が正解かどうかです.正解できたらTrueだし,間違えたらFalseです.

Positive/Negativeは,予測が真かどうかです.予測が真ならPositiveだし,偽ならNegativeです.

これを組み合わせた表記なので,予測結果とその正誤をみれば,4つのうちどれに当てはまるかはすぐに分かると思います.

2値分類における混同行列

まずは2値分類を考えます.2値分類は,2つのクラスがあって,入力がどちらに分類されるのかを推定するものです.代表例として,文章がポジティブ,ネガティブのどちらなのかを推定するタスクが挙げられます.この節ではこの例を使うことにします.

混同行列を作成するためには,「真のクラス」を決める必要があります.一旦,上の例を用いて,真のクラスをポジティブとしてみます.このとき.混同行列は次のように作成できます.

                      本当の正解
                 pos(真)    neg(偽)
モデルの  pos(真)  TP        FP
推定結果  neg(偽)  FN        TN

posはpositive,negはnegativeのつもりです.ポジティブを真と決めたので,ネガティブは偽になります.このように,2値分類では2x2の混同行列になります.これは,一般的な混同行列の説明でよく見る形でもあります.

一方,真のクラスをネガティブにした場合は次のようになります.

                      本当の正解
                 pos(偽)   neg(真)
モデルの  pos(偽)  TN        FN
推定結果  neg(真)  FP        TP

このように,どのクラスを真のクラスと捉えるかで,混同行列は見た目が変わることがわかると思います.ただし,このような混同行列を図示するときには,TPが左上に来るように行や列を入れ替えることが一般的なのかもしれません.今回はあえてそのままにしています.

多クラス分類における混合行列

次に3値分類を考えましょう.3つのクラスがあって,入力がどれに分類されるのかを推定します.例えば,文章をポジティブ,ネガティブ,それからニュートラル(特に感情が無いようなもの)に分類するタスクが考えられます.この節ではこの例を使うことにします.

混同行列を作成するためには「真のクラス」を決めないといけないので,一旦,真のクラスをポジティブとします.このとき,混同行列は次のように作成できます.

                          本当の正解
                 pos(真)   neg(偽)    neu(偽)
モデルの pos(真)  TP        FP         FP
推定結果 neg(偽)  FN        TN         TN
     neu(偽)  FN        TN         TN

neuニュートラルのつもりです.

真のクラスをポジティブと決めたので,それ以外のクラスは全て偽になることに注意してください.

FNFPTNは行列中に複数存在していますが,ラベルの付け方次第で一つの要素にまとめることができます.いま,混同行列の行ラベルや列ラベルは,posのようなクラス名になっています.現在は3値分類を考えているので,クラス名も3種類であり,混同行列も3x3にならざるを得ません.一方で,真偽に注目すると,当然ながら2種類です.よって,真偽に注目して要素をまとめると,混同行列は2x2のサイズに凝縮できます.これはすなわち,FPFNTNを合計して一つの要素にまとめたとも言い換えられます.

真: posに分類される
偽: posに分類されない
           本当の正解
            真   偽
モデルの  真  TP   FP 
推定結果  偽  FN   TN

さて,改めて上記の混同行列は,真のクラスをポジティブと決めたときのものです.これと同じように,真のクラスをネガティブとした場合や,ニュートラルとした場合も混同行列が作れます.少しくどいかもしれませんが,ネガティブの場合もざっくりやってみましょう.

まず,3x3の混同行列は次のようになります.

                 本当の正解
                 pos(偽)   neg(真)    neu(偽)
モデルの  pos(偽)  TN        FN         TN
推定結果  neg(真)  FP        TP         FP
      neu(偽)  TN        FN         TN

これを真偽に注目してまとめると,

真: negに分類される
偽: negに分類されない
           本当の正解
            真   偽
モデルの  真  TP   FP 
推定結果  偽  FN   TN

となって,「真のクラスがネガティブ」バージョンが完成しました.3x3の混同行列を2x2に凝縮できたことが,次に紹介する評価値の計算を行う上で嬉しいポイントです.

まとめると,混合行列は,真とするクラスを決めて初めて作成できるものであり,各クラスに対して作成できるものでした.また,分類候補が何クラスになっても混同行列は作れますし,真偽に注目してまとめると必ず2x2の行列に凝縮できます.

適合率と再現率,F1スコア

評価値の計算方法を紹介します.混同行列さえ作成できれば,計算は一瞬です.評価値は混同行列から計算するので,評価値は各クラスに対して計算できることに注意します.例えば,クラスAの適合率,クラスBの適合率,というように計算できます.

いま,混同行列が次のようにあるとしましょう.

           本当の正解
            真   偽
モデルの  真  TP   FP 
推定結果  偽  FN   TN

このとき,適合率は

 Precision = \frac{TP}{TP+FP}

で計算できます.また,再現率は

 Recall = \frac{TP}{TP+FN}

で計算できます.

適合率は,「モデルが真と予測した中でいくつ正解できた?」という指標です.TPとFPを使うので,(両方Pがついていることからも)分母はモデルが真と予測した数です.

再現率は,「正解が真であるものの中でいくつ正解できた?」という指標です.分母は正解が真であるものの数です.TPとFNの和がなぜそのような解釈になるのかはちょっと難しいですが,「TP: 真だと言ったら正解だった = そのデータは真」,「FN: 偽であると言ったら間違いだった = そのデータは真」というような説明から解釈できそうです.

F1スコアは,適合率と再現率の調和平均です.

 F_1score = \frac{2}{\frac{1}{P}+\frac{1}{R}} = \frac{2PR}{P+R}

適合率と再現率は,トレードオフの関係であることが知られています.適合率を伸ばすためには,できるだけ確信度が高いものだけを真だと予測すればいいですが,そうすると正解の集合を網羅できず,再現率が伸びません.一方,再現率を伸ばすのは簡単で,全部のデータについて問答無用で真と予測すれば100%を達成できます.しかし,多くの偽であるデータに対しても真と予測するので,適合率は伸びません.この2つの指標を良い感じにまとめてくれるのがF1スコアです.

マクロ平均とマイクロ平均

マクロ平均とマイクロ平均は,全クラスを対象にしたときの評価値を計算する方法です.

マクロ平均

マクロ平均は,各クラスの評価値を計算した後に平均する方法です.単純に評価値を足して,クラスの数で割ります.例えば,クラスA,B,Cの3値分類を考えたとき,各クラスの適合率を P_A, P_B, P_Cとすると,マクロ平均は

 P_{macro} = \frac{P_A + P_B + P_C}{3}

で計算できます.再現率やF1スコアについても,同じように計算できます.

マイクロ平均

マイクロ平均は,各クラスの混同行列を,要素ごとに全部足してから評価値を計算します.つまり,TPはTPだけで合計し,FNもFNだけで合計し,TNもFPもそれぞれ合計して一つの混同行列にまとめます.その後,そこから評価値を計算します.例えば,クラスA,B,Cの3値分類を考えたとき,各クラスのTPとFPを TP_A, TP_B, TP_C,および FP_A, FP_B, FP_Cとすると適合率のマイクロ平均は

 P_{micro} = \frac{TP_A+TP_B+TP_C}{(TP_A+TP_B+TP_C) + (FP_A+FP_B+FP_C)}

で計算できます.

ざっくりした違い

マクロ平均とマイクロ平均の大きな違いとして,事例数が少ないクラスに対する頑健さがあります.事例数とはデータの数のことです.例えば,極端な場合として,事例数が1つしかないクラスを考えます.このクラスの評価値は正解すれば1,間違えれば0になるため非常に極端です.マクロ平均はこの影響を強く受けます.一方,マイクロ平均はあまり影響を受けません.評価値の分母分子に1が足されるかどうかという程度です.

pythonでの実装

pythonでの実装では,sklearnのclassification_reportを使うのが便利です.詳しくは以下の記事を参照してください.

gotutiyan.hatenablog.com

【NLTK】NLTKに収録されているコーパスの利用方法

はじめに

本記事ではnltkに収録されているコーパスの利用方法を紹介します.

公式ドキュメント:

www.nltk.org

以下では,まずは収録コーパスを扱うためのメソッドを紹介した後,収録されている主なコーパスの紹介を行います.なお,メソッドの紹介のサンプルコードでは,収録コーパスとしてreutersを使用することにします.

モジュールのインポート

from nltk.corpus import コーパス名

仮にReutersコーパスをインポートしたければ

from nltk.corpus import reuters

と書けます.

メソッド

ファイルIDの一覧を取得

.fileids()でファイルIDの一覧を取得できます.

返り値は <class 'list'>です.

コーパスは一つのファイルに大量に記述されているわけではなくて,多数のファイルに分割されて収録されています.また,各ファイルにはファイルID(=ファイル名)が割り振られているので,その一覧を取得できます.

from nltk.corpus import reuters
fileids = reuters.fileids()
print(fileids[:5])
print('ファイル数:', len(fileids))
'''
出力:
['test/14826', 'test/14828', 'test/14829', 'test/14832', 'test/14833']
ファイル数: 10788
'''

あるファイルIDのコーパスを取得

.raw(ファイルID)で,ファイルIDと対応したコーパスを取り出せます.ファイルIDはリストによる複数指定もできます.

返り値は<class 'str'>です.

from nltk.corpus import reuters
fileid = reuters.fileids()
print(reuters.raw(fileid[0]))
'''
出力:
ASIAN EXPORTERS ...(省略)... the dispute.
'''

カテゴリ一覧を取得

.categories()で,コーパスのカテゴリ一覧を取得できます.

返り値は <class 'list'>です.

カテゴリーはいわゆるトピックです.

from nltk.corpus import reuters
categories = reuters.categories()
print(categories[:5])
print('カテゴリ数:',len(categories))
'''
出力:
['acq', 'alum', 'barley', 'bop', 'carcass']
カテゴリ数: 90
'''

あるファイルIDのカテゴリを取得

.categories(ファイルID)とすることで,ファイルIDに対応するカテゴリを取得できます.ファイルIDはリストによる複数指定もできます.

from nltk.corpus import reuters
fileids = reuters.fileids()
print(reuters.categories(fileids[0]))
'''
出力:
['trade']
'''

あるファイルIDのコーパスの単語リストを取得

.words(ファイルID)で,単語リストを取得できます.ファイルIDはリストによる複数指定もできます.

この単語リストは,単にコーパスを単語分割したものです.よって要素に重複があります.単語集合を得たい場合はset()で変換するなどの工夫が必要です.

from nltk.corpus import reuters
fileids = reuters.fileids()
print(reuters.words(fileids))
'''
出力:
['ASIAN', 'EXPORTERS', 'FEAR', 'DAMAGE', 'FROM', 'U', ...]
'''

また,categories=オプションで,特定のカテゴリに属する単語リストを取得できます.トピックに依存した何かを研究する際に有効です.

以下の例は意図的に出力の範囲をいじっていますが,単語が重複する具体例になっています.

from nltk.corpus import reuters
print(reuters.words(categories=['earn'])[216:228])
'''
出力:
['Trading', 'profit', '63', '.', '4', 'mln', 'vs', '45', '.', '1', 'mln', 'Trading']
'''

また,以下では単語リストを単にset()でくくり,集合に変換しています.結果を見ると,reutersコーパスのearnカテゴリでは,41万単語のうち39万単語ほどは重複していることが分かります.

from nltk.corpus import reuters
words = reuters.words(categories=['earn'])
print(len(words), len(set(words))) # 414011 16914

あるファイルIDのコーパスの文リストを取得

.sents(ファイルID).sents(categories=カテゴリ)で,文リストを取得できます.

from nltk.corpus import reuters
sents = reuters.sents(categories=['earn'])
print(sents[3])
'''
出力:
['Amatil', ',', 'in', 'which', 'B', '.', 'A', '.', 'T', '.']
'''

主な収録コーパス

Gutenberg Corpus

(おそらく文学作品を中心とした)文献の一部が利用できる英語コーパスです.著作権切れの文献を電子化する「プロジェクト・グーテンベルク」によって収集されたものの一部になります.例えば,シェイクスピアハムレットなんかが収録されています.カテゴリは付与されていないようです.

from nltk.corpus import gutenberg
print('ファイル数:',len(gutenberg.fileids()))
print('単語数:', len(gutenberg.words()))
print('文数:', len(gutenberg.sents()))
'''
ファイル数: 18
単語数: 2621613
文数: 98552
'''

Web and Chat Text

あまり整理されていない,ちょっと雑な文章が集められた英語コーパスです.入手元として,FireFoxのフォーラム,ニューヨークで聞こえてきた会話,ワインのレビューなどが挙げられています.カテゴリは付与されていません.

from nltk.corpus import webtext
print('ファイル数:',len(webtext.fileids()))
print('単語数:', len(webtext.words()))
print('文数:', len(webtext.sents()))
'''
ファイル数: 6
単語数: 396733
文数: 25728
'''

Brown Corpus

ブラウン大学による英語のコーパスです.文書にはカテゴリが付与されています.主なカテゴリの説明については以下が参考になります.

https://www.nltk.org/book/ch02.html#tab-brown-sources

from nltk.corpus import brown
print('ファイル数:',len(brown.fileids()))
print('カテゴリ数:',len(brown.categories()))
print('単語数:', len(brown.words()))
print('文数:', len(brown.sents()))
'''
ファイル数: 500
カテゴリ数: 15
単語数: 1161192
文数: 57340
'''

Reuters Corpus

ロイターのニュース記事コーパスです.trainとtestのファイルに分けられていて,ファイルIDを見ればどちらなのか分かるようになっています.

ニュース記事なのでタイトルと本文が含まれますが,タイトルを示す文字列は全て大文字で保存されています.

from nltk.corpus import reuters
print('ファイル数:',len(reuters.fileids()))
print('カテゴリ数:',len(reuters.categories()))
print('単語数:', len(reuters.words()))
print('文数:', len(reuters.sents()))
'''
ファイル数: 10788
カテゴリ数: 90
単語数: 1720901
文数: 54716
'''

Inaugural Address Corpus

歴代アメリカ大統領の演説コーパスです.ファイルIDは西暦-大統領名になっています.カテゴリの付与はありません.

from nltk.corpus import inaugural
print('ファイル数:',len(inaugural.fileids()))
print('カテゴリ数:',len(inaugural.categories()))
print('単語数:', len(inaugural.words()))
print('文数:', len(inaugural.sents()))
'''
ファイル数: 58
単語数: 149797
文数: 5050
'''

様々なアノテーション済みコーパス

上記で紹介したコーパス以外にも,NLTKは様々なコーパスを収録しています.

詳しくは以下のリンクを参照ください.

http://www.nltk.org/nltk_data/

【sklearn】TfidfVectorizerの使い方を丁寧に

はじめに

本記事では[sklearn.feature_extraction.text.TfidfVectorizerについて丁寧に説明します.

公式ドキュメント:

scikit-learn.org

tfとidf

  • tf

Term Frequencyで,単語の出現頻度です.文書 d_jにおける単語 w_iのtfは

 tf_{w_i, d_j} = \frac{w_iの出現数}{d_jの単語数}

です.文書群が決まっているとき,tfは文書と単語を指定して初めて計算できます.

「文書中により高頻度で出現する単語ほど,その文書にとって重要だ」という考えです.

  • idf

Inverse Document Frequencyで,逆文書頻度です.idfの計算方法は,+1するなどのケアによって複数存在しますが,TfidfVectorizerで使われているものを紹介します.文書群における単語 w_iのidfは

 idf_{w_i} = \log \frac{文書数 + 1}{w_iが出現する文書数 + 1} + 1

です.文書群が決まっているとき,idfは単語さえ決めれば計算できます.

「特定の文書に出現する単語ほど,ある話題に特化した意味のある単語である」という考えです.

  • tf-idf

tf-idfは,上記2つの値の積で表されます.

TfidfVectorizerの役割

TfidfVectorizerは,文書群を与えると,各文書をtf-Idfの値を元にしたベクトルに変換するものです.

TfidfVectorizerの入出力

  • 入力

文字列のリストです.1つの文字列が1つの文書に相当します.

例:

['I go to the park .',
 'I will go shopping .']

この例は,以降でも使うことにします.

  • 出力

2次元の行列が返ります.正確にはscipyのオブジェクトで,shapeは(文書数, 語彙サイズ)です.各文書の存在する単語がTfidfに置き換わったようなリストです.

例:

#['go', 'park', 'shopping', 'the', 'to', 'will']  -> 対応する単語
[[0.37  0.53    0.          0.53   0.53  0.    ]  # -> 文書1のベクトル
 [0.44  0.      0.63        0.     0.    0.63  ]] # -> 文書2のベクトル

(上記の例は分かりやすさのため変形しているので,実際の出力とは異なります)

TfidfVectorizerの宣言

モジュールをインポートして,インスタンスを作るだけです.

from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer()

主なメソッド・属性

fit()

入力の文書群を与えて,語彙の獲得やidfの計算をします.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park .',
          'I will go shopping .']
vectorizer = TfidfVectorizer()
vectorizer.fit(corpus)

transform()

fit()したことで得た語彙やidfを元に,文書をtf-idf行列に変換します.

以下の例では,返り値はscipyのオブジェクトで,shapeは(2,6) = (文書数, 語彙サイズ)であることが分かります.

返り値をそのまま出力すると,(行, 列) その要素の値の形式で出力されます.例えば,(0, 4) 0.534046329052269であれば,04列の要素が0.534046329052269と読めます. また,tf-idfの値が0でない要素のみ出力されます.

pythonのリストのように出力したい場合は,.toarray()で変形してから出力します.この場合,tf-idfが0の要素も表示されます.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park .',
          'I will go shopping .']
vectorizer = TfidfVectorizer()
vectorizer.fit(corpus)
X = vectorizer.transform(corpus)
print('type ->',type(X))
print('shape ->',X.shape)
print(X)
print(X.toarray())
'''
出力:
type -> <class 'scipy.sparse.csr.csr_matrix'>
shape -> (2, 6)
  (0, 4)        0.534046329052269
  (0, 3)        0.534046329052269
  (0, 1)        0.534046329052269
  (0, 0)        0.37997836159100784
  (1, 5)        0.6316672017376245
  (1, 2)        0.6316672017376245
  (1, 0)        0.4494364165239821
[[0.37997836 0.53404633 0.         0.53404633 0.53404633 0.        ]
 [0.44943642 0.         0.6316672  0.         0.         0.6316672 ]]
'''

fit_transform()

入力が文書群,出力がtf-idf行列です.

fit()transform()を同時に行います.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park .',
          'I will go shopping .']
vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(corpus)
print(X.toarray())
'''
[[0.37997836 0.53404633 0.         0.53404633 0.53404633 0.        ]
 [0.44943642 0.         0.6316672  0.         0.         0.6316672 ]]
'''

get_feature_names()

特徴量ラベル(=語彙)を表示します.transform()した後の行列では語彙サイズが列数になりますが,どの列がどの単語なのかを知る場合には,この関数を使います.

表示される単語は辞書順になります.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park .',
          'I will go shopping .']
vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(corpus)
print(vectorizer.get_feature_names())
print(X.toarray())
'''
['go', 'park', 'shopping', 'the', 'to', 'will']
[[0.37997836 0.53404633 0.         0.53404633 0.53404633 0.        ]
 [0.44943642 0.         0.6316672  0.         0.         0.6316672 ]]
'''

1つめの文書におけるgoのtf-idfは0.37997836のようです.

inverse_transform()

入力がtf-idf行列,出力が各文書の単語集合です(データ型としてはリストです).

tf-idfの行列から元の文書を復元します.単語の順番は保存されず,単語の集合が得られるような形になります.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park .',
          'I will go shopping .']
vectorizer = TfidfVectorizer()
# tf-idfを得て
X = vectorizer.fit_transform(corpus)
# 元に戻してみる
print(vectorizer.inverse_transform(X))
'''
出力
[array(['go', 'park', 'the', 'to'], dtype='<U8'), array(['go', 'shopping', 'will'], dtype='<U8')]
'''

idf_

idfだけを出力できます.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park .',
          'I will go shopping .']
vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(corpus)

print(vectorizer.get_feature_names())
print(vectorizer.idf_)
'''
出力
['go', 'park', 'shopping', 'the', 'to', 'will']
[1.         1.40546511 1.40546511 1.40546511 1.40546511 1.40546511]
'''
# log(2/1)+1 = 1.4054..

vocabulary_

[単語]=単語idとなる辞書を出力します.単語idを添字だと思ってリストに直すと,get_feture_names()と同じになります.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park .',
          'I will go shopping .']
vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(corpus)
print(vectorizer.vocabulary_)
'''
出力:
{'go': 0, 'to': 4, 'the': 3, 'park': 1, 'will': 5, 'shopping': 2}
'''

主なオプション

smooth_idf=

bool値,デフォルトTrue

idfの計算方法に関するオプションです.Trueであれば

 idf_{w_i} = \log \frac{文書数 + 1}{w_iが出現する文書数 + 1} + 1

で計算されます.Falseであれば

 idf_{w_i} = \log \frac{文書数}{w_iが出現する文書数} + 1

で計算されます.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park .',
          'I will go shopping .']
vectorizer_t = TfidfVectorizer(smooth_idf=True)
X = vectorizer_t.fit_transform(corpus)
print(vectorizer_t.get_feature_names())
print(vectorizer_t.idf_) # Trueのとき

vectorizer_f = TfidfVectorizer(smooth_idf=False)
X = vectorizer_f.fit_transform(corpus)
print(vectorizer_f.idf_) # Falseのとき
'''
出力
['go', 'park', 'shopping', 'the', 'to', 'will']
[1.         1.40546511 1.40546511 1.40546511 1.40546511 1.40546511]
[1.         1.69314718 1.69314718 1.69314718 1.69314718 1.69314718]
'''
# log(2/1)+1 = 1.4054..
# log(3/2)+1 = 1.6931..

analyzer=

文字列,デフォルトword

tf-idfの計算における単語の単位を指定します.wordだと単語単位で,charだと文字単位で特徴量が生成されます.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park .',
          'I will go shopping .']
vectorizer = TfidfVectorizer(analyzer='char')
X = vectorizer.fit_transform(corpus)
print(vectorizer.get_feature_names())
'''
出力
[' ', '.', 'a', 'e', 'g', 'h', 'i', 'k', 'l', 'n', 'o', 'p', 'r', 's', 't', 'w']
'''

ngram_range=

2次元リスト(タプル),デフォルト(1,1)

特徴量として加えるngramの範囲を(下限, 上限)で指定できます.デフォルトは(1,1)であるため,(analyzer='word'なら) 1単語ずつtf-idfに変換されます.(1,2)なら,各単語に加えてbi-gramも考慮されることになります.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park .',
          'I will go shopping .']
vectorizer = TfidfVectorizer(ngram_range=(1,2))
X = vectorizer.fit_transform(corpus)
print(vectorizer.get_feature_names())
'''
出力
['go', 'go shopping', 'go to', 'park', 'shopping', 'the', 'the park', 'to', 'to the', 'will', 'will go']
'''

max_features=

整数,デフォルトNone

特徴量数の上限を指定できます.本来,変換後のtf-idfのshapeは(文書数, 語彙サイズ)ですが,これを指定すると(文書数, min(max_features, 語彙サイズ))となります.このとき,コーパス中の出現数が多い単語が優先的に特徴量になるようです.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park .',
          'I will go shopping .']
vectorizer = TfidfVectorizer(max_features=4)
X = vectorizer.fit_transform(corpus)
print(vectorizer.get_feature_names())
'''
['go', 'park', 'shopping', 'the']
'''

vocabulary=

文字列のリスト,デフォルトNone

語彙を与えることができます.Noneであれば,語彙はコーパスから自動で獲得されます.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park .',
          'I will go shopping .']
vectorizer = TfidfVectorizer(vocabulary=['go', 'park'])
X = vectorizer.fit_transform(corpus)
print(vectorizer.get_feature_names())
print(X.toarray())
'''
['go', 'park']
[[0.57973867 0.81480247]
 [1.         0.        ]]
'''

binary=

bool,デフォルトFalse

Trueでは,tfを求めるときの分子が0(単語が出現しない),または1(単語が出現した)のバイナリになります.つまり,ある文書に同じ単語が複数回出現してもあまり意味を持たないようになります.

出力のtf-idf行列がバイナリになるわけではありません(それはただのbag of wordsですし...).

from sklearn.feature_extraction.text import TfidfVectorizer
# わざとgoを2回出現させた
corpus = ['I go to the park . go']
vectorizer_t = TfidfVectorizer(binary=True)
X = vectorizer_t.fit_transform(corpus)
print(vectorizer_t.get_feature_names())
print(X.toarray())

vectorizer_f = TfidfVectorizer(binary=False)
X = vectorizer_f.fit_transform(corpus)
print(X.toarray())
'''
出力
['go', 'park', 'the', 'to']
[[0.5 0.5 0.5 0.5]]
[[0.75592895 0.37796447 0.37796447 0.37796447]]
 '''

norm=

文字列,デフォルトl2

文書ベクトルのノルムに関する項目です.

l1だとL1正則(ベクトル要素の絶対値和が1),l2だとL2正則(ベクトル要素の二乗和が1)が適用されます.l2の場合,2つの文書ベクトルのコサイン類似度と内積が一致します.

from sklearn.feature_extraction.text import TfidfVectorizer
corpus = ['I go to the park . go']
vectorizer_l1 = TfidfVectorizer(norm='l1')
X = vectorizer_l1.fit_transform(corpus)
print(vectorizer_l1.get_feature_names())
print('l1:',X.toarray())

vectorizer_l2 = TfidfVectorizer(norm='l2')
X = vectorizer_l2.fit_transform(corpus)
print('l2:',X.toarray())
'''
['go', 'park', 'the', 'to']
l1: [[0.4 0.2 0.2 0.2]]
l2: [[0.75592895 0.37796447 0.37796447 0.37796447]]
'''

【sklearn】Classification_reportの使い方を丁寧に

はじめに

本記事ではsklearn.metrics.classification_reportについて丁寧に説明します.

公式ドキュメント:

scikit-learn.org

classification_reportの役割

classification_reportは,正解ラベル列と予測ラベル列を入力すると,適合率(precision),再現率(recall),F1スコア,正解率(accuracy),マクロ平均,マイクロ平均を算出してくれる優れものです.

分類タスクの評価に有効で,二値分類だけでなく,マルチクラス分類の評価も可能です.

classification_reportの入出力

  • 入力

 2つの一次元リストです.1つめが正解ラベル列,2つめがモデルの予測ラベル列です.

例:[0, 1, 1, 0]['pos', 'neg', 'neg', 'pos']など

  • 出力

文字列です.各評価値が良い感じに表示されるようなフォーマットになっています.

classification_reportの宣言

特に宣言は必要なくて,モジュールをインポートするだけです.

from sklearn.metrics import classification_report

出力の説明

入力は公式ドキュメントのものを借りてくるとします.入力のリストの要素は0~2なので,3値分類だと考えることができます.

from sklearn.metrics import classification_report
y_true = [0, 1, 2, 2, 2]
y_pred = [0, 0, 2, 2, 1]
print(classification_report(y_true, y_pred))

出力は以下のようになります.

              precision    recall  f1-score   support

           0       0.50      1.00      0.67         1
           1       0.00      0.00      0.00         1
           2       1.00      0.67      0.80         3

    accuracy                           0.60         5
   macro avg       0.50      0.56      0.49         5
weighted avg       0.70      0.60      0.61         5

出力は表になっていて,各ラベルに対する評価値を確認できます.基本的に横向きに見ればよくて,例えば0の行を見ると,ラベル0のprecisionは0.5で,recallは1.00でーということが分かります.

列ラベルの説明

  • precision

適合率です.モデルが真と予測した数を分母,その中で実際に正解した数を分子にした値です.

 precision = \frac{TP}{TP+FP}

  • recall

再現率です.正解データ中の真の数を分母,その中でモデルが正解した数を分子にした値です.

 recall = \frac{TP}{TP+FN}

  • f1-score

F値です.precisionとrecallの調和平均です.

 f_{1}score = \frac{2\times precision \times recall}{precision + recall}

  • support

正解データに含まれている個数です.

行ラベルの説明

  • 0,1,2

ラベルそのものです.入力によってここは変化します.

  • accuracy

正解率です.事例数を分母,その中でモデルが正解した数を分子にした時の値です.

 accuracy = \frac{TP+TN}{TP+FP+TN+FN}

マクロ平均です.各ラベルの評価値を合計して,単にラベルの種類数で割って平均を取ります.例えば,ラベル0,1,2のprecisionを P_0, P_1, P_2とすると,precisionのマクロ平均は

 \frac{P_0 + P_1 + P_2}{3}

で求められます.

重み付き平均です.各ラベルの評価値とそのラベル数(=support)の積を合計して,事例数で割ります.例えば,ラベル0,1,2のprecisionを P_0, P_1, P_2,ラベルの個数を n_0, n_1, n_2とすると,precisionの重み付き平均は

 \frac{P_0n_0 + P_1n_1 + P_2n_2}{n_0+n_1+n_2}

で求められます.

オプション

target_names=

ラベル名を指定できます.普通,機械学習にかけるときには,正解ラベルはIDに変換されていると思うので,それを元に戻すようなことができます.target_names=には一次元リストを与えます.IDを添字とみなして,その要素に置き変える形です.

from sklearn.metrics import classification_report

y_true = [0, 1, 2, 2, 2]
y_pred = [0, 0, 2, 2, 1]
names = ['positive', 'negative', 'neutral']
print(classification_report(y_true, y_pred, target_names=names))
'''
              precision    recall  f1-score   support

    positive       0.50      1.00      0.67         1
    negative       0.00      0.00      0.00         1
     neutral       1.00      0.67      0.80         3
以下略
'''

labels=

どのラベルを評価するか指定できます.デフォルトでは,正解データに存在する全てのラベルが評価されますが,評価するラベルを制限したい場合や,正解データに含まれないが本当は存在するラベルを仮定して評価する際に有効です.labels=に評価したいラベルの一次元リストを与えることで評価値を算出できます.

from sklearn.metrics import classification_report

y_true = [0, 1, 2, 2, 2]
y_pred = [0, 0, 2, 2, 1]
print(classification_report(y_true, y_pred, labels=[0,1,2,3]))
'''
              precision    recall  f1-score   support

           0       0.50      1.00      0.67         1
           1       0.00      0.00      0.00         1
           2       1.00      0.67      0.80         3
           3       0.00      0.00      0.00         0
以下略
'''

正解データに含まれないラベル3もあることを明示的に指定できており,評価値も出力されています.

sample_weight=

各事例(サンプル)に対する重みを指定できます.ラベルに対する重みではないことに注意してください.sample_weight=に重みを並べた一次元リストを与えます.

以下の例では,先頭のデータの重みは1で,それ以外のデータの重みは0としているため,結局,先頭要素の評価だけが全体の評価に影響します.

from sklearn.metrics import classification_report

y_true = [0, 1, 2, 2, 2]
y_pred = [0, 0, 2, 2, 1]
weight = [1, 0, 0, 0, 0]
print(classification_report(y_true, y_pred, sample_weight=weight))
'''
              precision    recall  f1-score   support

           0       1.00      1.00      1.00       1.0
           1       0.00      0.00      0.00       0.0
           2       0.00      0.00      0.00       0.0

    accuracy                           1.00       1.0
   macro avg       0.33      0.33      0.33       1.0
weighted avg       1.00      1.00      1.00       1.0
'''

digits=

小数点以下を何桁まで表示するかを整数で指定できます.デフォルトは2です.

from sklearn.metrics import classification_report

y_true = [0, 1, 2, 2, 2]
y_pred = [0, 0, 2, 2, 1]
print(classification_report(y_true, y_pred, digits=4))
'''
              precision    recall  f1-score   support

           0     0.5000    1.0000    0.6667         1
           1     0.0000    0.0000    0.0000         1
           2     1.0000    0.6667    0.8000         3

    accuracy                         0.6000         5
   macro avg     0.5000    0.5556    0.4889         5
weighted avg     0.7000    0.6000    0.6133         5
'''

output_dict=

評価値を辞書で返します.このオプションはbool値です.デフォルトはFalseになっているため,結果は文字列で得られます.単に評価値を目視するだけなら文字列でも構いませんが,その先も評価値を用いて何かするプログラムが続いている場合,直接評価値を参照できたほうが便利です.

返り値の辞書は,辞書名[行名][列名]という形で参照できます.

from sklearn.metrics import classification_report

y_true = [0, 1, 2, 2, 2]
y_pred = [0, 0, 2, 2, 1]
eval_dict = classification_report(y_true, y_pred, output_dict=True)
print(eval_dict['0']['precision']) # 0.5
print(eval_dict['macro avg']['f1-score']) # 0.48888888888888893

micro avgが表示される条件

micro avgはマイクロ平均です.

色々入力を変えていると,出力にmicro avgが出力される時とaccuracyが表示されることがあります.

micro avgが表示されるときは,

  1. オプションとしてlabels=が指定されていること

  2. 正解・推定データに含まれるラベル種類と,labels=で指定したラベル種類が不一致

の2つの条件を同時に満たすときです.どちらか一方でも満たさない場合,accuracyが表示されます.

というのは,条件を満たさない場合は,f1スコアのマイクロ平均と正解率が一致します.このことから,micro avgを計算する必要はなく,単にaccuracyが表示されます.

from sklearn.metrics import classification_report
# 0,1,2が全て含まれている入力
y_true = [0, 1, 2, 2, 2]
y_pred = [0, 0, 2, 2, 1]
print(classification_report(y_true, y_pred, labels=[0,1,2]))
'''
(中略)
    accuracy                           0.60         5
   macro avg       0.50      0.56      0.49         5
weighted avg       0.70      0.60      0.61         5
'''
# accuracyが表示される

一方で,2.の条件を満たす場合は,f1スコアのマイクロ平均と正解率は一致しません.よって,micro avgを得るためには真面目に計算するしかなく,micro avgが表示されます.

from sklearn.metrics import classification_report
# ラベル1が含まれていない入力
y_true = [0, 0, 2, 2, 2]
y_pred = [0, 0, 2, 2, 0]
print(classification_report(y_true, y_pred, labels=[0,1,2]))
'''
(中略)
   micro avg       0.80      0.80      0.80         5
   macro avg       0.56      0.56      0.53         5
weighted avg       0.87      0.80      0.80         5

'''
# micro avgが表示される

【sklearn】LabelEncoderの使い方を丁寧に

はじめに

本記事ではsklearn.preprocessing.LabelEncoder()について丁寧に説明します.

公式ドキュメント:

scikit-learn.org

LabelEncoderの役割

LabelEncoder()は,文字列や数値で表されたラベルを,0~(ラベル種類数-1)までの数値に変換してくれるものです.機械学習で分類系のタスクを扱う場合,正解のラベルが文字列で表されることはよくあります.このようなとき,LabelEncoder()を使うと簡単に数値に変換できるという感じです.

LabelEncoderの基本的な入出力

エンコーダを想定した入出力です.

  • 入力

入力は,各要素がラベルであるような一次元リストです.データ型はpythonの生のリストはもちろん,numpyの'numpy.ndarray',pandasのpandas.core.series.Series も受け付けます.リストの各要素は文字列でも良いですし,数値でも良いです.

例:['positive', 'negative', 'positive']

  • 出力

出力は,入力の各ラベルが0~(ラベル種類数-1)の数値に変換された一次元リストです.

例:[0, 1, 0]

例では,positive0negative1が対応していることが分かります.以降,本記事では入力側をラベル,出力側をラベルIDと呼ぶことにします.

LabelEncoderの宣言

宣言は簡単です.特にオプションも必要ありません.

from sklearn import preprocessing
le = preprocessing.LabelEncoder()

fit()

まずは,ラベルとラベルIDの対応づけを行います.positiveは0にしよう,みたいなことを決めるのです.これはle.fit(ラベルの一次元リスト)で行います.

from sklearn import preprocessing
le = preprocessing.LabelEncoder()

labels = ['positive', 'negative', 'positive']
le.fit(labels)

transform() (ラベル→ラベルID)

ラベルとラベルIDの対応づけができれば,それに従って変換することができます.これはtransform(ラベルの一次元リスト)で行います.fitはle.fit()を書くだけですが,transformはhoge = le.transform()のように返り値を保存する必要があります.

from sklearn import preprocessing
le = preprocessing.LabelEncoder()

labels = ['positive', 'negative', 'positive']
le.fit(labels)
labels_id = le.transform(labels)
print(labels_id) # [1 0 1]

これで変換が完了しました.

fit_transform()

fit()transform()を一気に行う場合,fit_transform()を使うと楽です.

入力がラベルの一次元リスト,出力がラベルIDの一次元リストになっています.

from sklearn import preprocessing
le = preprocessing.LabelEncoder()

labels = ['positive', 'negative', 'positive']
labels_id = le.fit_transform(labels)
print(labels_id) # [1 0 1]

inverse_transform() (ラベルID→ラベル)

transformがエンコードだとすると,これはデコードする処理になります.

入力がラベルIDの一次元リスト,出力がラベルのリストです.

あくまでもエンコードができないとデコードもできないため,必ずfit()した後に行います.

from sklearn import preprocessing
le = preprocessing.LabelEncoder()

labels = ['positive', 'negative', 'positive']
# とりあえずfitしてから
le.fit(labels)
ids = [1, 1, 0]
# デコード
decoded_labels = le.inverse_transform(ids)
print(ids, '->', decoded_labels)
# [1, 1, 0] -> ['positive' 'positive' 'negative']

classes_(どのラベルがどのIDなのかを取得)

le.classes_で, fit()によって各ラベルがどのラベルIDと対応づけられたのかを取得できます.

le.classes_は一次元リストになっていて,各要素について,値がラベル,その添字がラベルIDと解釈できます.例えば['negative' 'positive']という一次元リストであれば,negativeの添字は0なので,negative0が対応していることが分かります.

from sklearn import preprocessing
le = preprocessing.LabelEncoder()

labels = ['positive', 'negative', 'positive']
le.fit(labels)
print(le.classes_)
# ['negative' 'positive']

使い所としては,評価系のライブラリを使った時に,オプションとしてラベル名を渡すような場面が考えられます.例えば,sklearnのclassification_reporttarget_names=引数みたいなものです.

2次元以上のラベル列をエンコードする時

こういう場合があるか分かりませんが,2次元リストの形式で保存されたラベル列をIDに変換したいときの話です.これは間違いの方法ですが,とりあえず,for文で一つずつ処理することを考えます.

# !!! バグが発生しうる例 !!!
from sklearn import preprocessing
le = preprocessing.LabelEncoder()

labels_list = [['positive', 'negative'],
               ['negative', 'positive']]
ids_list = [le.fit_transform(labels).tolist() for labels in labels_list]
print(ids_list)
"""
[[1, 0], 
 [0, 1]]
"""

ただし,上の例は偶然うまくいっているものの,少し安直で,バグを生みます.というのは,変換をfit_transform()で行なっているため,変換するたびにfit()もされてしまいます.これにより,ラベルとIDの対応づけが変換のたびに更新されてしまいます.

バグを生む例を作るとこうなります.

# !!! バグがある例 !!!
from sklearn import preprocessing
le = preprocessing.LabelEncoder()

labels_list = [['a', 'b'], 
               ['b', 'c']]
ids_list = [le.fit_transform(labels).tolist() for labels in labels_list]
print(ids_list)
"""
[[0, 1],
 [0, 1]]

('b'が0と1に変換されているし,'b'と'c'が1に変換されている.本当は
[[0, 1],
 [1, 2]] 
であるはず)
"""

というわけで,2次元以上のラベル列を変換する場合,fit()は全てのラベルが含まれるようなリストで一度だけ行いましょう.どのようなラベルが存在するかは事前に分かるはずなので,そのようなリストは準備できるはずです.

from sklearn import preprocessing
le = preprocessing.LabelEncoder()

# 全てのラベルを並べてfit()だけしておく
all_labels = ['a', 'b', 'c']
le.fit(all_labels)

labels_list = [['a', 'b'], ['b', 'c']]
# 変換処理はfit_transform()ではなくて,transform()で
ids_list = [le.transform(labels).tolist() for labels in labels_list]
print(ids_list)
"""
[[0, 1],
 [1, 2]]
"""

【Pytorch】nn.Embeddingの使い方を丁寧に

はじめに

本記事では,Pytorchの埋め込み層を実現するnn.Embedding()について,入門の立ち位置で解説します.

ただし,結局公式ドキュメントが最強なので,まずはこちらを読むのをお勧めします.

pytorch.org

対象読者は,

  • 他のモデルの実装記事見ても,全人類nn.Embeddingをサラッと使ってて何だこれ〜〜って人

  • nn.Embeddingの入出力のイメージが分からない人

  • 公式ドキュメント(英語だし)分からね〜〜という人

目次

nn.Enbeddingの入出力

まずは入出力を確認します.

  • 入力

入力は,単語IDの並びです.例えば,いま,単語とIDの対応が

{'this': 0, 'is': 1, 'a': 2, 'sentence': 3}

だと仮定すると,
['This', 'is', 'a', 'sentence']
という単語列は
[0, 1, 2, 3]
という単語IDの並びに変換できます.このような単語IDの並びが入力です.one-hotにする必要はありません.

  • 出力

出力は,各単語の埋め込みベクトルです.入力として[0,1,2,3]を与えた場合,

[[単語ID 0 に対する埋め込みベクトル],
[単語ID 1 に対する埋め込みベクトル],
[単語ID 2 に対する埋め込みベクトル],
[単語ID 3 に対する埋め込みベクトル]]

が出力となります.各単語IDが埋め込みベクトルに変わるため,次元が一つ増えます.shapeとしては,(系列長) -> (系列長, 埋め込み次元)と変化します.詳しくは,あとで具体例で確認します.

なお,ここでは各データ型をpythonのリストのように書いていますが,本当は<class 'torch.Tensor'>である必要があります.

nn.Embeddingの宣言

nn.Embedding()は,基本的に2つの引数をとります.

  • 第一引数: 語彙サイズ(単語IDの最大値+1)

  • 第二引数: 埋め込む次元

例えば,入出力の項で用いた例では,全部で4単語を扱うので語彙サイズは4です.埋め込む次元は,自由に決めることができます.以下では5次元とかにしてみましょう.

import torch
import torch.nn as nn

# 語彙サイズ
vocab_size = 4
# 埋め込む次元
emb_dim = 5
embeddings = nn.Embedding(vocab_size, emb_dim)

とりあえず1単語埋め込む

とりあえず1単語埋め込んでみます.宣言は上記のものを流用するとして,単語ID0の単語だけ埋め込みましょう.単語とIDの対応は人ぞれぞれですが,入出力の項で用いた例ではthisの埋め込みベクトルを得る処理だと思えば良いです.

入力はあくまでも単語IDの並びなので,1単語だけだとしても,リストにする必要があります.また,入出力のところで触れたように,入力のデータ型は<class 'torch.Tensor'>でないといけないので,torch.tensor(リスト)で変換してから入力します.

import torch
import torch.nn as nn

vocab_size = 4
emb_dim = 5
embeddings = nn.Embedding(vocab_size, emb_dim)
# 0番目の単語なので,[0]をTensorに変換
word = torch.tensor([0])
embed_word = embeddings(word)
print(embed_word)
print(word.shape, '->', embed_word.shape)
'''
出力: 
tensor([[-0.5962, -1.2342,  1.1888, -1.1408, -0.3594]],
       grad_fn=<EmbeddingBackward>)
torch.Size([1]) -> torch.Size([1, 5])
'''

ということで,単語ID0の埋め込みベクトルは[-0.5962, -1.2342, 1.1888, -1.1408, -0.3594]だと分かりました.宣言時に埋め込む次元を5にしているので,出力も5次元になっています.

一応shapeを確認すると,(1) -> (1, 5) = (系列長, 埋め込み次元)と変化しているのが分かります.

複数単語を一気に埋め込む

いま,['This', 'is', 'a', 'sentence'] という単語列が,[0, 1, 2, 3] と単語IDの並びに変換されているとします.この時も,1単語の時と全く同じように書けます.

import torch
import torch.nn as nn

vocab_size = 4
emb_dim = 5
embeddings = nn.Embedding(vocab_size, emb_dim)

words = torch.tensor([0, 1, 2, 3])
embed_words = embeddings(words)
print(embed_words)
print(words.shape, '->', embed_words.shape)
'''
出力: 
tensor([[-0.0964,  0.0113,  0.5742,  0.7339, -1.9287],
        [ 0.8564,  1.8212, -0.6291,  0.4318,  1.5869],
        [ 0.2528, -0.3460,  0.0923, -0.7709, -0.6723],
        [ 1.7714,  0.0655, -0.6220, -0.3896, -0.5604]],
       grad_fn=<EmbeddingBackward>)
torch.Size([4]) -> torch.Size([4, 5])
'''

このように,複数単語も一気に計算できました.shapeが(4, 5) = (系列長, 埋め込み次元)と変化していることも確認しておきます.

ミニバッチ化してたらどうなるの

学習時にミニバッチ化している場合,次元が一つ増えます.具体的には,バッチサイズが2だとすると

[['this', 'is', 'a', 'sentence'],
 ['is', 'this', 'a', 'sentence']]

のようなデータが1回の入力になるわけです.単語IDには

[[0, 1, 2, 3],
 [1, 0, 2, 3]]

みたいに変換できるでしょう.このような場合も,同じように埋め込むことができます.

import torch
import torch.nn as nn

vocab_size = 4
emb_dim = 5
embeddings = nn.Embedding(vocab_size, emb_dim)
sents = torch.tensor([[0, 1, 2, 3],
                     [1, 0, 2, 3]])
embed_sents = embeddings(sents)
print(embed_sents)
print(sents.shape, '->', embed_sents.shape)

'''
出力: 
tensor([[[-1.2136, -2.5102, -0.1156, -0.1305, -0.0215],
         [ 2.7627, -0.6553, -0.2072,  0.7654, -2.3114],
         [-1.7942, -1.3689,  0.1742, -0.4785,  0.0510],
         [ 0.4606,  0.7227, -1.6526, -1.4224, -0.8632]],

        [[ 2.7627, -0.6553, -0.2072,  0.7654, -2.3114],
         [-1.2136, -2.5102, -0.1156, -0.1305, -0.0215],
         [-1.7942, -1.3689,  0.1742, -0.4785,  0.0510],
         [ 0.4606,  0.7227, -1.6526, -1.4224, -0.8632]]],
       grad_fn=<EmbeddingBackward>)
torch.Size([2, 4]) -> torch.Size([2, 4, 5])
'''

やはり,各単語IDがしっかり埋め込まれています.また,同じ単語IDは同じ埋め込みベクトルになっていることも確認できます.さらに,変換後の次元は(2, 4, 5) = (バッチサイズ, 系列長, 埋め込み次元)となっていることも分かります.本記事のテーマとは関係ありませんが,この形はいわゆるLSTMなどのRNNにおいて,batch_first=Trueとした時の入力で要求される形そのものです.

padding

同一バッチ内での系列長とpadding

一つ前でミニバッチでの例を示しましたが,これはバッチ内のデータの系列長が揃っているという意味で特殊な例です.2文とも4単語で揃っています.実データではそんなことは稀で,文によって単語数は異なるでしょう.nn.Embeddingは,同一バッチ内で系列長が異なるとエラーを吐くので,系列長を揃えるためのpadding周りも紹介します.

paddingは,系列長が足りない部分を何かしらの値で埋めることです.引き続き,具体例として入出力の項で用いた例を使います.いま,入力の単語を1始まりの単語IDで置き換えて,paddingは0で行うことにしましょう.また,単語とIDの対応は
{'this': 1, 'is': 2, 'a': 3, 'sentence': 4}
と仮定します.また,入力の文を

[['this', 'is', 'a', 'sentence'],
 ['this', 'sentence']]

とします.バッチサイズが2だとすると,バッチ内の系列長が4単語と2単語で異なるため,nn.Embeddingに入力できません.これを解決するため,単語IDに変換してpaddingすると

[[1, 2, 3, 4],
 [1, 4, 0, 0]]

となります.2文目について,足りない2単語を0でpaddingすることで,2つの文の系列長が一致しました.

padding_idxオプション

(一つ前の続き)これでnn.Embedding()の入力とできますが,paddingした0というのはダミーのIDであって,何かしらの単語を表すものではありません.0に対しては埋め込みベクトルも計算して欲しくないので,オプションのpadding_idxを用いて無視するようにします.また,ダミーIDのために単語IDが1つ余分に消費されるため,宣言時の語彙サイズも1つ大きくする必要があります.

import torch
import torch.nn as nn

# 語彙サイズを1つ大きく
vocab_size = 4+1
emb_dim = 5
# padding_idx=0を追加
embeddings = nn.Embedding(vocab_size, emb_dim, padding_idx=0)
sents = torch.tensor([[1, 2, 3, 4],
                     [1, 4, 0, 0]])
embed_sents = embeddings(sents)
print(embed_sents)
print(sents.shape, '->', embed_sents.shape)

'''
出力:
tensor([[[ 0.1721,  0.7526,  0.3652,  0.2402, -0.4727],
         [ 0.3910, -0.0685,  2.2712,  0.3159, -0.6302],
         [-0.1785,  0.4621, -0.5341, -0.8397,  0.6010],
         [-0.4067, -1.0936, -0.5026,  0.2667, -0.1626]],

        [[ 0.1721,  0.7526,  0.3652,  0.2402, -0.4727],
         [-0.4067, -1.0936, -0.5026,  0.2667, -0.1626],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000]]],
       grad_fn=<EmbeddingBackward>)
torch.Size([2, 4]) -> torch.Size([2, 4, 5])
'''

paddingしたところは埋め込みベクトルがゼロベクトルになっていることが分かります.

おわりに

今回はnn.Embeddingを細かめに説明しました.入出力の具体例とshapeを意識して解説したので,参考になれば幸いです.もし誤りなどあれば,ご指摘お願いいたします.

paddingに関する個人的な実験

padding_idxを負にしたら?

padding_idxは負にもできます.この場合,普段のpythonにおける負のindex参照と同じように,「後ろから何番目」として動作します.例えば,vocab_size = 5のもとでpadding_idx = -1とすると,単語IDが4の埋め込みベクトルが計算されなくなります.

import torch
import torch.nn as nn

vocab_size = 4+1
emb_dim = 5
# padding_idxを-1にしてみた
embeddings = nn.Embedding(vocab_size, emb_dim, padding_idx=-1)
sents = torch.tensor([[1, 2, 3, 4],
                     [1, 4, 0, 0]])
embed_sents = embeddings(sents)
print(embed_sents)
print(sents.shape, '->', embed_sents.shape)

'''
出力: 
tensor([[[-0.1468,  1.0873, -1.0831, -0.4471,  1.3967],
         [ 0.3240, -2.7857, -0.6588,  0.4635, -1.5496],
         [ 1.4328,  0.7491, -0.9504, -0.5701, -0.8590],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000]],

        [[-0.1468,  1.0873, -1.0831, -0.4471,  1.3967],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000],
         [ 0.0230, -1.0231,  0.5071, -2.9424, -1.3194],
         [ 0.0230, -1.0231,  0.5071, -2.9424, -1.3194]]],
       grad_fn=<EmbeddingBackward>)
torch.Size([2, 4]) -> torch.Size([2, 4, 5])
'''

paddingするダミーIDを負にしたら?

個人的に,-1でpaddingできたらvocab_sizeを1つ大きくしたりしなくて良いなーと思いましたが,それはできません.そもそもpadding_idx=-1の挙動的に,-1を認識させるのが無理ですね.

import torch
import torch.nn as nn

vocab_size = 4+1
emb_dim = 5
embeddings = nn.Embedding(vocab_size, emb_dim, padding_idx=-1)
# -1でpaddingしてみる
sents = torch.tensor([[1, 2, 3, 4],
                     [1, 4, -1, -1]])
embed_sents = embeddings(sents)
print(embed_sents)
print(sents.shape, '->', embed_sents.shape)

'''
出力:
~~ 色々エラーが出て ~~
RuntimeError: index out of range: Tried to access index -1 out of table with 4 rows. at ../aten/src/TH/generic/THTensorEvenMoreMath.cpp:418
'''