pythonでトピックモデル(LDA)

この前の記事で、scikit-learnのニュース記事のジャンルをロジスティック回帰で予測するというモデルを作ってみました。

参考:scikit-learnのニュースデータをロジスティック回帰で分類

今回はアプローチを変えて、トピックモデルを試してみようと思います。
どちらかというと、20newsのデータセットでもう少し何かやりたいというのが主目的で、
トピックモデルの理論的な説明については今回は省略します。
興味のあるかたへは、講談社から出ている岩田具治先生の、 トピックモデル (機械学習プロフェッショナルシリーズ)
が非常にわかりやすかったのでおすすめです。ページ数も少なめでありがたい。
(数式が多くて書くのが大変なのですがゆくゆくは時系列分析みたいにこのブログでも説明したい。)

さて、pythonでトピックモデルを実装するには gensim を使うのが一般的のようです。
gensim topic modelling for humans
ただ、今回はいつも使っているscikit-learnでやってみました。
(gensimはword2vec等で使ってるのですがscikit-learnに比べると少し苦手。)

scikit-learnでトピックモデルを実装するために読むドキュメントはこちら。
sklearn.decomposition.LatentDirichletAllocation
Topic extraction with Non-negative Matrix Factorization and Latent Dirichlet Allocation

サンプルコードと同じことをしてもしょうがないので、少し工夫をしています。
・サンプルデータのカテゴリーを前回の記事同様に5個に絞る(その代わりそのカテゴリの全データを使用)
・カテゴリーごとに各文章のトピッックを可視化

前置きが長くなりましたが、やってみましょう。
必要ライブラリーのインポートとデータの読み込み


from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation
from sklearn.datasets import fetch_20newsgroups
import matplotlib.pyplot as plt
import numpy as np

remove = ('headers', 'footers', 'quotes')
categorys = [
        "rec.sport.hockey",
        "soc.religion.christian",
        "sci.med",
        "comp.windows.x",
        "talk.politics.mideast",
    ]
twenty_news = fetch_20newsgroups(
                                subset='all',
                                remove=remove,
                                categories=categorys
                            )
X = twenty_news.data

続いて、単語の出現頻度を数え、LDAのモデルを構築して学習します。
トピック数は カテゴリー数と同じ5でも試したのですが、
どうやら6か7にして、あまり重要でない単語を引き受けるトピックを作った方が納得性の高いものになりました。
サンプルコードは20カテゴリーを10トピックでうまく処理できているのに何故だろう?


# 単語の出現頻度データを作成
tf_vectorizer = CountVectorizer(max_df=0.90, min_df=5, stop_words='english')
tf = tf_vectorizer.fit_transform(X)
len(tf_vectorizer.get_feature_names())

# LDAのモデル作成と学習
lda = LatentDirichletAllocation(
                            n_components=7,
                            learning_method='online',
                            max_iter=20
                        )
lda.fit(tf)

それでは、学習した7個のトピックについて、それぞれの頻出語をみてみます。


features = tf_vectorizer.get_feature_names()

for tn in range(7):
    print("topic #"+str(tn))
    row = lda.components_[tn]
    words = ', '.join([features[i] for i in row.argsort()[:-20-1:-1]])
    print(words, "\n")

出力は下記の通りです。(乱数の影響で、モデルの学習をやり直すと結果は変わります。)

topic #0
god, people, think, don, know, just, like, does, say, believe, jesus, church, time, way, did, christ, things, good, christian, question

topic #1
25, 10, 11, 12, 14, 16, 15, 17, 20, 13, 18, 19, 55, 30, la, period, 24, 21, pit, 92

topic #2
armenian, armenians, turkish, people, turkey, armenia, turks, greek, genocide, russian, azerbaijan, government, history, muslim, university, soviet, war, 000, ottoman, killed

topic #3
game, don, said, team, just, didn, hockey, like, know, went, year, time, games, think, got, people, going, did, ll, came

topic #4
israel, jews, jewish, israeli, arab, state, people, world, right, public, arabs, rights, human, war, anti, peace, adl, states, country, palestinian

topic #5
medical, health, disease, cancer, patients, use, new, hiv, doctor, season, good, treatment, years, aids, high, drug, number, time, information, vitamin

