Skip to content

第 13 章 演習解答

第 13 章「テキストデータを用いた機械学習」の演習問題の解答例です。問題は 第 13 章 演習問題 を参照してください。

演習 13-1: TF-IDF と分類器の比較

問題を見る

import numpy as np
import pandas as pd
import MeCab
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.svm import LinearSVC
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score

# 1. データ読み込み + 分かち書き関数の定義
df = pd.read_csv("movie_reviews.csv")

tagger = MeCab.Tagger("-Owakati")
def tokenize(text):
    return tagger.parse(text).strip().split()

texts = df["text"].values
labels = df["label"].values

# 2. テキストを先に分割し、訓練側だけで語彙とIDFを学習
train_texts, test_texts, y_train, y_test = train_test_split(
    texts, labels, test_size=0.2, random_state=42
)
vectorizer = TfidfVectorizer(tokenizer=tokenize, token_pattern=None)
X_train = vectorizer.fit_transform(train_texts)
X_test = vectorizer.transform(test_texts)

# 3. 3 つの分類器を比較
models = {
    "ナイーブベイズ": MultinomialNB(),
    "SVM": LinearSVC(max_iter=1000),
    "ロジスティック回帰": LogisticRegression(max_iter=1000),
}
for name, model in models.items():
    model.fit(X_train, y_train)
    acc = accuracy_score(y_test, model.predict(X_test))
    print(f"{name}: 正解率 {acc:.4f}")

# 4. ロジスティック回帰の係数から重要語を抽出
# classes_ は ['negative' 'positive'] なので、係数が大きいほど positive 寄り
model_lr = models["ロジスティック回帰"]
feature_names = vectorizer.get_feature_names_out()
coef = model_lr.coef_[0]

top_pos = np.argsort(coef)[::-1][:10]
top_neg = np.argsort(coef)[:10]

print("\nポジティブ寄りの単語 トップ10:")
print(", ".join(feature_names[i] for i in top_pos))
print("\nネガティブ寄りの単語 トップ10:")
print(", ".join(feature_names[i] for i in top_neg))
ナイーブベイズ: 正解率 0.7500
SVM: 正解率 0.7500
ロジスティック回帰: 正解率 0.7750

ポジティブ寄りの単語 トップ10:
に, ある, あり, それ, 幸せ, 満足, 気, する, できる, せ

ネガティブ寄りの単語 トップ10:
ない, た, 悪い, しか, 印象, が, しまう, 。, 感じ, レベル

考察:語彙と IDF は訓練テキストだけから求め、テストテキストには同じ変換を使います。この分割ではロジスティック回帰(0.7750)が最も高くなりました。重要語には「満足」「悪い」などの感情語のほか、「に」「た」のような助詞や助動詞も入ります。ストップワードを除くときは、否定の「ない」まで消さないよう注意します。分類器を選ぶときは訓練データ内の交差検証を使い、このテスト結果で選び直さないようにします。

演習 13-2: ベクトル化の設定変更

問題を見る

import pandas as pd
import MeCab
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.svm import LinearSVC
from sklearn.metrics import accuracy_score

# 演習13-1と同じテキストの分割
df = pd.read_csv("movie_reviews.csv")
train_texts, test_texts, y_train, y_test = train_test_split(
    df["text"].values, df["label"].values, test_size=0.2, random_state=42
)
tagger = MeCab.Tagger("-Owakati")
def tokenize(text):
    return tagger.parse(text).strip().split()

# 両方とも訓練テキストだけから語彙とIDFを学習する
for label, ngram in [("unigram", (1, 1)), ("1-2gram", (1, 2))]:
    vectorizer = TfidfVectorizer(tokenizer=tokenize, token_pattern=None,
                                ngram_range=ngram)
    X_train = vectorizer.fit_transform(train_texts)
    X_test = vectorizer.transform(test_texts)
    model = LinearSVC(max_iter=1000)
    model.fit(X_train, y_train)
    accuracy = accuracy_score(y_test, model.predict(X_test))
    print(f"{label}: 語彙数 {X_train.shape[1]}, 正解率 {accuracy:.4f}")
unigram: 語彙数 845, 正解率 0.7500
1-2gram: 語彙数 3256, 正解率 0.7250

考察:バイグラムを加えると語彙数は 845 から 3256 に増えましたが、正解率は 0.7500 から 0.7250 に下がりました。単語の並びを扱えても、精度が上がるとは限りません。設定を選ぶときは訓練データ内の交差検証を使い、語彙と IDF も各分割の訓練側だけで学習します。

