0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルLLMで法令QAを解かせてみた 生成タスク+英訳で精度が2倍になった話

Posted at

背景と課題意識

法令QAデータセットが下記で公開された。
digital-go-jp/lawqa_jp

中身を見てみる限り、 クラウドLLM+RAG構成 を前提に設計されている模様。
セキュリティ要件やコスト面の理由から 閉域環境でLLMを動作させたい場合
そのままでは適用しにくい。

また、ローカルで動作するLLM(特にT5系やFLAN系)は、
モデルによって得意タスクが異なる。
そのため、各モデルの特性を踏まえた実験的検証を行うことにした。

あとはローカルでやるとどうなるんだろうという純粋な好奇心である。


目的

今回の目的は次の2点である。

  1. ローカルLLM(T5系/FLAN系)で法令QAをどこまで解けるか実測する。
  2. 精度向上の余地がどこにあるかを確かめる。

使用データセット

出典:digital-go-jp/lawqa_jp
今回使用したのはselection.csvのみ。

ファイル名 説明
selection.csv 検証に使用。設問文・選択肢・正答を含む。
selection_randomized.json 選択肢順をランダム化し、順序依存性を検証。
law_list.json 設問で参照される法令一覧。

実験環境

項目 内容
実行環境 Google Colab
フレームワーク PyTorch / Transformers
GPU T4(自動切り替え)
モデル t5-small / t5-base / flan-t5-base / flan-t5-large

推論と生成による分類

1. T5系:尤度関数による「推論」

t5系では回答を生成しても空欄が返されるなど分類タスクをうまく反映できなかったため、確からしさ(尤度)による推論→結果出力方式を採用した。

これは回答を生成するのではなく、「もし回答がaだったらどのくらい自信がある(確からしい)?」を各回答で比較している。つまり、
プロンプト→LLM→回答がaの場合の尤度10%
プロンプト→LLM→回答がbの場合の尤度5%
・・・
と尤度をスコアリングし最も自身のあるものを結果として出す。

  • 利点:再現性が高く、解釈しやすい。
  • 欠点:生成指示に弱く、文脈理解が浅い場合スコアが平坦化する。

2. FLAN-T5系:「生成」による回答

flan-t5-base / flan-t5-large では、タスクを 生成タスク として扱い、
モデルに「答えをJSON形式で生成せよ」と指示した。

JSON形式にすることで、出力形式を安定させやすい。

  • 利点:自然言語理解が強く、指示追従性が高い。
  • 欠点:出力フォーマット崩壊のリスクがあり、正規表現などの後処理が必要。

3. 翻訳ステップ(flan-t5のみ)

FLAN系は日本語文の推論精度が低いため、
元データを英語に翻訳してから分類を行った。

この「翻訳→生成」パイプラインが、最終的に最も高い精度を示した。


実験結果

モデル 方式 言語 Accuracy F1 備考
t5-small 尤度分類 日本語 0.220 0.220 単語理解が浅い
t5-base 尤度分類 日本語 0.200 0.200 文脈保持が弱い
flan-t5-base 生成 日本語 0.200 0.200 指示理解あり
flan-t5-large 生成+英訳 英語 0.400 0.400 翻訳で精度倍増

t5-small(尤度分類)

Accuracy: 0.220, F1: 0.220
Per class: {'a': 0.311, 'b': 0.261, 'c': 0.095, 'd': 0.0}

Confusion Matrix:
    pred →  a  b  c  d
a [7 2 0 0]
b [8 3 0 0]
c [11  7  1  1]
d [10  0  0  0]

aに多く偏ってしまっている。スコアにほとんど差がないことで、一番最初の選択肢に多く寄っている。


t5-base(尤度分類)

Accuracy: 0.200, F1: 0.200
Per class: {'a': 0.261, 'b': 0.0, 'c': 0.0, 'd': 0.304}

Confusion Matrix:
    pred →  a  b  c  d
a [3 0 0 6]
b [4 0 0 7]
c [4 0 0 16]
d [3 0 0 7]

こちらもaかdに偏っている。


flan-t5-base(生成)

Accuracy: 0.200, F1: 0.200
Per class: {'a': 0.229, 'b': 0.154, 'c': 0.0, 'd': 0.323}

Confusion Matrix:
    pred →  a  b  c  d
a [4 0 0 5]
b [6 1 0 4]
c [13  0  0  7]
d [3 1 1 5]

ある程度ばらついたが、精度としてはt5系とほとんど変わらず。


flan-t5-large(生成+英訳)