topic #6
edu, use, file, window, com, server, program, dos, windows, available, motif, using, version, widget, sun, set, display, mit, x11, information

#1があまり意味のない数値を引き受けてくれていますが、
それ以外は、トピックごとに、宗教や国際的な話題、スポーツに医療に、コンピューターなどの単語が分類されています。

最後に、元の各テキストが、カテゴリーごとに妥当なトピック(話題)を持つと判定さているのか可視化してみてみましょう。
どんな可視化方法が一番わかりやすいか色々試したのですが、カラーマップが比較的良さそうでしたので紹介します。
(このほか箱ひげ図などもそこそこ綺麗に特徴が出ましたが。)


topic_data = lda.transform(tf)
fig = plt.figure(figsize=(6, 25))
for i in range(5):
    ax = fig.add_subplot(6, 1, 1+i)
    im = ax.pcolor(topic_data[twenty_news.target == i], vmax=1, vmin=0)
    fig.colorbar(im)
    # 軸の設定
    ax.set_xticks(np.arange(7) + 0.5, minor=False)
    ax.set_xticklabels(np.arange(7))
    ax.set_title(twenty_news.target_names[i])
plt.show()

出力がこちら。

概ね、カテゴリーごとに別のトピックに分類されているのがみて取れます。

scikit-learnで重回帰分析

今回と次回でpythonで重回帰分析を実行する方法を二つ紹介します。
今回はscikit-learnのLinearRegressionを使う方法です。

ドキュメントはこちら。
sklearn.linear_model.LinearRegression

最初に検証用のダミーデータを作ります。
$x_{i,j}$を -10 ~ 10の一様分布からサンプリングし、次の式で$y_i$を作ります。
$x_{i,3}$の係数が0になっていることから分かる通り、$x_{i,3}$は$y_i$には何の関係もない値です。
また、ノイズとして正規分布に従う乱数加えておきます。
$$
y_i = 5 + 2x_{i,0} -3x_{i,1} + 4x_{i,2} + 0x_{i,3} + \varepsilon_i,\\
\varepsilon_i \sim N(0,1) , \ \ \ i = 0,1,\cdots, 99
$$

サンプルデータを作って保存するコードがこちら。


import numpy as np
X = np.random.uniform(-10, 10, size=(100, 4))
y = 5 + X@[2, -3, 4, 0] + np.random.normal(0, 1, 100)
np.savez("sample_data.npz", X=X, y=y)

早速、回帰分析して回帰係数と定数項ついでに決定係数を求めてみましょう。
(回帰分析の目的が予測モデルを作ることであれば、データを訓練用と評価用に分けるべきなのですが、
今回は回帰係数を求める方法の紹介が主目的なので分けていません。)


import numpy as np
from sklearn.linear_model import LinearRegression

# データの読み込み
npzfile = np.load("sample_data.npz")
X = npzfile["X"]
y = npzfile["y"]

# モデルのインスタンス生成
model = LinearRegression()
#学習
model.fit(X, y)
# LinearRegression(copy_X=True, fit_intercept=True, n_jobs=1, normalize=False)

# 回帰係数
print(model.coef_)
# [ 2.00044153 -2.99255801  3.98231315  0.01708309]

# 切片
print(model.intercept_)
# 4.943464324307898

# 決定係数 R^2
model.score(X, y)
# 0.9988893054003364

各コードの後ろにつけているコメントが僕の環境で実行した時の結果です。
回帰係数も切片もそれぞれほぼ正解に近い値が算出されていますね。

注:サンプルデータを乱数で生成しているので、データ生成からやり直せば結果は変わります。

scikit-learnを使うと、非常に手軽に回帰分析ができることがわかりました。
次回はstatsmodelsで同じことをやってみます。

pickleを使ってpythonのオブジェクトをファイルに保存する

(注)この記事はscikit-learnのモデルをファイルに保存することを念頭に書いていますが、
pickle自体はscikit-learnのモデル以外のものも直列化してファイルに書き出すことができるモジュールです。

以前の記事で、kerasで作成したmodelを保存したり読み込んだりする方法を書きました。
今回はscikit-learnで作ったモデルを保存してみます。
kerasには専用の関数が用意されていたのですが、scikit-learnにはありません。
そのため、他の方法が必要です。
そこでpython標準ライブラリの pickleが使えます。
ドキュメント

