🙌

SVG論文に学ぶ、埋め込み+SVMによる文書分類と能動学習への展望

に公開

最近、LLMの世界で注目を集めている技術にSVG (Support Vector Generation)があります。これは、LLMの能力を古典的な機械学習アルゴリズムであるサポートベクターマシン(SVM)と組み合わせる、非常に興味深いアプローチです。私もこの技術に感銘を受け、自身のブログでSVGの真相:32パラメータのAIは、次世代LLM(MoE)の司令塔になるかとしてその可能性を考察しました。

SVGの元論文を読み解くと、特に以下の3つの点が革新的であると感じました。

  1. ゼロショット学習とカーネルマシンの等価性: 論文では、「ゼロショット学習は数学的にカーネルマシンと等価である」という驚くべき事実を証明しています。これは、テキスト分類のようなタスクが、テキスト埋め込み空間内での類似度計算(カーネル)によって解けることを意味します。
  2. カーネルトリックの活用: 最適な分類境界を見つける問題が、SVMの最適化問題(いわゆるカーネルトリック)として定式化できることを示しています。これにより、数学的に洗練された方法で解を導き出すことが可能になります。
  3. "Generation"(生成)の役割: SVGが単なるSVMと一線を画すのは、LLMの生成能力を使う点です。分類境界の構築に最も重要なデータ点(=サポートベクター)を、MCMCサンプリングという手法を用いて動的に生成させるのです。これにより、少量のデータからでも頑健なモデルを構築できる可能性が示唆されています。

この独創的なアイデアに触発され、私はSVGの核心部分をよりシンプルな形で実装してみることにしました。具体的には、「LLMによるサポートベクターの動的生成」という最も複雑な部分を一旦省略し、「テキスト埋め込みベクトルをSVMで分類する」という基本的な枠組みを試すことにしました。

実験の題材として、以前の文書分類の実験でも使用したSoftware Requirements Classificationデータセットを用いました。これは、ソフトウェアの要求仕様テキストを、機能要求(FR)や非機能要求(NFR)などの12のカテゴリに分類するタスクです。

実験の概要

まず、テキストをベクトル化するための埋め込みモデルとして、Granite-278M-MultilingualをGGUF形式で量子化したモデル(hf.co/bartowski/granite-embedding-278m-multilingual-GGUF:IQ3_M)を採用しました。

このモデルを選んだ理由は、軽量でありながら多言語に対応しており、エッジデバイスでの運用に適しているためです。実際に、手元のRaspberry Pi 400上でOllamaを使ってローカルにホストしており、この環境をそのまま活用しました。

実装

1. 学習フェーズ

import polars as pl
import langchain_openai as lco
import langchain_core as lc
from langchain_community.embeddings import OllamaEmbeddings
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.svm import SVC
from sklearn.metrics import classification_report
import joblib
from sklearn.preprocessing import LabelEncoder
# 💡 tqdmをインポート
from tqdm import tqdm
# 埋め込みリストを効率的に結合するために chain をインポート
from itertools import chain

texts = pl.read_csv("Software-Requirements-Classification/PROMISE.csv")

embeddings = OllamaEmbeddings(base_url="http://192.168.1.200:11434", model="hf.co/bartowski/granite-embedding-278m-multilingual-GGUF:IQ3_M")

# --- 埋め込み処理をtqdmでラップする ---
all_texts = texts["RequirementText"].to_list()
batch_size = 32  # 適切なバッチサイズを設定 (モデルや環境に合わせて調整)
text_embeddings = []

# tqdmで全テキストをバッチに分けて処理し、進捗を表示
for i in tqdm(range(0, len(all_texts), batch_size), desc="Embedding Texts"):
    # バッチを取得
    batch = all_texts[i:i + batch_size]
    
    # 埋め込みを計算
    batch_embeddings = embeddings.embed_documents(batch)
    
    # 結果の埋め込みをリストに追加
    text_embeddings.extend(batch_embeddings)

X = np.array(text_embeddings)

y = texts["_class_"].to_list()

le = LabelEncoder()

y_encoded = le.fit_transform(y)

# X (NumPy配列) と y_encoded (NumPy配列) を使って train_test_split を実行
X_train, X_test, y_train, y_test = train_test_split(
    X, y_encoded, test_size=0.2, random_state=42, stratify=y_encoded
)

target_names = le.classes_

svm_classifier = SVC(kernel='linear', random_state=42, class_weight='balanced')
svm_classifier.fit(X_train, y_train)
y_pred = svm_classifier.predict(X_test)
report = classification_report(y_test, y_pred, target_names=target_names)
print(report)

print("Classes:", target_names)
print("Label mapping:", dict(zip(target_names, range(len(target_names)))))
print("Number of classes:", len(target_names))

