小規模LLM文書分類:失敗から学ぶアプローチ選択の重要性
以前の記事では、Granite-278M で生成した埋め込みベクトルをSVMで分類するアプローチ(訓練/テスト分割 80%/20%)が高い性能を示すことを確認しました。
一方で、google/gemma-3-270m-it を使った文書分類の実験も過去に行っていましたが、そちらは訓練データを98%使用するなど、実験条件が異なっており、SVMの手法と公正に比較することができませんでした。
そこで本記事では、実験条件を埋め込み+SVMの手法と統一するため、gemma-3-270m-it を用いた文書分類の追加実験を行った結果を報告します。具体的には、訓練/テスト分割を80%/20%に設定し、gemma を使った2つのアプローチを試みました。
- 分類ヘッドの追加: 過去の実験の再評価。
- 生成タスクとしてのファインチューニング: 新たな試み。
この一連の試行錯誤の過程と、最終的に「埋め込み+SVM」が最も優れていたという結論を共有します。
最初の試み:gemma に分類ヘッドを追加する
まず最初に試したのが、gemma-3-270m-it モデルに無理やり分類ヘッドを接続し、AutoModelForSequenceClassification として扱うアプローチでした。この実験の詳細は記事「文書分類の実験」にまとめていますが、結果は以下の通り、惨憺たるものでした。
| precision | recall | f1-score | support | |
|---|---|---|---|---|
| A | 0.00 | 0.00 | 0.00 | 2 |
| F | 0.48 | 1.00 | 0.65 | 24 |
| FT | 0.00 | 0.00 | 0.00 | 1 |
| L | 0.00 | 0.00 | 0.00 | 1 |
| LF | 0.00 | 0.00 | 0.00 | 2 |
| MN | 0.00 | 0.00 | 0.00 | 2 |
| O | 0.00 | 0.00 | 0.00 | 2 |
| PE | 0.00 | 0.00 | 0.00 | 5 |
| PO | 0.00 | 0.00 | 0.00 | 1 |
| SE | 0.00 | 0.00 | 0.00 | 5 |
| US | 0.00 | 0.00 | 0.00 | 5 |
| accuracy | 0.48 | 50 | ||
| macro avg | 0.04 | 0.09 | 0.06 | 50 |
| weighted avg | 0.23 | 0.48 | 0.31 | 50 |
weighted avg の f1-score は 0.31。これは、モデルがほとんどのクラスを学習できず、多数派クラスである F に予測が偏ってしまった結果です。gemma-it は対話用にファインチューニングされており、その内部表現が分類タスクに適していなかったため、分類ヘッドを付けても機能しなかったと考えられます。
次の試み:生成タスクとしてファインチューニングする
分類ヘッドがダメなら、gemma-it が得意な「生成」タスクとして解けないか?と考えました。そこで、文書分類を「対応するクラス名を生成する」タスクとして設定し、LoRAを用いてファインチューニングを行いました。
!pip install torch tensorboard
!pip install transformers
!pip install datasets
!pip install evaluate
!pip install wandb
!pip install peft
!pip install trl
!pip install bitsandbytes
!pip install accelerate
!pip install protobuf
!pip install sentencepiece
from huggingface_hub import login
login(new_session=False)
import datasets
import pandas as pd
from sklearn.model_selection import train_test_split
# データフレームをロードしてクラス名を取得
data_file = "/content/drive/MyDrive/Data/PROMISE.csv"
df = pd.read_csv(data_file)
df_train, df_test = train_test_split(
df,
test_size=0.2, # テストデータの割合を20%に指定
stratify=df['_class_'], # _class_列の分布を維持するよう指定 (層化サンプリング)
random_state=42 # 再現性のためのシード値
)
dataset = []
for index, row in df_train.iterrows():
dataset.append({
"messages": [
{"role": "user", "content": row['RequirementText']},
{"role": "assistant", "content": row['_class_']}
]
})
# Convert the list to a Hugging Face dataset
from datasets import Dataset
hf_dataset = Dataset.from_list(dataset)
hf_dataset_splitted = hf_dataset
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
model_id = "google/gemma-3-270m-it"
# 4ビット量子化の設定
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type='nf4',
bnb_4bit_compute_dtype=torch.bfloat16,
)
# モデルをロード
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=quantization_config,
device_map="auto",
attn_implementation='eager' # Added for performance recommendation
)
# トークナイザーをロード
tokenizer = AutoTokenizer.from_pretrained(model_id)
from peft import LoraConfig
from trl import SFTConfig
FINAL_MODEL_DIR = "/content/drive/MyDrive/Models/TextAnalysis/gemma3-softreq-ex002a"
# LoRAの設定
peft_config = LoraConfig(
lora_alpha=16,
lora_dropout=0.05,
r=16,
bias="none",
target_modules="all-linear", # すべての線形層を対象にする
task_type="CAUSAL_LM",
)
# 学習のハイパーパラメータ
args = SFTConfig(
output_dir=FINAL_MODEL_DIR, # 学習済みモデルの保存先
num_train_epochs=3,
per_device_train_batch_size=1,
gradient_accumulation_steps=4,
optim="adamw_torch_fused",
learning_rate=2e-4,
logging_steps=10,
save_strategy="epoch",
# ... その他の設定
)
from trl import SFTTrainer
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=hf_dataset_splitted,
peft_config=peft_config,
# tokenizer=tokenizer, # Removed the tokenizer argument
)
# 学習開始!
trainer.train()
# モデルの保存
trainer.save_model()
model = AutoModelForCausalLM.from_pretrained(
FINAL_MODEL_DIR,
device_map="auto",
torch_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained("/content/drive/MyDrive/Models/TextAnalysis/gemma3-softreq-ex002")
from transformers import pipeline
pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)
texts = []
predicted_class = []
actual_class = []
for index, row in df_test.iterrows():
text = row['RequirementText']
messages = [
{"role": "user", "content": text},
]
prompt = pipe.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
# テキスト生成
outputs = pipe(
prompt,
max_new_tokens=256,
do_sample=True,
temperature=0.7,
top_k=50,
top_p=0.95
)
result = outputs[0]['generated_text'][len(prompt):].strip()
texts.append(text)
predicted_class.append(result)
actual_class.append(row['_class_'])
df1 = pd.DataFrame({
'text': texts,
'predicted_class': predicted_class,
'actual_class': actual_class,
})
# Calculate accuracy
accuracy = (df1['predicted_class'] == df1['actual_class']).mean()
print(f"The accuracy is: {accuracy:.4f}")
from sklearn.metrics import classification_report
print(classification_report(df1['actual_class'], df1['predicted_class']))
結果
このアプローチの結果は以下の通りです。
| precision | recall | f1-score | support | |
|---|---|---|---|---|
| A | 0.00 | 0.00 | 0.00 | 6 |
| F | 0.75 | 0.89 | 0.81 | 89 |
| FT | 0.00 | 0.00 | 0.00 | 4 |
| L | 0.00 | 0.00 | 0.00 | 3 |
| LF | 0.50 | 0.10 | 0.17 | 10 |
| M | 0.00 | 0.00 | 0.00 | 0 |
| MN | 0.00 | 0.00 | 0.00 | 5 |
| O | 1.00 | 0.12 | 0.22 | 16 |
| PE | 0.83 | 0.38 | 0.53 | 13 |
| PO | 0.00 | 0.00 | 0.00 | 2 |
| SC | 0.00 | 0.00 | 0.00 | 4 |
| SE | 0.44 | 0.96 | 0.60 | 25 |
| US | 0.73 | 0.47 | 0.57 | 17 |
| accuracy | 0.61 | 194 | ||
| macro avg | 0.30 | 0.21 | 0.21 | 194 |
| weighted avg | 0.63 | 0.61 | 0.56 | 194 |
f1-score は 0.56 となり、最初の試み (0.31) よりは改善が見られました。いくつかのクラスを部分的に学習できるようになったものの、依然として多くのクラスを分類できておらず、実用的なレベルには程遠い結果です。
比較と考察:なぜ gemma は失敗したのか
ここで、最終的に成功したアプローチ、すなわち別記事で報告した「Granite-278M + SVM」の結果 (f1-score: 0.77) と比較します。
-
アプローチ1 (
gemma+ 分類ヘッド): f1-score0.31 -
アプローチ2 (
gemma生成タスク化): f1-score0.56 -
アプローチ3 (
Granite+ SVM): f1-score0.77
同じ270Mクラスのモデルを使いながら、なぜここまで差が開いたのでしょうか。
-
タスクの専門性:
Granite-278Mは、テキストの意味をベクトル空間にマッピングする「埋め込み生成」に特化して訓練されています。対してgemma-3-270m-itは汎用的な対話モデルです。文書分類というタスクにおいては、最初から意味の類似性を捉えるように訓練された埋め込み特化モデルの方が、質の高い特徴量を抽出できたと考えられます。 -
タスク設定の難易度: 分類ヘッドの追加はモデルの内部表現とミスマッチを起こし、生成タスク化は分類タスクそのものより難易度が高くなります。一方、SVMはベクトル空間内での境界線を見つけるという純粋な「分類」問題に集中できます。このタスク設定の違いが、結果に大きく影響したと推測されます。
-
小規模モデルの限界:
270Mというパラメータサイズでは、LoRAによる効率的なチューニングをもってしても、モデルの汎用的な能力を維持しつつ、13クラス分類という新しいタスクに完全に対応させるのは困難だったようです。特に、元のモデルが持たない能力を後から注入するには限界があり、結果として中途半端な学習に終わってしまったと考えられます。
結論:小規模モデル活用の教訓
今回の失敗から、小規模言語モデル(SLM)を特定のタスクに適用する際の重要な教訓が得られました。
「餅は餅屋」 — この一言に尽きます。
計算リソースが限られている場合や、手元のSLMでタスクを解こうとする場合、モデルを直接ファインチューニングするアプローチが常に最適とは限りません。
むしろ、
- タスクをより小さなサブタスク(例:意味のベクトル化、ベクトルの分類)に分割する。
- それぞれのサブタスクに特化した、軽量で高性能なツール(例:埋め込み特化モデル、SVM)を選択する。
という古典的ながら堅実なアプローチが、結果として高い性能と安定性を実現する場合があります。
今回の実験は、LLMのファインチューニングが万能ではないこと、そして適切な問題設定とツール選択の重要性を改めて示してくれました。小規模モデルで思うような性能が出ない場合は、一度立ち止まって、アプローチそのものを見直してみる価値があるでしょう。
Discussion