演習 13-3: 自作レビューの判定

問題を見る

import pandas as pd
import MeCab
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.svm import LinearSVC

# データの準備と SVM の学習(演習13-1と同じ)
df = pd.read_csv("movie_reviews.csv")

tagger = MeCab.Tagger("-Owakati")
def tokenize(text):
    return tagger.parse(text).strip().split()

train_texts, test_texts, y_train, y_test = train_test_split(
    df["text"].values, df["label"].values, test_size=0.2, random_state=42
)
vectorizer = TfidfVectorizer(tokenizer=tokenize, token_pattern=None)
X_train = vectorizer.fit_transform(train_texts)
X_test = vectorizer.transform(test_texts)
model_svm = LinearSVC(max_iter=1000)
model_svm.fit(X_train, y_train)

# 自作レビューの判定
my_reviews = [
    "映像が美しく、最後まで夢中で観た。文句なしの傑作だと思う。",
    "テンポが良くて登場人物も魅力的。友人にも勧めたい映画だった。",
    "話の展開が遅くて退屈だった。時間を返してほしい。",
    "期待して観たが、脚本が薄くてがっかりした。",
]
X_my = vectorizer.transform(my_reviews)
for text, pred in zip(my_reviews, model_svm.predict(X_my)):
    print(f"{pred}: {text}")
positive: 映像が美しく、最後まで夢中で観た。文句なしの傑作だと思う。
positive: テンポが良くて登場人物も魅力的。友人にも勧めたい映画だった。
negative: 話の展開が遅くて退屈だった。時間を返してほしい。
negative: 期待して観たが、脚本が薄くてがっかりした。

考察:4文とも意図どおりのラベルになりました。ただし、モデルは positive か negative を必ず返すので、中立的な文にもどちらかが付きます。unigram の TF-IDF は語順を表さないため、否定や文脈に左右される文は誤判定しやすくなります。4文だけで、未知のレビューへの性能は判断できません。

演習 13-4: 文の類似度と類似文検索

問題を見る

まず、3つの文の類似度を計算します。

from sentence_transformers import SentenceTransformer, util

# モデルの読み込み
model_st = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

sentences = [
    "この映画は面白かった",
    "この作品はとても楽しめた",
    "今日は雨が降っています",
]
embeddings = model_st.encode(sentences)

sim_12 = util.cos_sim(embeddings[0], embeddings[1]).item()
sim_13 = util.cos_sim(embeddings[0], embeddings[2]).item()
print(f"1文目と2文目の類似度: {sim_12:.4f}")
print(f"1文目と3文目の類似度: {sim_13:.4f}")
1文目と2文目の類似度: 0.6442
1文目と3文目の類似度: 0.0980

続いて、類似文検索です。クエリと候補文をベクトル化し、類似度が最も高い候補文を出力します。

# 類似文検索
query = "スマートフォンの新製品が発表された"
candidates = [
    "新しいスマホが登場して話題になっている",
    "今日の夕食はカレーライスにした",
    "台風が接近し、明日は大雨の予報だ",
    "最新の携帯電話が公開され注目を集めた",
]

query_emb = model_st.encode([query])
cand_emb = model_st.encode(candidates)

# クエリと各候補の類似度(1×4 の行列の先頭行)
sims = util.cos_sim(query_emb, cand_emb)[0]
for text, sim in zip(candidates, sims):
    print(f"{sim.item():.4f}: {text}")

best = int(sims.argmax())
print(f"\n最も近い文: {candidates[best]}")
0.8258: 新しいスマホが登場して話題になっている
0.0226: 今日の夕食はカレーライスにした
0.0774: 台風が接近し、明日は大雨の予報だ
0.7206: 最新の携帯電話が公開され注目を集めた

最も近い文: 新しいスマホが登場して話題になっている

考察:「この映画は面白かった」と「この作品はとても楽しめた」は、共通の単語が「この」「は」「た」くらいなのに、類似度は 0.6442 と高くなりました。「映画」と「作品」、「面白かった」と「楽しめた」の意味の近さを Embedding が捉えているためです。無関係な雨の文との類似度は 0.0980 でした。単語の一致を数える TF-IDF では、1文目と2文目の類似度は低くなります。類似文検索でも、「スマホ」「携帯電話」と書き方の違う2文が上位(0.8258 と 0.7206)に来ています。

演習 13-5: TF-IDF と Embedding の精度比較

問題を見る

import pandas as pd
import MeCab
from sentence_transformers import SentenceTransformer
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score