利用方法は、ドキュメントのpickle.dumppickle.loadの説明と、一番下の使用例が参考になります。

clfという変数に、学習済みのモデルが格納されているという想定で、保存と読み込みのコード例を紹介します。
また、保存するファイル名は何でも良いのですが、サンプルコードではclf.pickleとします。

まずは保存。


import pickle
with open("clf.pickle", "wb") as f:
    pickle.dump(clf, f)

次に読み込み。


import pickle
with open("clf.pickle", "rb") as f:
    clf = pickle.load(f)

これで、一度学習したモデルを読み込んで、予測に活用することができます。
scikit-learnで学習したモデルを本番運用するならばほぼ必須の技術です。
(pickle以外の方法を使うという手もありますが、何らかの形での保存と読み込みの手段が必要です)

パイプラインのグリッドサーチ

scilit-learnでグリッドサーチする方法買いた以前の記事の、
次の記事として、パイプラインを使ったモデルのグリッドサーチ方法を書く予定だったのを失念していたので紹介します。
(パイプラインそのもの紹介もまだなのでそのうち記事に起こします。)

公式のドキュメントはこちらです。
sklearn.pipeline.Pipeline
sklearn.model_selection.GridSearchCV

単一のモデルのグリッドサーチとの違いは、サーチ対象のパラメータを指定する時、
“変数名”: [変数のリスト]
で指定したところを、
“ステップ名”__”変数名”: [変数のリスト]
と指定するようにするだけです。

例を見た方がわかりやすいので、irisのデータと、
モデルは PCA + ロジスティック回帰 でやってみます。


# 必要なライブラリのインポート
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report

# データの準備
iris = load_iris()
X_train, X_test, y_train, y_test = train_test_split(
            iris.data,
            iris.target,
            test_size=0.2,
            stratify=iris.target,
)

# モデル(パイプライン)の作成
clf = Pipeline(
    [
        ("pca", PCA()),
        ("lr", LogisticRegression())
    ]
)

# 探索するパラメータの設定
# ここのkeyの指定方法が重要
params = {
    "pca__n_components": [2, 3, 4],
    "lr__penalty": ["l1", "l2"],
    "lr__C": [0.01, 0.1, 1, 10, 100]
}

# グリッドサーチ
gs_clf = GridSearchCV(
    clf,
    params,
    cv=5
)
gs_clf.fit(X_train, y_train)

# 最適なパラメーター
print(gs_clf.best_params_)

# 最適なモデルの評価
best_clf = gs_clf.best_estimator_
print(classification_report(y_test, best_clf.predict(X_test)))

# 以下出力結果

{'lr__C': 100, 'lr__penalty': 'l2', 'pca__n_components': 3}
             precision    recall  f1-score   support

          0       1.00      1.00      1.00        10
          1       1.00      1.00      1.00        10
          2       1.00      1.00      1.00        10

avg / total       1.00      1.00      1.00        30

最終的にテストデータでの評価が正解率100%なのは運が良かっただけです。
train_test_splitの結果次第で、実行するたびにbest_params_も評価も変わります。

次元削減とその後の分類機は、まとめて最適かしたいので非常に便利です。

20ニュースグループのテキストデータを読み込んでみる

職場にはテキストデータは大量にあるのですが、自宅での学習で自然言語処理にについて学ぼうとすると途端にデータ不足に悩まされます。
そんなわけで青空文庫からデータを持ってくるような記事をこのブログに書いているのですが、
実はscikit-learnの付属のデータセットにもテキストデータはあります。(ただし英語)

Tfidfや、word2vecを動かしてみるには十分なので、それの取得方法を紹介します。
ドキュメントはこちら。
sklearn.datasets.fetch_20newsgroups

インポートして、引数でsubsetを指定することで訓練データとテストデータを入手できます。未指定だと訓練データのみです。両方一度に入手するためにはsubset="all"を指定する必要があります。
僕は始めはそれを知らず引数なしで実行して、訓練データを2つに分けて使ってました。


from sklearn.datasets import fetch_20newsgroups
twenty_train = fetch_20newsgroups(subset="train") # 引数省略可能。
twenty_test = fetch_20newsgroups(subset="test")
# trainとtestを同時に入手したい時は  subset="all" を指定。