print("Saving the LabelEncoder to 'label_encoder.joblib'...")
joblib.dump(le, "Models/label_encoder.joblib")

print("Saving the trained SVM model to 'svm_model.joblib'...")
joblib.dump(svm_classifier, "Models/svm_model.joblib")

このコードのポイントを解説します。

  • ライブラリのインポート:

    • polars: 高速なデータフレーム操作ライブラリ。CSVの読み込みに使用します。
    • langchain_community.embeddings.OllamaEmbeddings: Ollama経由でローカルLLM(埋め込みモデル)を呼び出すためのLangChainコンポーネントです。
    • numpy: 数値計算ライブラリ。埋め込みベクトルをSVMが扱える形式に変換するために使用します。
    • sklearn: 機械学習ライブラリ。SVC(SVM分類器)、train_test_split(データ分割)、classification_report(評価指標の計算)などを使います。
    • joblib: 学習済みモデルやオブジェクトをファイルに保存・読み込みするために使用します。
    • tqdm: 長い処理の進捗状況をプログレスバーで表示します。
  • データの準備:

    • pl.read_csv: polarsを使ってCSVから要求仕様のテキストとラベルを読み込みます。
    • OllamaEmbeddings: ローカルで動作させているOllamaサーバー(http://192.168.1.200:11434)上の埋め込みモデルを指定します。
    • バッチ処理: 全テキストを一度に処理するのではなく、batch_size(ここでは32)ごとに区切って埋め込みを計算しています。これにより、メモリ消費を抑え、安定した処理を実現します。tqdmで進捗が可視化されるので、処理時間を把握しやすくなります。
    • LabelEncoder: _class_列にあるカテゴリカルなラベル(例: "F", "SE")を、機械学習モデルが扱える数値(0, 1, 2...)に変換します。
  • モデルの学習と評価:

    • train_test_split: データを訓練用(80%)とテスト用(20%)に分割します。stratify=y_encodedを指定することで、分割後のデータセットでも元のラベルの比率が維持されるため、不均衡データにおいて特に重要です。
    • SVC: サポートベクター分類器を初期化します。
      • kernel='linear': 線形カーネルを使用。データが線形分離可能であるという仮定に基づいています。
      • class_weight='balanced': クラスのサンプル数に応じて自動的に重みを調整し、不均衡データの影響を緩和します。サンプル数が少ないクラスの誤分類ペナルティが重くなります。
    • svm_classifier.fit: 訓練データを使ってSVMモデルを学習させます。
    • classification_report: テストデータでの予測結果(y_pred)と正解ラベル(y_test)を比較し, Precision, Recall, F1-scoreなどの評価指標を算出します。
  • モデルの保存:

    • joblib.dump: 学習済みのSVMモデル(svm_classifier)とLabelEncoderle)をファイルとして保存します。これにより、後で推論を行う際に再学習することなく、これらのオブジェクトを再利用できます。

結果と考察

テスト結果としては以下の結果を得ました。

precision recall f1-score support
A 1.00 0.67 0.80 6
F 0.80 0.93 0.86 89
FT 1.00 0.50 0.67 4
L 1.00 0.33 0.50 3
LF 0.73 0.80 0.76 10
MN 0.50 0.80 0.62 5
O 0.57 0.50 0.53 16
PE 0.86 0.92 0.89 13
PO 0.00 0.00 0.00 2
SC 0.60 0.75 0.67 4
SE 0.90 0.72 0.80 25
US 1.00 0.47 0.64 17
accuracy 0.78 194
macro avg 0.75 0.62 0.64 194
weighted avg 0.80 0.78 0.77 194

2. 推論フェーズ

import numpy as np
import joblib
from langchain_community.embeddings import OllamaEmbeddings

# 保存したモデルとエンコーダーを読み込む
label_encoder = joblib.load("Models/label_encoder.joblib")
svm_classifier = joblib.load("Models/svm_model.joblib")

# 埋め込みモデルを初期化
embeddings = OllamaEmbeddings(
    base_url="http://192.168.1.200:11434", 
    model="hf.co/bartowski/granite-embedding-278m-multilingual-GGUF:IQ3_M"
)

def classify_requirement(requirement_text: str) -> str:
    """
    与えられた要求仕様テキストを分類し、クラス名を返す。
    """
    # 1. テキストを埋め込みベクトルに変換
    req_embedding = embeddings.embed_query(requirement_text)
    
    # 2. scikit-learnモデルの入力形式 (2D配列) に変換
    req_embedding_np = np.array(req_embedding).reshape(1, -1)
    
    # 3. SVMモデルで予測(結果は数値ラベル)
    predicted_label_encoded = svm_classifier.predict(req_embedding_np)
    
    # 4. 数値ラベルを元のクラス名("F", "SE"など)に変換
    predicted_label = label_encoder.inverse_transform(predicted_label_encoded)
    
    return predicted_label[0]

