!date
!python --version
Mon Apr 21 06:58:06 AM UTC 2025
Python 3.11.12

28. 極性分類システムの構築例(Bag-of-Wordsベース)#

28.1. 本演習の目標#

  • ルールベース(rule_based.ipynb)と機械学習との違いを理解する。

28.2. 実装方針#

特徴量を Bag-of-Words によるバイナリコーディング f(x) とする。学習器は、全ての単語に対する重み W による荷重和スコアを求め、スコアに基づき識別する。

  • 特徴量関数 h=f(x): bag-of-words

  • score=Wh=Wf(x)

28.3. A. データセット用意(コピペ)#

!curl -O https://ie.u-ryukyu.ac.jp/~tnal/2025/dm/static/r_assesment_sentiment.xlsx
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 53514  100 53514    0     0  17962      0  0:00:02  0:00:02 --:--:-- 17957
import pandas as pd

filename = "r_assesment_sentiment.xlsx"
assesment_df = pd.read_excel(filename)
assesment_df.head()
title grade required q_id comment wakati1 wakati2 sentiment
0 工業数学Ⅰ 1 True Q21 (1) 特になし 特に なし 特に ない 0
1 工業数学Ⅰ 1 True Q21 (2) 正直わかりずらい。むだに間があるし。 正直 わかり ず らい 。 むだ に 間 が ある し 。 正直 わかる ぬ らい 。 むだ に 間 が ある し 。 -1
2 工業数学Ⅰ 1 True Q21 (2) 例題を取り入れて理解しやすくしてほしい。 例題 を 取り入れ て 理解 し やすく し て ほしい 。 例題 を 取り入れる て 理解 する やすい する て ほしい 。 -1
3 工業数学Ⅰ 1 True Q21 (2) 特になし 特に なし 特に ない 0
4 工業数学Ⅰ 1 True Q21 (2) スライドに書く文字をもう少しわかりやすくして欲しいです。 スライド に 書く 文字 を もう少し わかり やすく し て 欲しい です 。 スライド に 書く 文字 を もう少し わかる やすい する て 欲しい です 。 -1
x_data = list(assesment_df['wakati1'])
y_data = list(assesment_df['sentiment'])

print(f"{x_data[0]=}, {type(x_data[0])=}")
print(f"{y_data[0]=}, {type(y_data[0])=}")
x_data[0]='特に なし', type(x_data[0])=<class 'str'>
y_data[0]=0, type(y_data[0])=<class 'int'>
# 学習用データ、テスト用データに分割
from sklearn.model_selection import train_test_split

# train_size = 学習用データの割合。
# random_state = 疑似乱数生成するためのシード値。
#   シード値を固定しておくと「シャッフルするけど毎回同じシャッフル結果」を利用できる。
#   結果を再現できるため、動作確認や失敗分析をし易い。
# shuffle = シャッフするなら True。
X_train, X_test, y_train, y_test = train_test_split(x_data, y_data, train_size=0.8, random_state=1, shuffle=True)
print(f"{len(X_train)=}, {len(y_train)=}")
print(f"{len(X_test)=}, {len(y_test)=}")
print(f"{X_train[0]=}, {y_train[0]=}")
len(X_train)=136, len(y_train)=136
len(X_test)=34, len(y_test)=34
X_train[0]='python の 内容 は 予想 を 上回る ほど の 量 だっ た ので 、 まだ 理解度 が 完璧 と は 言え ない 状況 です 。 夏休み は 復習 を し て 、 2 学期 から また 新しい 言語 を 学ん で いき たい と 思い ます 。', y_train[0]=0

28.4. モジュール読み込み#

今回はどちらもカットして問題ないが、多くの実装で利用されるため使っています。

  • random: データセットをシャッフルするために利用。

  • tqdm: プログレス・バー(進捗状況)を表示するために利用。

import random
import tqdm

28.5. B. 特徴抽出(変更あり)#

Bag-of-Wordsにより特徴表現。

def extract_features(x: str) -> dict[str, float]:
    features = {}
    x_split = x.split(' ')
    for x in x_split:
        features[x] = features.get(x, 0) + 1.0
    return features

# 実行例
text = X_train[8]
features = extract_features(text)
print(f"{text=}")
print(f"{features=}")
text='社会人 に 向け て の これから を 考える いい 機会 に なっ た ので よかっ た です 。'
features={'社会人': 1.0, 'に': 2.0, '向け': 1.0, 'て': 1.0, 'の': 1.0, 'これから': 1.0, 'を': 1.0, '考える': 1.0, 'いい': 1.0, '機会': 1.0, 'なっ': 1.0, 'た': 2.0, 'ので': 1.0, 'よかっ': 1.0, 'です': 1.0, '。': 1.0}

28.6. C. 分類器構築(少し修正)#

ルールベースでは、(1) good_words, bad_words, bias の3種類の重みを用意し、(2) それぞれの出現数に掛け合わせた総和によりスコアを求め、(3) しきい値処理により推定するという流れで処理した。