# 件数の確認
print(len(twenty_train.data)) # 11314
print(len(twenty_test.data)) # 7532

初回実行時にダウンロードされ、data_home で指定したパスか、デフォルトでは~/scikit_learn_dataにデータが保存されます。
そのため、1回目だけは時間がかかりますが、2回目からは高速です。

twenty_train と、 twenty_test には辞書型で各情報が入ります。
twenty_train.keys()を実行すると、
dict_keys(['data', 'filenames', 'target_names', 'target', 'DESCR'])
と出ます。

DESCR にデータの説明、 target_namesに各ニュースグループの名前が入っているので、それぞれ一度は見ておくことをお勧めします。


# 出力
['alt.atheism',
 'comp.graphics',
 'comp.os.ms-windows.misc',
 'comp.sys.ibm.pc.hardware',
 'comp.sys.mac.hardware',
 'comp.windows.x',
 'misc.forsale',
 'rec.autos',
 'rec.motorcycles',
 'rec.sport.baseball',
 'rec.sport.hockey',
 'sci.crypt',
 'sci.electronics',
 'sci.med',
 'sci.space',
 'soc.religion.christian',
 'talk.politics.guns',
 'talk.politics.mideast',
 'talk.politics.misc',
 'talk.religion.misc']

普段は、 data に入ってるテキストデータの配列と、正解ラベルであるtargetをつかえばOKだと思います。

このほか、BoWに変換済みの、 fetch_20newsgroups_vectorized というのもありますが、
あまり使う機会はないのではないかなぁと思います。
学習目的であれば、前処理なども自分で経験したほうがいいと思うので。

ちなみに、scikit-learnのチュートリアルには、
このデータセットを用いて、tfidfとナイーブベイズでモデルを作るものがあります。
Working With Text Data
(このページは見ていませんでしたが、)僕も自然言語処理を始めたばかりの頃、全く同じような内容で勉強をスタートしたので、非常に懐かしく思いました。
その時の試したファイルはあるので、そのうちこのブログにもまとめ直します。

t-SNEでDigitsを次元圧縮して可視化してみた

特に意図はないのですが、これまで高次元のデータを次元削減して可視化する時はPCAをよく使っていました。
基本的には線形変換なので、非線形な構造を持ってるデータはうまく特徴を捉えられません。
(それはそれで確認する意味があると思いますが。)

最近は、t-SNEという手法を使っている人が多いようなので、やってみたメモです。
irisだとPCAで十分うまく次元削減できてしまうので、今回はdigitsを使います(8*8の手書き数字画像データ)

t-SNEの論文

t-SNE自体の実装は、scikit-leearnを使います。
ドキュメントはここ


# ライブラリインポート
from sklearn.datasets import load_digits
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt

# データ準備
digits = load_digits()
X = digits.data
y = digits.target

# t-SNEの実行
tsne = TSNE(n_components=2)
X_tsne = tsne.fit_transform(X)

# 可視化
x_max, x_min = X_tsne[:, 0].max() * 1.05, X_tsne[:, 0].min() * 1.05
y_max, y_min = X_tsne[:, 1].max() * 1.05, X_tsne[:, 1].min() * 1.05
fig = plt.figure(figsize=(10, 10))
ax = fig.add_subplot(1, 1, 1, xlim=(x_min, x_max), ylim=(y_min, y_max))
ax.set_title("t-SNE")
for i, target in enumerate(y):
    ax.text(X_tsne[i, 0], X_tsne[i, 1], target)
plt.show()

これを実行して表示される画像がこちらです。

一部、変なところに分類されている数字があったり、1が複数のグループに分かれていたりするところはありますが、
非常に見事に分類できています。
これを好んで使う人がいるのも納得です。
高次元のデータの可視化のツールとして提唱されているだけはあります。

ちなみに、PCAで2次元に圧縮したのがこれ。

t-SNEと全く違う結果になっていますね。
(だからといって、PCAという手法自体が劣るというわけではないので注意です。)

scikit-learnでテキストをBoWやtfidfに変換する時に一文字の単語も学習対象に含める

本当に初めて自然言語処理をやった頃のメモから記事化。