if __name__ == "__main__":
    test_requirement = "The system shall allow users to reset their passwords via email."
    predicted_class = classify_requirement(test_requirement)
    print(f"The requirement '{test_requirement}' is classified as: {predicted_class}")

この推論コードの流れは以下の通りです。

  1. モデルの読み込み: joblib.load を使い、学習時に保存した svm_model.jobliblabel_encoder.joblib を読み込みます。
  2. テキストのベクトル化: classify_requirement 関数は、入力された新しいテキスト requirement_textembeddings.embed_query でベクトルに変換します。
  3. 形式の変換: scikit-learnの predict メソッドは単一のサンプルでも2次元配列 (n_samples, n_features) を期待するため、np.array(req_embedding).reshape(1, -1) を使って形式を整えます。
  4. 予測: svm_classifier.predict で分類を実行します。戻り値は [2] のような数値の配列です。
  5. ラベルの復元: label_encoder.inverse_transform を使って、数値ラベルを元の "F" や "SE" といった人間が読めるクラス名に変換し、結果を返します。

if __name__ == "__main__": ブロックは、このスクリプトが直接実行された場合に動作するサンプルコードで、動作確認に便利です。

分類パフォーマンスの分析

  1. 全体的なパフォーマンス

Accuracy (正解率): 0.78 (78%)

Weighted Avg F1-score: 0.77

多数派クラスが全体の結果を強く牽引していますが、多数派クラスを含めた全体の性能はまずまずです。

  1. 多数派クラス(サポート数が多いクラス)

F (サポート数 89):

F1-score: 0.86 と非常に良好です。Recall (0.93) が高く、ほとんどのFクラスのサンプルを見逃していません。Precision (0.80) も許容範囲です。

SE (サポート数 25):

F1-score: 0.80。Precision (0.90) は高いですが、Recall (0.72) はFより低く、一部のSEを見逃していることが示唆されます。

PE (サポート数 13):

F1-score: 0.89。Precision (0.86) と Recall (0.92) の両方が高く、非常にうまく分類できています。

US (サポート数 17):

Precision (1.00) は完璧ですが、Recall (0.47) が極端に低く、USクラスのサンプルを半分以上見落としています。これはモデルがこのクラスに対して非常に慎重(高精度)ですが、多くのサンプルを別のクラスに誤分類(低再現率)していることを意味します。

O (サポート数 16):

F1-score: 0.53。Precision (0.57) と Recall (0.50) が共に低く、分類が苦手なクラスの一つです。

  1. 少数派クラス(サポート数が少ないクラス)

サポート数が10以下のクラスは、評価指標が不安定になりがちです。

クラス Precision Recall F1-Score Support 評価
A 1.00 0.67 0.80 6 高精度ですが、Recall改善の余地あり。
FT 1.00 0.50 0.67 4 高精度ですが、Recallが低く、見落としが多い。
L 1.00 0.33 0.50 3 極めて高い精度ですが、Recallが非常に低く、ほとんど見落とし。
LF 0.73 0.80 0.76 10 バランスの良い性能。
MN 0.50 0.80 0.62 5 高再現率ですが、Precisionが低く、他のクラスをMNだと誤分類しがち。
SC 0.60 0.75 0.67 4 低精度/高再現率。
PO 0.00 0.00 0.00 2 完全に分類失敗。2つのPOサンプルはすべて誤分類されました。

結論と今後の展望

今回の実験を通じて、SVG論文に触発された「埋め込みベクトル + SVM」というアプローチが、文書分類タスクにおける強力なベースラインとなり得ることが確認できました。Accuracy 78% という結果は、決して悪くありません。特に、class_weight='balanced' オプションが、ある程度のデータ不均衡を吸収し、全体的な性能向上に寄与していることが伺えます。

しかし、結果の詳細な分析から、このモデルの明確な弱点も見えてきました。
最大の課題は、極端な少数派クラスの扱いです

  • Recall(再現率)の低さ: サンプル数が1桁台のクラス(PO, L, FTなど)では、モデルがそのクラスを正しく検出する能力(Recall)が著しく低いことがわかります。特に、2サンプルしかないPOクラスは全く検出できていません(Recall 0.00)。
  • class_weightの限界: class_weight='balanced'は万能ではなく、サンプル数が極端に少ない場合は、そのクラスのパターンを学習するのに十分な情報が得られず、効果が限定的になることが示唆されました。

この実験は、SVGのポテンシャルの一端を示すと同時に、古典的な機械学習が直面する「データ不均衡」という根深い課題を改めて浮き彫りにしました。

次に試すべき改善策

このベースラインモデルを改善するために、以下の方向性が考えられます。