BoWベースでは、(1) 全ての単語に対して異なる重み(初期値0)を用意し、(2) それぞれの出現数に掛け合わせた総和によりスコアを求め、(3) しきい値処理により推定する。変更点は(1)のみ。

# 全ての重みを0に初期化
feature_weights = {}

def run_classifier(features: dict[str, float]) -> int:
    '''入力された特徴辞書の極性を推定する。

    入力 (features):特徴辞書。
    出力1 (int): 推定ラベル: 良い評価(1)、悪い評価(-1)、どちらでもない(0)。
    出力2 (score): 算出スコア。
    '''
    score = 0
    for feat_name, feat_value in features.items():
        score = score + feat_value * feature_weights.get(feat_name, 0)
    if score > 0:
        return 1, score
    elif score < 0:
        return -1, score
    else:
        return 0, score

for i in range(5):
    print(f"{X_train[i]=}")
    features = extract_features(X_train[i])
    print(f"{features=}")
    estimated_label, score = run_classifier(features)
    true_label = y_train[i]
    print(f"{score=}, {estimated_label=}, {true_label=}")
    print("---")
X_train[i]='python の 内容 は 予想 を 上回る ほど の 量 だっ た ので 、 まだ 理解度 が 完璧 と は 言え ない 状況 です 。 夏休み は 復習 を し て 、 2 学期 から また 新しい 言語 を 学ん で いき たい と 思い ます 。'
features={'python': 1.0, 'の': 2.0, '内容': 1.0, 'は': 3.0, '予想': 1.0, 'を': 3.0, '上回る': 1.0, 'ほど': 1.0, '量': 1.0, 'だっ': 1.0, 'た': 1.0, 'ので': 1.0, '、': 2.0, 'まだ': 1.0, '理解度': 1.0, 'が': 1.0, '完璧': 1.0, 'と': 2.0, '言え': 1.0, 'ない': 1.0, '状況': 1.0, 'です': 1.0, '。': 2.0, '夏休み': 1.0, '復習': 1.0, 'し': 1.0, 'て': 1.0, '2': 1.0, '学期': 1.0, 'から': 1.0, 'また': 1.0, '新しい': 1.0, '言語': 1.0, '学ん': 1.0, 'で': 1.0, 'いき': 1.0, 'たい': 1.0, '思い': 1.0, 'ます': 1.0}
score=0.0, estimated_label=0, true_label=0
---
X_train[i]='特に なし'
features={'特に': 1.0, 'なし': 1.0}
score=0.0, estimated_label=0, true_label=0
---
X_train[i]='配布 資料 が 教科書 の 内容 に 沿っ て おり 、 わかり やすかっ た 。'
features={'配布': 1.0, '資料': 1.0, 'が': 1.0, '教科書': 1.0, 'の': 1.0, '内容': 1.0, 'に': 1.0, '沿っ': 1.0, 'て': 1.0, 'おり': 1.0, '、': 1.0, 'わかり': 1.0, 'やすかっ': 1.0, 'た': 1.0, '。': 1.0}
score=0.0, estimated_label=0, true_label=1
---
X_train[i]='Zoom の 音声 、 資料 画像 の 画質 など 特に 問題 なく 授業 を 受け られ た 。'
features={'Zoom': 1.0, 'の': 2.0, '音声': 1.0, '、': 1.0, '資料': 1.0, '画像': 1.0, '画質': 1.0, 'など': 1.0, '特に': 1.0, '問題': 1.0, 'なく': 1.0, '授業': 1.0, 'を': 1.0, '受け': 1.0, 'られ': 1.0, 'た': 1.0, '。': 1.0}
score=0.0, estimated_label=0, true_label=1
---
X_train[i]='たまに 説明 が ない コード が あっ たり し た ので 少し 戸惑っ た 。 いずれ はやっ て いく もの で は ある が 、 、 、'
features={'たまに': 1.0, '説明': 1.0, 'が': 3.0, 'ない': 1.0, 'コード': 1.0, 'あっ': 1.0, 'たり': 1.0, 'し': 1.0, 'た': 2.0, 'ので': 1.0, '少し': 1.0, '戸惑っ': 1.0, '。': 1.0, 'いずれ': 1.0, 'はやっ': 1.0, 'て': 1.0, 'いく': 1.0, 'もの': 1.0, 'で': 1.0, 'は': 1.0, 'ある': 1.0, '、': 3.0}
score=0.0, estimated_label=0, true_label=-1
---

28.7. New: 学習#

単語に対する重みを学習により求める。学習アルゴリズムは以下の通り。

  • もし推測結果が正しいなら、何もしない(重みを更新しない)。

  • もし推測結果が誤りなら、全ての特徴量を 新しい重み = 現在の重み + y * 特徴量 で更新する。

    • case 1: 正解が1で、-1と誤った場合。

      • 新しい重み = 現在の重み + 特徴量

      • 特徴量が加算される。これにより重みがより正の方向に修正され、スコアが0より大きな値になりやすくなる。

    • case 2: 正解が-1で、1と誤った場合。

      • 新しい重み = 現在の重み - 特徴量

      • 特徴量が減算される。これにより重みがより負の方向に修正され、スコアが0より大きな値になりやすくなる。