テキストを分かち書きしたあと、BoW(Bag of Words) や tfidfに変換するとき、
scikit-learnを使うと便利です。
sklearn.feature_extraction.text に次の二つのクラスが定義されていて、
それぞれ語彙の学習と BoW /tfidfへの変換を行ってくれます。

CountVectorizer
TfidfVectorizer

ただ、これらのクラスはデフォルトパラメーターに少し癖があり注意していないと一文字の単語を拾ってくれません。
TfidfVectorizer の方を例にやってみましょう。


from sklearn.feature_extraction.text import TfidfVectorizer

model = TfidfVectorizer()
text_list = [
    "すもも も もも も もも の うち",
    "隣 の 客 は よく 柿 食う 客 だ",
]
model.fit(text_list)
print(model.vocabulary_)

これの出力結果が、下記になります。
これらが学習した単語です。
{‘すもも’: 1, ‘もも’: 2, ‘うち’: 0, ‘よく’: 3, ‘食う’: 4}

“も” や “の” が入ってないのはまだ許容範囲としても、
“隣”や”客”がvocabulary_に含まれないのは困ります。

これらは TfidfVectorizer のインスタンスをデフォルトのパラメーターで作ったことに起因します。

modelの内容を表示してみましょう。(jupyterで model とだけ入力して実行した結果)


>>> model
TfidfVectorizer(analyzer='word', binary=False, decode_error='strict',
        dtype=, encoding='utf-8', input='content',
        lowercase=True, max_df=1.0, max_features=None, min_df=1,
        ngram_range=(1, 1), norm='l2', preprocessor=None, smooth_idf=True,
        stop_words=None, strip_accents=None, sublinear_tf=False,
        token_pattern='(?u)\\b\\w\\w+\\b', tokenizer=None, use_idf=True,
        vocabulary=None)

注目するべきはここです。
token_pattern='(?u)\\b\\w\\w+\\b’
\\ は \をエスケープしたものなので、以下の説明中では\\ は \と書きます。
正規表現で、\b は単語の境界 、 \wは、単語構成文字、\w+ は1文字以上の単語構成文字の連続 を意味します。
そのため、 \w\w+ は2文字以上の単語構成文字の連続を意味し、
\b\w\w+\b は “単語の境界、2文字以上の単語構成文字、単語の境界” と続く文字列を単語のパターンとして採用するという指定になります。
そのため、 一文字の単語は学習対象から抜けています。

英語なら a や I が抜けるだけですが日本語では多くの漢字が抜けてしまうので、これは困りますね。

ということで、最初に TfidfVectorizer のオブジェクトを作る時には token_pattern を指定しましょう。
1文字以上を含めたいだけなので、 ‘(?u)\\b\\w+\\b’ にすれば大丈夫です。


from sklearn.feature_extraction.text import TfidfVectorizer

model = TfidfVectorizer(token_pattern='(?u)\\b\\w+\\b')
text_list = [
    "すもも も もも も もも の うち",
    "隣 の 客 は よく 柿 食う 客 だ",
]
model.fit(text_list)
print(model.vocabulary_)

これで出力は下記のようりなり、”隣”も”客”も含まれます。
{‘すもも’: 1, ‘も’: 5, ‘もも’: 6, ‘の’: 3, ‘うち’: 0, ‘隣’: 10, ‘客’: 8, ‘は’: 4, ‘よく’: 7, ‘柿’: 9, ‘食う’: 11, ‘だ’: 2}

これ以外にも “-” (ハイフン) などが単語の境界として設定されていて想定外のところで切られたり、
デフォルトでアルファベットを小文字に統一する設定になっていたり(lowercase=True)と、注意する時に気をつけないといけないことが、結構あります。

norm,smooth_idf,sublinear_tf などの影響でよくある自然言語処理の教科書に載っている数式と実装が違うのも注意ですね。
この辺はまた別の機会にまとめようと思います。

scikit-learnのtrain_test_splitで、訓練データとテストデータのラベルの割合を揃える

自分の場合なのですが、普段の業務で機械学習を行う場合不均衡データを扱うことが非常に多くあります。
ラベルづけされたデータを train_test_split で訓練データとテストデータに分けるとき、
運が悪いと訓練データとテストデータで、ラベルの割合がずいぶん変わってしまうことがありました。