1. ハイパーパラメータチューニング

SVCの正則化パラメータCを調整することで、誤分類へのペナルティを制御し、モデルの汎化性能を改善できる可能性があります。GridSearchCVなどを用いた探索が有効です。

2. カーネルの検討

現在は線形カーネル (kernel='linear') を使用していますが、埋め込み空間がより複雑な構造を持つ可能性も考慮し、非線形な境界を学習できるRBFカーネル (kernel='rbf') を試す価値は高いでしょう。その際は、パラメータgammaも同時にチューニングする必要があります。

3. サンプリング戦略によるクラス不均衡の是正

class_weightだけでは不十分だった少数派クラスに対して、より直接的なアプローチとしてオーバーサンプリング手法 SMOTE (Synthetic Minority Over-sampling Technique) の導入が考えられます。SMOTEは、少数派クラスのデータポイント間に新しい合成サンプルを生成することで、データの不均衡を物理的に緩和します。

ただし、SMOTEはノイズを増幅させるリスクもあるため、必ず訓練データにのみ適用し、交差検証(Cross-validation)を通じてその効果を慎重に評価する必要があります。

4. 能動学習(Active Learning)による反復的なモデル改善

SVG論文の核心は、MCMCサンプリングを用いて「決定境界に最も寄与するデータ」を動的に生成する点にありますが、このアプローチは特許の問題を抜きにしても、実装とチューニングの難易度が極めて高いという現実的な課題があります。

論文を読むと、その複雑さが伺えます。

  • 膨大な計算コスト: MCMCの1ステップごとにSVMの再最適化が要求され、計算コストが非常に高くなります。
  • 複雑な確率計算: サンプルの受理率を計算するために、通常は不要な「逆方向の遷移確率」を推定する必要があり、論文でも近似式を導入しています。
  • 安定化の難しさ: 論文の「再現性」の章では、このMCMCを安定させるためのおびただしい数のヒューリスティクス(並列チェーン、バーンイン期間など)が記述されており、調整が「魔境」であることが示唆されています。

そこで、SVG論文の著者自身も「今後の方向性」として言及している、よりシンプルで実用的な代替案が**能動学習(Active Learning)**です。これは、モデル自身が「次にどのデータを学習すべきか」を判断する手法で、「データ同化」の思想をより平易に実現します。

具体的なプロセスは以下の通りです。

  1. 初期モデルの学習: まずは現状の通り、少数の教師ありデータでSVMを学習させます。
  2. 候補データの生成: 学習に用いるLLMを使い、分類タスクに関連しそうなテキストを大量に生成します。これは単純な生成タスクです。
  3. 「不確実な」データの選別: 初期モデル(SVM)を使い、生成した大量の候補データを分類させます。このとき、予測の確信度が最も低いサンプル、すなわち決定境界に最も近いサンプルdecision_functionの値が0に近いもの)を選び出します。これらが、モデルが最も「迷っている」データです。
  4. データの「同化」: 選別した不確実なサンプルを、新たな教師データとして訓練セットに追加します。最もシンプルな方法は、モデルの予測結果をそのままラベルとして付与する「自己訓練(Self-training)」です。
  5. モデルの再学習: 新しくデータが追加された訓練セット全体で、SVMを再学習させます。

この「生成→選別→再学習」のループを反復することで、複雑な確率計算を伴わずに、決定境界を効率的に洗練させ、モデルの精度を継続的に向上させることが期待できます。

将来的な展望:Active Learningによるモデルの継続的改善

今回の実験は、静的なデータセットに対する一度の学習で完結するものでした。しかし、SVGの元論文で示唆されているように、より実践的なアプローチとして Active Learning(能動学習) の導入が考えられます。これは、一種の「データ同化」の考え方に基づいています。

具体的には、以下のようなサイクルを構築します。

  1. 現在のモデルが最も分類に迷ったデータ(例えば、SVMの決定境界に非常に近いサンプル)を特定します。
  2. それらのサンプルに人間が(あるいはより強力なモデルが)再度ラベルを付け、質の高い教師データとして追加します。
  3. 新しいデータセットでモデルを再学習させ、性能を向上させます。

このアプローチの利点は、手当たり次第にデータを集めるのではなく、モデルの性能向上に最も寄与するデータを効率的に選択できる点にあります。LLMをこの「どのデータが学習に有効か」を見極めるプロセスに活用することで、より少ないアノテーションコストで、継続的に分類器を賢くしていくことが可能になります。

データが不均衡な状況においても、どの少数派クラスのサンプルを追加すれば効果的かを見極める手助けとなり、今回の実験で課題となった少数派クラスの分類精度を重点的に改善できる可能性があります。これは、実世界の変化し続けるデータに対応するための、非常に現実的で強力な戦略と言えるでしょう。

Discussion