Accuracy: 0.400, F1: 0.400
Per class: {'a': 0.516, 'b': 0.452, 'c': 0.250, 'd': 0.286}

Confusion Matrix:
    pred →  a  b  c  d
a [8 0 1 0]
b [2 7 0 2]
c [9 8 3 0]
d [3 5 0 2]

元の文章も英語にすると、精度が倍に向上。


分析

観点 傾向
t5系(推論) 尤度差(確からしさ)が小さく、選択肢識別が曖昧。単語単位の理解に弱い。
flan-t5系(生成) 指示追従性は高いが、日本語入力では出力のばらつきが大きい。
翻訳効果 英訳により論理構造が明確化し、文法的ノイズが減少。精度が約2倍に向上。

考察

  • 翻訳が決定的に効いた。
    英語化するだけでAccuracyが40%に到達。
    日本語タスクでは、曖昧な表現がノイズとして精度を阻害していた可能性がある。

  • モデルの大小がそのまま精度に反映されるとは限らない。

  • flanの方が分類タスクに強いとされていたが、英語にしないとt5と精度が変わらないこと、問題文含め英語にすることで日本語の内容でも精度があがり、適切な使用が精度向上に資する。


改善の方向性

  • 今回は zero-shot のみ。few-shot設定を試せばさらに改善が見込める。
  • RAG構成(法令本文を参照させる)で、文脈依存の解答精度が向上する可能性。
  • Markdown構造を含む設問文では、余計な装飾情報がノイズ化していたため、
    前処理のテキスト整形が今後の課題。

まとめと感想

  • T5系列:尤度ベース分類では20%前後が限界。
  • FLAN系列:生成タスク+英訳で**精度が約2倍(40%)**に向上。
  • 意外だったのは翻訳をかますと英訳でも精度が上がるということだった。

実行コード全文

以下はGoogle Colab上で実行したコードの全体。
Colab環境でそのまま再現できる。

# Google Driveをマウント
from google.colab import drive
drive.mount('/content/drive')

# Google Colab環境用のセットアップ
!pip install datasets evaluate rouge_score loralib peft transformers torch --quiet
!pip install deep-translator

# ライブラリのインポート
import os
import json
import numpy as np
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer, GenerationConfig
import re
from typing import List, Dict, Any
import warnings
warnings.filterwarnings('ignore')
import torch, pandas as pd, re
import seaborn as sns
import matplotlib.pyplot as plt
from sklearn.metrics import f1_score, accuracy_score, confusion_matrix
from deep_translator import GoogleTranslator

# デバイス設定
device = "cuda" if torch.cuda.is_available() else "cpu"

# データセットの読み込み関数
def load_dataset_from_csv(file_path: str) -> List[Dict[str, Any]]:
    df = pd.read_csv(file_path)
    return df[['コンテキスト','指示','問題文','選択肢','output']].to_dict('records')

# モデルとトークナイザの読み込み関数
def load_model_and_tokenizer(model_name="google/flan-t5-large"):
    model = AutoModelForSeq2SeqLM.from_pretrained(model_name, torch_dtype=torch.bfloat16).to(device)
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    return model, tokenizer

# 選択肢テキストのパース関数
def parse_choices(choice_text: str) -> dict:
    """
    CSVの「選択肢」列から a,b,c,d の辞書を作成
    """
    pattern = r"([abcd])[\s::..、,)]\s*(.+?)(?=(?:\n[abcd][\s::..、,)]|$))"
    matches = re.findall(pattern, choice_text.strip(), flags=re.S)
    return {k: v.strip() for k, v in matches}

# プロンプト生成関数
def create_prompt(c, i, q, ch_dict):
    return (
        "Read the following context and question, then choose the correct option.\n\n"
        f"Context:\n{c}\n\n"
        f"Instruction:\n{i}\n\n"
        f"Question:\n{q}\n\n"
        "Choices (in JSON):\n"
        f"{json.dumps(ch_dict, ensure_ascii=False, indent=2)}\n\n"
        "Output your answer as JSON in the format {\"answer\": \"a\"}.\n"
        "Do not include explanations or extra text."
    )

# 予測関数
@torch.no_grad()
def predict_choice(model, tokenizer, prompt):
    inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512).to(model.device)
    outputs = model.generate(**inputs, max_new_tokens=16)
    pred = tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
    try:
        data = json.loads(pred)
        ans = data.get("answer", "").lower().strip()
        return ans if ans in "abcd" else ""
    except json.JSONDecodeError:
        m = re.search(r"[abcd]", pred.lower())
        return m.group(0) if m else ""