#  全データのラベルの割合は 99:1
df['label'].value_counts()
'''
0    9900
1     100
'''
# データの2割りをテストデータにする
df_train, df_test = train_test_split(df, test_size=0.2)

# 訓練データでは ラベル1 は 0.9625 %
df_train.label.value_counts() / len(df_train)
```
0    0.990375
1    0.009625
```
# テストデータでは ラベル1 は 1.15%
df_test.label.value_counts() / len(df_test)
```
0    0.9885
1    0.0115
```

この例ではまだ許容範囲かなという気もしますが運が悪いとかなりの差が開きます。

そこで、かつてはデータフレームをラベルごとに分けてから個別に訓練用とテスト用に分けて、
それをマージして訓練データとテストデータを作ると言った面倒なことをやっていたことがあります。

その後、 train_test_split のマニュアルを読んでいたら非常に便利な引数があることがわかりました。

stratify に、割合を揃えたい列を指定してあげると、訓練データとテストデータで同じ割合になるように分けてくれます。


#  全データのラベルの割合は 99:1
df['label'].value_counts()
'''
0    9900
1     100
'''
# データの2割をテストデータにする
df_train, df_test = train_test_split(df, test_size=0.2, stratify=df.label)

df_train.label.value_counts() / len(df_train)
```
0    0.99
1    0.01
```
df_test.label.value_counts() / len(df_test)
```
0    0.99
1    0.01
```

綺麗に分かれました。

scikit-learn でグリッドサーチ

機械学習のハイパーパラメーターを決定するとき、グリッドサーチという手法を使うことがあります。
よほど学習時にかかるケース以外では、ほぼ確実に行なっています。

そのとき、scikit-learn の GridSearchCV というクラスを使うことが多いのでその使い方をメモしておきます。
今回は題材として、 digits という手書き数字のデータセットを利用します。


from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

最初にデータを準備します。
# データの読み込み
digits = load_digits()
X = digits.data
y = digits.target
# 訓練データとテストデータに分割
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

次にサーチするパラメーターを指定します。


# グリッドサーチするパラメーターを指定。変数名と値のリストの辞書 、それが複数ある場合はそれらの配列。
param_grid = [
    {
        'C': [1, 10, 100, 1000],
        'kernel': ['linear']
    },
    {
        'C': [0.1, 1, 10, 100, 1000],
        'kernel': ['rbf'],
        'gamma': [0.001, 0.0001, 'auto']
    },
    {
        'C': [0.1, 1, 10, 100, 1000],
        'kernel': ['poly'], 'degree': [2, 3, 4],
        'gamma': [0.001, 0.0001, 'auto']
    },
    {
        'C': [0.1, 1, 10, 100, 1000],
        'kernel':['sigmoid'],
        'gamma': [0.001, 0.0001, 'auto']
    }
]

モデルを作って、グリッドサーチの実行


from sklearn.model_selection import GridSearchCV
from sklearn.svm import SVC

# モデルの準備
model = GridSearchCV(
    SVC(),  # 予測機
    param_grid,  # サーチ対象のパラメーター
    cv=5,  # 交差検証の数
    # このほか、評価指標(scoring) や、パラレル実行するJob数なども指定可能(n_jobs)
)
# グリッドサーチの実行
model.fit(X_train, y_train)

最良のパラメーターを確認する


print(model.best_params_)

# 出力
{'C': 1, 'gamma': 0.001, 'kernel': 'rbf'}

最後に、テスト用に取っておいたデータで、出来上がったモデルを評価します。


from sklearn.metrics import classification_report

# 学習したモデルで予測
y_predict = model.predict(X_test)
# 作成したモデルの評価
print(classification_report(y_test, y_predict))

# 出力
             precision    recall  f1-score   support

          0       1.00      1.00      1.00        38
          1       0.97      1.00      0.99        34
          2       1.00      1.00      1.00        38
          3       0.97      1.00      0.99        34
          4       1.00      1.00      1.00        36
          5       1.00      0.97      0.99        35
          6       0.97      0.97      0.97        39
          7       0.97      1.00      0.98        31
          8       0.97      0.97      0.97        38
          9       1.00      0.95      0.97        37

avg / total       0.99      0.99      0.99       360

なかなか良い正解率ですね。