補足

  • どちらでもない(0)に対する学習は行っていない(省略)。

# E. 性能評価関数(コピペ)
def calculate_accuracy(x_data: list[str], y_data: list[int]) -> float:
    total_number = 0
    correct_number = 0
    for x, y in zip(x_data, y_data):
        y_pred, score = run_classifier(extract_features(x))
        total_number += 1
        if y == y_pred:
            correct_number += 1
    return correct_number / float(total_number)
# 全ての重みを0に初期化
feature_weights = {}

# 学習前のスコア
train_accuracy = calculate_accuracy(X_train, y_train)
test_accuracy = calculate_accuracy(X_test, y_test)
print(f"before: {train_accuracy=:.5f}, {test_accuracy=:.5f}")

# 学習
NUM_EPOCHS = 10
for epoch in range(1, NUM_EPOCHS+1):
    # データセットをシャッフル
    data_ids = list(range(len(X_train)))
    random.shuffle(data_ids)

    # サンプルごとの処理
    for data_id in tqdm.tqdm(data_ids, desc=f'Epoch {epoch}'):
        x = X_train[data_id]
        y = y_train[data_id]

        if y == 0: # 「どちらでもない(0)」ケースはスキップ。
            continue

        # 予測
        features = extract_features(x)
        predicted_y, score = run_classifier(features)

        # 予測結果が誤り時の重み更新処理
        if predicted_y != y:
            for feature in features:
                feature_weights[feature] = feature_weights.get(feature, 0) + y * features[feature]
                #print(f"{feature_weights=}")

    train_accuracy = calculate_accuracy(X_train, y_train)
    test_accuracy = calculate_accuracy(X_test, y_test)
    print(f"{epoch=}: {train_accuracy=:.5f}, {test_accuracy=:.5f}")
before: train_accuracy=0.16912, test_accuracy=0.20588
Epoch 1: 100%|██████████| 136/136 [00:00<00:00, 31922.62it/s]
epoch=1: train_accuracy=0.78676, test_accuracy=0.52941
Epoch 2: 100%|██████████| 136/136 [00:00<00:00, 43168.26it/s]
epoch=2: train_accuracy=0.89706, test_accuracy=0.64706
Epoch 3: 100%|██████████| 136/136 [00:00<00:00, 7456.54it/s]
epoch=3: train_accuracy=0.80147, test_accuracy=0.67647
Epoch 4: 100%|██████████| 136/136 [00:00<00:00, 11287.06it/s]
epoch=4: train_accuracy=0.92647, test_accuracy=0.70588
Epoch 5: 100%|██████████| 136/136 [00:00<00:00, 15123.02it/s]
epoch=5: train_accuracy=0.94118, test_accuracy=0.67647


Epoch 6: 100%|██████████| 136/136 [00:00<00:00, 42011.00it/s]
epoch=6: train_accuracy=0.94118, test_accuracy=0.67647
Epoch 7: 100%|██████████| 136/136 [00:00<00:00, 14828.95it/s]
epoch=7: train_accuracy=0.88971, test_accuracy=0.73529
Epoch 8: 100%|██████████| 136/136 [00:00<00:00, 34317.49it/s]
epoch=8: train_accuracy=0.76471, test_accuracy=0.64706
Epoch 9: 100%|██████████| 136/136 [00:00<00:00, 41737.42it/s]
epoch=9: train_accuracy=0.81618, test_accuracy=0.67647


Epoch 10: 100%|██████████| 136/136 [00:00<00:00, 44749.77it/s]
epoch=10: train_accuracy=0.83088, test_accuracy=0.67647

28.8. D. 性能評価(コピペ)#

関数定義は既に定義済みなので、ここでは実行コードのみコピペ。

label_count = {}
for y in y_test:
    if y not in label_count:
        label_count[y] = 0
    label_count[y] += 1
print(label_count)

train_accuracy = calculate_accuracy(X_train, y_train)
test_accuracy = calculate_accuracy(X_test, y_test)
print(f'Train accuracy: {train_accuracy}')
print(f'Dev/test accuracy: {test_accuracy}')
{-1: 9, 1: 18, 0: 7}
Train accuracy: 0.8308823529411765
Dev/test accuracy: 0.6764705882352942

28.9. E. 失敗分析(コピペ)#

def find_errors(x_data, y_data):
    error_ids = []
    y_preds = []
    for i, (x, y) in enumerate(zip(x_data, y_data)):
        pred, score = run_classifier(extract_features(x))
        y_preds.append(pred)
        if y != y_preds[-1]:
            error_ids.append(i)
    for _ in range(5):
        my_id = random.choice(error_ids)
        x, y, y_pred = x_data[my_id], y_data[my_id], y_preds[my_id]
        print(f'{x}\ntrue label: {y}\npredicted label: {y_pred}\n')

find_errors(X_train, y_train)
特に なし
true label: 0
predicted label: 1

特に なし
true label: 0
predicted label: 1

特に なし
true label: 0
predicted label: 1

特に なし
true label: 0
predicted label: 1

特に なし
true label: 0
predicted label: 1