実践編Practice
テキスト分類器をゼロから作る
公開されているニュース記事のコーパスを題材に、生のテキストから出発して、前処理・特徴量化・分類・評価までの一連の流れを自分の手で組み立てます。ナイーブベイズとロジスティック回帰という2つの分類器を同じ土俵で比べ、適合率・再現率・F値・混同行列・交差検証という評価の道具を一通り使います。第2段階で単体で学んだ自然言語処理・前処理・機械学習を、1本のパイプラインとしてつなぐ総合演習です。
この教材は日本語のみで提供していますThis learning material is available in Japanese only.
課題設定The task
ニュース記事を主題ごとに自動で振り分ける
手元にたくさんの文書があり、それぞれがどの話題に属するかを人手で仕分けするのは大変です。そこで、あらかじめ主題のわかっている記事を教師データとして与え、未知の記事の主題を自動で当てるテキスト分類器を作ります。迷惑メールの判定、レビューの肯定否定の判別、問い合わせの自動振り分けなど、実務でも広く使われる基本形です。
題材には、機械学習の教材で古くから使われてきた公開コーパス 20 Newsgroups を使います。1990年代のネットニュース(掲示板)の投稿を、投稿先のグループ(話題)ごとにまとめたデータセットです。今回はそのうち話題の異なる4グループ、alt.atheism(無神論)・comp.graphics(コンピュータグラフィックス)・sci.med(医学)・soc.religion.christian(キリスト教)を取り上げ、記事本文からどのグループの投稿かを当てる4クラス分類に挑みます。
最終的に得られるのは、次の3つです。第一に、生テキストを入れると主題を返す学習済みのパイプライン。第二に、その性能を適合率・再現率・F値・混同行列・交差検証で多面的に測った評価結果。第三に、ナイーブベイズとロジスティック回帰という2つの分類器の比較と、どちらがなぜ良いのかという考察です。
この流れをあえて一続きにするのは、実務の分析が単一の技術ではなく、前処理から評価までのつながりで決まるからです。特徴量の作り方ひとつで精度は変わり、評価をひとつの数字だけで見ると判断を誤ります。各段のねらいと落とし穴を、コードと出力例で確かめていきます。
前提知識Prerequisites
第2段階の「自然言語処理」「機械学習」、第1段階の「前処理」を先に一読しておくと理解がなめらかです。まだでも、必要な考え方はこのページ内で最小限そのつど補います。次の節に、関係するモジュールへのリンクをまとめました。
使う技術Techniques used
3つのモジュールを1本につなぐ
この演習は、第1・第2段階で単体で学んだ次の3つの技術を組み合わせます。まだ学んでいなければ、先にそれぞれのモジュールへ寄り道すると、この後の各ステップの意味がはっきりします。
自然言語処理(NLP)Natural Language Processing
- 役割:テキストの前処理・トークン化と、BoW / TF-IDF によるテキストの数値化。この演習の Step 2・3 の土台です。
- モジュールを開くOpen the module ›
開発データの前処理Data Preprocessing
- 役割:学習・検証データの分割、リークの防止、パイプラインで前処理を学習に閉じ込める考え方。Step 2 で使います。
- モジュールを開くOpen the module ›
機械学習Machine Learning
- 役割:分類器の学習、適合率・再現率・F値・混同行列・交差検証といった評価。Step 4・5 の中心です。
- モジュールを開くOpen the module ›
ノートブックで実行Run it
コードはその場で動かせます
このページに載せるコードは、1本のノートブックにまとめてあります。Google Colab で開けば、環境構築なしにブラウザ上で上から順に実行できます。ダウンロードして手元の Jupyter で動かしても構いません。ページのコードとノートブックの中身は同じです。
practice-text-classifier.ipynb
Colab ならインストール込みでそのまま実行できます。fetch_20newsgroups は初回にデータをネットワーク経由で取得します。scikit-learn は Colab に最初から入っていますが、念のため冒頭でインストールを明示しています。
データを手に入れる
Get the dataこの段でやることGoal
まずは題材のコーパスを読み込みます。scikit-learn には fetch_20newsgroups という関数が用意されていて、20 Newsgroups を自動でダウンロードして返してくれます。categories で使うグループを絞り、subset='all' で学習用と評価用をまとめて取得します。あとで自分でデータを分割するためです。
投稿には署名やヘッダ、引用といった、本文以外の手がかりが含まれます。これらはグループを見分けるうえで反則級のヒント(たとえば投稿先の名前そのもの)になりがちなので、remove で落とし、あくまで本文の言葉づかいから当てる、より正直な設定にします。
from sklearn.datasets import fetch_20newsgroups
# 話題の異なる4グループだけを対象にする
categories = [
"alt.atheism",
"comp.graphics",
"sci.med",
"soc.religion.christian",
]
# 本文以外の手がかり(ヘッダ・署名・引用)は取り除く
data = fetch_20newsgroups(
subset="all",
categories=categories,
remove=("headers", "footers", "quotes"),
shuffle=True,
random_state=42,
)
print("文書数:", len(data.data))
print("クラス名:", data.target_names)
# グループごとの件数を数える
import numpy as np
for i, name in enumerate(data.target_names):
print(name, (data.target == i).sum())
文書数: 3759
クラス名: ['alt.atheism', 'comp.graphics', 'sci.med', 'soc.religion.christian']
alt.atheism 799
comp.graphics 973
sci.med 990
soc.religion.christian 997全部で3759件、4グループがだいたい800から1000件ずつ含まれています。中身を1件のぞいてみましょう。data.data が本文の文字列、data.target が0から3の整数ラベルです。
i = 0
print("ラベル:", data.target[i], "->", data.target_names[data.target[i]])
print("本文の冒頭:")
print(data.data[i][:200])
ラベル: 2 -> sci.med
本文の冒頭:
My understanding is that the myelin sheath around nerves is what
gets damaged in MS. The immune system attacks it as if it were a
foreign body, and the resulting scarring disrupts the signals ...この段のまとめRecap
公開コーパスを4グループに絞って読み込みました。3759件、本文とラベルの組がそろっています。ヘッダや引用を落としたので、以降は「本文の言葉づかい」だけで話題を当てる勝負になります。
前処理とデータ分割
Preprocess & splitこの段でやることGoal
次に、テキストを整え、学習用と評価用に分けます。ここで一番大事なのはリーク(情報漏れ)を避けることです。評価用データの情報が学習に混ざると、見かけの精度は上がっても、未知のデータには通用しない分類器ができてしまいます。
正規化とトークン化
英語なので、まず小文字化して大文字小文字の違いをそろえ、単語だけを取り出します(数字や記号を落とす)。「the」「is」のようなどの記事にも出るありふれた語(ストップワード)は、話題の区別に効きにくいので除きます。これらはこの後の TfidfVectorizer の設定で一括して指定できるので、ここでは考え方を確認しておきます。
import re
# 前処理の考え方を1文で確認する(実際の学習では Vectorizer に任せる)
sample = data.data[0]
stop = {"the", "is", "it", "as", "a", "an", "of", "to", "and"}
tokens = re.findall(r"[a-z]+", sample.lower()) # 小文字化してトークン化
tokens = [t for t in tokens if t not in stop] # ストップワード除去
print(tokens[:12])
['my', 'understanding', 'that', 'myelin', 'sheath', 'around', 'nerves', 'what', 'gets', 'damaged', 'in', 'ms']学習用と評価用に分ける
train_test_split で全体を学習用(訓練)と評価用(テスト)に分けます。stratify=y を指定すると、各グループの割合を分割の前後で保ちます。件数に偏りがあるとき、これを忘れると評価用にあるグループがほとんど入らない、といった事故が起きます。test_size=0.25 で4分の1を評価用に回します。
from sklearn.model_selection import train_test_split
X, y = data.data, data.target
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.25,
stratify=y, # グループの割合を保ったまま分割
random_state=42,
)
print("学習用:", len(X_train), "評価用:", len(X_test))
学習用: 2819 評価用: 940リークを防ぐ鉄則Avoiding leakage
TF-IDF の語彙や IDF の重みは、学習用データだけから決めなければいけません。評価用も含めて計算してしまうと、評価用の情報が学習に漏れます。これを確実に守るのが、次の Step で使う Pipeline です。前処理と分類器をひとまとめにし、fit のときは学習用だけで前処理を学習し、predict のときはその設定を評価用に当てる、という順序を自動で守ってくれます。
特徴量にする(TF-IDF)
Vectorize with TF-IDFこの段でやることGoal
分類器に入れられるのは数値ベクトルです。テキストを数値化する定番が TF-IDF です。ある語がその文書で何回出たか(TF)を、コーパス全体でのめずらしさ(IDF)で重み付けします。どの文書にも出るありふれた語は弱められ、特定の話題に偏って出る語が強調されます。狙いは、話題を見分ける手がかりになる語を目立たせることです。
これは scikit-learn の既定(smooth_idf=True)の定義です。分母分子に 1 を足して df=0 での破綻を防ぎ、末尾の +1 で「全文書に出る語」の重みが 0 に潰れないようにします。各文書のベクトルは最後に L2 正規化(長さ1に)されます。
TfidfVectorizer は、トークン化・ストップワード除去・TF-IDF 計算をまとめて行います。今回は次の3つを設定します。stop_words="english" で英語のストップワードを除き、min_df=5 で「5文書未満にしか出ない珍しすぎる語」を捨ててノイズと次元を抑え、sublinear_tf=True で頻度を 1 + log(tf) に圧縮して、同じ語が何度も出る影響をやわらげます。
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer(
stop_words="english", # ありふれた語を除く
min_df=5, # 5文書未満の語は捨てる
sublinear_tf=True, # tf を 1 + log(tf) に圧縮
)
# 学習用だけで語彙とIDFを学習し、そのまま変換する
X_train_vec = vectorizer.fit_transform(X_train)
print("行列の形:", X_train_vec.shape)
print("語彙の例:", vectorizer.get_feature_names_out()[500:506])
行列の形: (2819, 5041)
語彙の例: ['atheism' 'atheist' 'atheists' 'atoms' 'attack' 'attempt']2819件の記事が、5041語の語彙にもとづく疎行列(ほとんどが0)に変わりました。この行列が分類器の入力です。ただし、実際の学習では fit_transform を単独で呼ぶのではなく、次の Step のように Pipeline の中に組み込んで、学習用だけで fit されるようにします。これが Step 2 で触れたリーク防止の実装です。
分類器を学習する
Train the classifiersこの段でやることGoal
2つの分類器を、同じ TF-IDF を通して学習させ、後で公平に比べます。Pipeline でベクトル化と分類器をつなぐと、fit と predict の順序が自動で守られ、リークを防げます。
- ナイーブベイズ(
MultinomialNB):各語の出現を「クラスごとの確率」として素朴に掛け合わせ、最も確率の高いクラスを選びます。単語カウントと相性がよく、学習が速く、テキスト分類の古典的な強豪です。 - ロジスティック回帰(
LogisticRegression):各語に重み(正負)を割り当て、その線形和からクラスの確率を出します。語どうしの重みを調整して境界を引くため、紛らわしいクラスの切り分けが得意なことが多いです。
from sklearn.pipeline import Pipeline
from sklearn.naive_bayes import MultinomialNB
from sklearn.linear_model import LogisticRegression
def make_vectorizer():
return TfidfVectorizer(stop_words="english", min_df=5, sublinear_tf=True)
# 2つのパイプライン(前処理 + 分類器)
nb = Pipeline([
("tfidf", make_vectorizer()),
("clf", MultinomialNB()),
])
lr = Pipeline([
("tfidf", make_vectorizer()),
("clf", LogisticRegression(max_iter=1000, C=10)),
])
# 学習用データだけで学習する(前処理も含めて fit される)
nb.fit(X_train, y_train)
lr.fit(X_train, y_train)
print("学習が終わりました")
学習が終わりました学習済みのパイプラインは、生のテキストをそのまま渡すだけで予測を返します。前処理はパイプラインの中で自動的にかかります。試しに短い文で予測させてみましょう。
examples = [
"The patient was prescribed antibiotics for the infection.",
"I rendered the 3D scene with ray tracing and saved the image.",
]
for text, pred in zip(examples, lr.predict(examples)):
print(data.target_names[pred], "<-", text)
sci.med <- The patient was prescribed antibiotics for the infection.
comp.graphics <- I rendered the 3D scene with ray tracing and saved the image.この段のまとめRecap
ベクトル化と分類器を Pipeline でつなぎ、2つのモデルを同じ条件で学習させました。次はこれを数字で評価します。「なんとなく当たっている」ではなく、どのクラスをどれだけ当てられているかを見ます。
評価する
Evaluateこの段でやることGoal
精度(accuracy、全体の正解率)だけを見ると判断を誤ります。クラスごとに適合率・再現率・F値を確認し、どこで間違えるかを混同行列で見て、さらに交差検証で「たまたまの分割で良く見えていないか」を確かめます。
- 適合率(precision):あるクラスと予測したもののうち、本当にそのクラスだった割合。誤検出の少なさ。
- 再現率(recall):本当にそのクラスのもののうち、正しく拾えた割合。取りこぼしの少なさ。
- F値(F1):適合率と再現率の調和平均。両者のバランスを1つの数字で表します。
クラスごとの成績Per-class report
classification_report が、クラスごとの適合率・再現率・F値を一覧にしてくれます。まずナイーブベイズから。
from sklearn.metrics import classification_report
y_pred_nb = nb.predict(X_test)
print(classification_report(
y_test, y_pred_nb, target_names=data.target_names, digits=2))
precision recall f1-score support
alt.atheism 0.76 0.69 0.72 200
comp.graphics 0.92 0.94 0.93 243
sci.med 0.91 0.91 0.91 247
soc.religion.christian 0.78 0.83 0.81 250
accuracy 0.85 940
macro avg 0.84 0.84 0.84 940
weighted avg 0.85 0.85 0.85 940続いてロジスティック回帰。同じ評価用データで比べます。
y_pred_lr = lr.predict(X_test)
print(classification_report(
y_test, y_pred_lr, target_names=data.target_names, digits=2))
precision recall f1-score support
alt.atheism 0.86 0.83 0.84 200
comp.graphics 0.94 0.96 0.95 243
sci.med 0.94 0.94 0.94 247
soc.religion.christian 0.88 0.90 0.89 250
accuracy 0.91 940
macro avg 0.91 0.90 0.91 940
weighted avg 0.91 0.91 0.91 940どこで間違えるかConfusion matrix
confusion_matrix は、行が正解、列が予測のクロス集計です。対角成分が正解、対角から外れたところが間違いです。ナイーブベイズの結果を見てみましょう。
from sklearn.metrics import confusion_matrix
cm = confusion_matrix(y_test, y_pred_nb)
print(data.target_names)
print(cm)
['alt.atheism', 'comp.graphics', 'sci.med', 'soc.religion.christian']
[[138 5 7 50]
[ 4 228 8 3]
[ 6 12 225 4]
[ 33 3 6 208]]行の意味は上から alt.atheism・comp.graphics・sci.med・soc.religion.christian です。1行目を読むと、本当は無神論の200件のうち138件は正解ですが、50件がキリスト教と取り違えられています。逆に4行目でもキリスト教が33件、無神論と混同されています。話題も語彙も近い2グループの境目が、いちばん難しいことがわかります。
交差検証で確かめるCross-validation
1回の分割で出た数字は、分割の運に左右されます。cross_val_score はデータを5つに分け、順に1つを評価用、残りを学習用として5回学習・評価し、精度のばらつきを見せてくれます。パイプラインごと渡すので、各分割で前処理も学習用だけから作り直され、リークは起きません。
from sklearn.model_selection import cross_val_score
for name, model in [("NB", nb), ("LR", lr)]:
scores = cross_val_score(model, X, y, cv=5, scoring="accuracy")
print(f"{name}: 平均 {scores.mean():.3f} (+/- {scores.std():.3f})")
print(" 各分割:", [round(s, 3) for s in scores])
NB: 平均 0.840 (+/- 0.008)
各分割: [0.837, 0.851, 0.828, 0.845, 0.839]
LR: 平均 0.899 (+/- 0.007)
各分割: [0.895, 0.911, 0.889, 0.903, 0.898]どの分割でもロジスティック回帰がおよそ6ポイント上回り、ばらつきも小さいので、この差はたまたまではなさそうだと読めます。ホールドアウト(1回の分割)の 0.85 対 0.91 とも整合しています。
結果を読み解く
Interpret the resultsどちらが良かったかWhich model won
今回の設定では、ロジスティック回帰がナイーブベイズを一貫して上回りました。特に差が出たのは、語彙の重なる alt.atheism と soc.religion.christian の切り分けです。ナイーブベイズは各語を独立に扱うため、両グループに共通して出る「god」「believe」などの語に引きずられ、無神論の再現率が0.69にとどまりました。ロジスティック回帰は語の重みを調整して境界を引くので、同じ境目で再現率0.83まで持ち直しています。
なぜそうなるかWhy
ロジスティック回帰は各クラスごとに語の重み(係数)を持ちます。係数の大きい語を並べると、モデルが何を手がかりにしているかが見えます。線形モデルの解釈しやすさは、精度と並ぶ長所です。
import numpy as np
vec = lr.named_steps["tfidf"]
clf = lr.named_steps["clf"]
feature_names = np.array(vec.get_feature_names_out())
for i, name in enumerate(data.target_names):
top = feature_names[np.argsort(clf.coef_[i])[-6:]][::-1]
print(f"{name}: {list(top)}")
alt.atheism: ['atheism', 'atheists', 'religion', 'islam', 'moral', 'god']
comp.graphics: ['graphics', 'image', 'file', 'files', 'format', '3d']
sci.med: ['msg', 'doctor', 'disease', 'medical', 'patients', 'pain']
soc.religion.christian: ['god', 'jesus', 'church', 'christ', 'christians', 'sin']各グループらしい語が並んでいます。「god」が無神論とキリスト教の両方に顔を出しているのが、両者が紛らわしい理由をよく物語っています。文脈を無視して語の有無だけを見ると、この2つはどうしても近づきます。
改善の余地Room to improve
- 特徴量:単語だけでなく2語のつながり(bi-gram)を加えると、「no god」のような否定の手がかりを拾えることがあります。
ngram_range=(1, 2)を試す価値があります。 - ハイパーパラメータ:
min_dfや、ロジスティック回帰の正則化Cは精度に効きます。GridSearchCVで系統的に探せます。 - 評価の見方:多数派クラスに引っ張られる精度より、クラスごとのF値やマクロ平均を重視すると、少数派の取りこぼしに気づけます。
- 限界:今回は本文の語だけを見る素朴な手法です。語順や文脈まで扱うには、第2段階の深層言語モデルへ進む道があります。
この段のまとめRecap
「精度が高い方が良い」で終わらせず、どのクラスでなぜ間違えたか、その差は安定しているかまで見ました。線形モデルの重みから手がかりの語を確かめると、モデルの振る舞いに納得がいきます。ここまでが、前処理から評価までを一続きにする分析の一巡です。
発展課題Try more
こう変えたらどうなる?
ノートブックの数字や設定を書き換えて、結果がどう動くかを確かめましょう。解答は用意しません。方向性だけ示すので、手を動かして確かめるのがいちばんの近道です。
1. クラスを増やすAdd categories
categoriesに別のグループ(たとえばrec.sport.baseballやsci.space)を加えて、クラス数が増えると精度がどう変わるか見ます。話題が近いグループを足すと難しくなるはずです。
2. 特徴量を変えるTune the features
TfidfVectorizerのngram_range=(1, 2)やmin_df・max_dfを変えて、語彙の大きさと精度の関係を調べます。次元が増えるほど良いとは限りません。
3. 別のコーパスで試すTry another corpus
- 日本語なら livedoor ニュースコーパスが定番です。分かち書き(janome など)を挟めば、同じ流れで日本語の分類器が作れます。前処理だけ差し替えるのがコツです。
4. 重要語を掘り下げるRead the weights
- ロジスティック回帰の係数が負に大きい語(そのクラスらしくない語)も並べてみます。誤分類された記事を1件ずつ読み、なぜ間違えたのかを言葉で説明できると理解が深まります。
まとめWrap-up
前処理から評価まで、一続きにできた
生のニュース記事から出発して、前処理・データ分割・TF-IDF による特徴量化・2つの分類器の学習・多面的な評価までを、1本のパイプラインとしてつなぎました。ナイーブベイズは速くて手堅い基準、ロジスティック回帰は紛らわしい境目に強く、今回は後者が上回りました。どちらが良いかは、精度だけでなくクラスごとのF値・混同行列・交差検証を合わせて初めて判断できる、というのが最大の収穫です。
この演習で組み合わせた技術は、次のモジュールで単体としても深められます。
自然言語処理(NLP)NLP
- 前処理・BoW / TF-IDF・分散表現をもっと詳しく。モジュールへOpen ›
開発データの前処理Preprocessing
- 分割・リーク防止・データセット構築の作法。モジュールへOpen ›
機械学習Machine Learning
- 分類器の仕組みと評価指標を体系的に。モジュールへOpen ›
次の演習・段階へNext exercise
- 次の演習:Next: ソースコードを測って読み解くMeasure and read source code(演習一覧All exercises)
- 別のコーパスや分類器に差し替えて、同じパイプラインをもう一周する。Swap in another corpus or classifier and run the same pipeline again.
おつかれさまでしたNice work
ぜひノートブックを開いて、クラスや ngram_range、正則化 C の数字を変えて再実行してみてください。数字が動く様子を自分の目で見ると、評価の勘所が体にしみこみます。次の総合演習では、また別の技術の組み合わせに挑みます。