# 1. データ読み込み(ラベルは文字列のまま使う)
df = pd.read_csv("emails.csv")

# 2. 先に 1 回だけ訓練・テストに分割(同じ行を両手法で使う)
train_df, test_df = train_test_split(
    df, test_size=0.2, random_state=42, stratify=df["label"]
)

# 3. TF-IDF + ロジスティック回帰
tagger = MeCab.Tagger("-Owakati")
def tokenize(text):
    return tagger.parse(text).strip().split()

vectorizer = TfidfVectorizer(tokenizer=tokenize, token_pattern=None)
X_train_tfidf = vectorizer.fit_transform(train_df["text"])
X_test_tfidf = vectorizer.transform(test_df["text"])

model_tfidf = LogisticRegression(max_iter=1000)
model_tfidf.fit(X_train_tfidf, train_df["label"])
acc_tfidf = accuracy_score(test_df["label"], model_tfidf.predict(X_test_tfidf))

# 4. Sentence Transformers の Embedding + ロジスティック回帰
model_st = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
X_train_emb = model_st.encode(train_df["text"].tolist())
X_test_emb = model_st.encode(test_df["text"].tolist())

model_emb = LogisticRegression(max_iter=1000)
model_emb.fit(X_train_emb, train_df["label"])
acc_emb = accuracy_score(test_df["label"], model_emb.predict(X_test_emb))

# 5. 2 つの精度を並べて表示
print(f"TF-IDF    + ロジスティック回帰: 正解率 {acc_tfidf:.4f}")
print(f"Embedding + ロジスティック回帰: 正解率 {acc_emb:.4f}")
TF-IDF    + ロジスティック回帰: 正解率 0.8500
Embedding + ロジスティック回帰: 正解率 0.8333

考察:分割を先に1回だけ行い、同じ行をそれぞれの方法でベクトル化しています。分類器も分割も同じなので、差はベクトル化の違いだけから生まれます。結果は TF-IDF が 0.8500、Embedding が 0.8333 で、テスト60件のうち1件の差です。このメールには「お世話になっております」(ビジネス)、「久しぶり」(プライベート)、「当選」「今すぐ」(スパム)のように、クラスごとに決まった言い回しがあります。そのため、単語の出現だけを見る TF-IDF でも十分に分けられます。13.5.3 項の映画レビューでは、Embedding で正解率が大きく上がりました。言い換えが多いタスクほど Embedding が有利になりやすいです。

発展課題: アンカー文による学習なし感情判定

問題を見る

from sentence_transformers import SentenceTransformer, util

model_st = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

# ポジ・ネガを代表するアンカー文
pos_anchor = "素晴らしい映画だった"
neg_anchor = "ひどい映画だった"

# 判定したい文
tests = [
    "期待以上の出来で本当に感動した",
    "時間の無駄だった。二度と見たくない",
    "話の展開が退屈で全く楽しめなかった",
    "映像が美しく、心から楽しめる作品だった",
]

# 1. アンカー文とテスト文をまとめてベクトル化する
embeddings = model_st.encode([pos_anchor, neg_anchor] + tests)
anchor_emb = embeddings[:2]
test_emb = embeddings[2:]

# 2. 各テスト文とアンカー文の類似度(4×2 の行列)
sims = util.cos_sim(test_emb, anchor_emb)

# 3. 近い方のラベルを表示する
for text, sim in zip(tests, sims):
    pos_sim, neg_sim = sim[0].item(), sim[1].item()
    label = "positive" if pos_sim > neg_sim else "negative"
    print(f"{label} (pos={pos_sim:.3f}, neg={neg_sim:.3f}): {text}")
positive (pos=0.261, neg=0.175): 期待以上の出来で本当に感動した
negative (pos=0.183, neg=0.348): 時間の無駄だった。二度と見たくない
negative (pos=0.303, neg=0.479): 話の展開が退屈で全く楽しめなかった
positive (pos=0.683, neg=0.232): 映像が美しく、心から楽しめる作品だった

考察:ラベル付きデータで学習していないのに、4文とも妥当な判定になりました。Embedding では意味の近い文どうしが近くに置かれるので、どちらのアンカー文に近いかを測るだけで分類できます。これはゼロショット分類と呼ばれる方法の簡単な形です。ただし、類似度はアンカー文の言い回しで変わります。1文目は pos=0.261、neg=0.175 と、どちらとの類似度も低く差も小さめです。皮肉や中立的な文では差がさらに小さくなり、判定が不安定になります。アンカー文を書き換えて、結果がどう変わるか試してみてください。