# 日本語テキストを英語に翻訳
def translate_to_en(texts):
    if isinstance(texts, str):
        texts = [texts]
    translator = GoogleTranslator(source='ja', target='en')
    return [translator.translate(t) for t in texts]

# 評価関数
def evaluate(model, tokenizer, data, max_samples=None):
    data = data[:max_samples] if max_samples else data
    y_true, y_pred = [], []
    for s in data:
        context, instruction, question = translate_to_en(
            [s["コンテキスト"], s["指示"], s["問題文"]]
        )
        ch_dict = {k: translate_to_en(v) for k, v in parse_choices(s["選択肢"]).items()}
        p = create_prompt(context, instruction, question, ch_dict)
        ans = predict_choice(model, tokenizer, p)
        y_true.append(s["output"])
        y_pred.append(ans)
    acc = accuracy_score(y_true, y_pred)
    f1 = f1_score(y_true, y_pred, average='micro')
    f1_cls = f1_score(y_true, y_pred, average=None, labels=['a','b','c','d'])
    print(f"Accuracy: {acc:.3f}, F1: {f1:.3f}\nPer class:", dict(zip(['a','b','c','d'], f1_cls)))
    return {"accuracy": acc, "f1": f1, "f1_per_class": dict(zip(['a','b','c','d'], f1_cls))}, y_true, y_pred

# メイン実行
print("Loading dataset...")
data = load_dataset_from_csv('/content/drive/MyDrive/lawqa_jp/data/selection.csv')
print(f"Loaded {len(data)} samples.")

print("\nLoading model...")
model, tokenizer = load_model_and_tokenizer()
print("Model ready.\n")

print("Evaluating...")
res, y_true, y_pred = evaluate(model, tokenizer, data, max_samples=50)

# 混同行列
print("\nConfusion Matrix:")
cm = confusion_matrix(y_true, y_pred, labels=['a','b','c','d'])
print("    pred →  a  b  c  d")
for t, row in zip(['a','b','c','d'], cm):
    print(f"{t} {row}")

# ヒートマップ描画
plt.figure(figsize=(8, 6))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
            xticklabels=['a', 'b', 'c', 'd'],
            yticklabels=['a', 'b', 'c', 'd'])
plt.xlabel('predict')
plt.ylabel('true')
plt.title('heatmap')
plt.show()

推論コード(関数群のみ)

device = "cuda" if torch.cuda.is_available() else "cpu"

def load_dataset_from_csv(file_path: str) -> List[Dict[str, Any]]:
    df = pd.read_csv(file_path)
    return df[['コンテキスト','指示','問題文','選択肢','output']].to_dict('records')

def load_model_and_tokenizer(model_name="google-t5/t5-base"):
    model = AutoModelForSeq2SeqLM.from_pretrained(model_name, torch_dtype=torch.float16).to(device)
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    return model, tokenizer

def create_prompt(c, i, q, ch):
    return f"文脈:{c}\n質問:{q}\n指示:{i}\n選択肢:{ch}\n答えはa,b,c,dのいずれか1文字。"

@torch.no_grad()
# 各回答の尤度を算出する関数
def score_labels_batch(model, tokenizer, prompt, labels):
    inputs = tokenizer([prompt]*len(labels), return_tensors="pt",
                       padding=True, truncation=True, max_length=512).to(model.device)
    with tokenizer.as_target_tokenizer():
        labels_ids = tokenizer(labels, return_tensors="pt", padding=True).input_ids.to(model.device)
    labels_ids[labels_ids == tokenizer.pad_token_id] = -100
    losses = []
    for i in range(len(labels)):
        out = model(**{k: v[i].unsqueeze(0) for k,v in inputs.items()}, labels=labels_ids[i].unsqueeze(0))
        losses.append(out.loss.item())
    return dict(zip(labels, losses))

# 各回答の尤度を計算して最小のものを選ぶ関数
def predict_choice(model, tokenizer, prompt):
    scores = score_labels_batch(model, tokenizer, prompt, ['a','b','c','d'])
    return min(scores, key=scores.get)

def evaluate(model, tokenizer, data, max_samples=None):
    data = data[:max_samples] if max_samples else data
    y_true, y_pred = [], []
    for s in data:
        p = create_prompt(s['コンテキスト'], s['指示'], s['問題文'], s['選択肢'])
        ans = predict_choice(model, tokenizer, p)
        y_true.append(s['output'])
        y_pred.append(ans)
    acc = accuracy_score(y_true, y_pred)
   
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?