ノートブック: Google Colab で開く / GitHub

TL;DR

  • 国立国会図書館サーチAPI(SRU)を用いて617件の書誌データを収集
  • llm-jp-3-1.8b に LoRA(全パラメータの0.67%)を適用し、書名からNDC第1次区分への分類を学習
  • 学習前 22.0% → 学習後 78.0%(+56ポイント)
  • LoRAはドメイン知識の注入ではなく、タスク遂行のための振る舞いを獲得させる手法

NDC(日本十進分類法)とは

日本の図書館で広く使われている書籍の分類体系です。すべての本に0〜9の第1次区分(類目)が割り当てられます。

NDCジャンル
0総記(百科事典・情報学など)
1哲学・宗教
2歴史・地理
3社会科学(法律・経済・教育)
4自然科学(数学・物理・医学)
5技術・工学
6産業(農業・商業・運輸)
7芸術・スポーツ
8言語
9文学

図書館において資料の整理(目録作成)時にNDCコードを付与する作業は、主題分析の専門的知識を要する業務です。書名のみから大まかな分類を自動推定できるモデルがあれば、分類作業の初期スクリーニングとして有用です。

LoRAとは何か

LoRA(Low-Rank Adaptation)は、大規模言語モデルを効率的にファインチューニングするための手法です。

通常のファインチューニングではモデルの全パラメータ(18億個など)を更新しますが、LoRAでは元のモデルを凍結し、Attention層に小さな「アダプター」行列を挿入してそこだけを学習させます。

モデル本体 (18億パラメータ)  →  凍結(更新対象外)
LoRAアダプター (数百万パラメータ)  →  学習対象

今回の設定では全パラメータの約0.67%(12,582,912 / 1,880,197,120)だけを学習対象にしています。これにより、GPUメモリの消費を抑えつつ、タスク特化の性能を得ることができます。

Step 1. 国立国会図書館サーチAPIからデータ取得

国立国会図書館サーチのSRU APIは誰でも無料で利用可能です。各NDCカテゴリから最大80件ずつ取得し、タイトル文字数(3〜80文字)によるフィルタリング後に合計617件の書誌データを収集しました。カテゴリごとの取得件数は以下の通りで、均等ではありません。

NDCカテゴリ取得件数
0総記65件
1哲学67件
2歴史73件
3社会科学59件
4自然科学52件
5技術・工学63件
6産業65件
7芸術・スポーツ57件
8言語67件
9文学49件

なお、APIの特性上、取得される書誌には書名が極めて短いものや内容が判別しにくいものも含まれるため、学習データの品質には一定のノイズが存在します。

NDC_NAMES = {
    "0": "総記", "1": "哲学", "2": "歴史",
    "3": "社会科学", "4": "自然科学", "5": "技術・工学",
    "6": "産業", "7": "芸術・スポーツ", "8": "言語", "9": "文学",
}

def fetch_ndl_books(ndc_digit, count=80, start=1):
    """NDLサーチSRU APIから指定NDCの書誌を取得する"""
    base_url = "https://ndlsearch.ndl.go.jp/api/sru"
    query = f'ndc="{ndc_digit}*"'
    params = (
        f"?operation=searchRetrieve"
        f"&query={urllib.parse.quote(query)}"
        f"&maximumRecords={count}"
        f"&startRecord={start}"
        f"&recordSchema=dcndl"
    )
    url = base_url + params

    req = urllib.request.Request(url)
    with urllib.request.urlopen(req, timeout=30) as resp:
        xml_text = resp.read().decode("utf-8")

    root = ET.fromstring(xml_text)
    books = []
    for record in root.findall(f".//{{{NS_SRW}}}record"):
        # recordData の中身を解析してタイトルとNDCを抽出
        # ...(タイトル3〜80文字のもののみ採用)
        books.append({
            "title": title,
            "ndc": ndc_digit,
            "ndc_name": NDC_NAMES[ndc_digit],
        })
    return books

# 全NDCカテゴリから取得
all_books = []
for digit in "0123456789":
    books = fetch_ndl_books(digit, count=80)
    all_books.extend(books)
    time.sleep(1)  # API負荷軽減

取得したデータをシャッフルした上で、各カテゴリから5件ずつ(計50件)をテストデータとして分離し、残りの567件を学習データとしました。

Step 2. プロンプト設計

モデルに対し、書名からNDCの第1次区分(1桁目)を出力させる分類タスクのプロンプトを設計します。

以下の本のタイトルから、NDC(日本十進分類法)の1桁目を答えてください。

【NDC一覧】
0: 総記
1: 哲学
2: 歴史
3: 社会科学
4: 自然科学
5: 技術・工学
6: 産業
7: 芸術・スポーツ
8: 言語
9: 文学

【タイトル】吾輩は猫である
【NDC】

学習データでは 【NDC】 の後に正解(例: 9)を付加します。推論時はモデルに続きを生成させ、最初に出現する数字を予測値として採用します。

Step 3. モデルとLoRA設定

本実験はGoogle Colab(Tesla T4 GPU)上で実行しました。ベースモデルには日本語特化の小型LLM llm-jp/llm-jp-3-1.8b(パラメータ数: 約18.8億)を使用しました。

MODEL_NAME = "llm-jp/llm-jp-3-1.8b"
model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME, torch_dtype=torch.bfloat16, device_map="auto",
)

LoRAの設定は以下の通りです。

lora_config = LoraConfig(
    r=32,                # LoRAのランク(アダプターの次元数)
    lora_alpha=32,       # スケーリング係数
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],  # Attention全層に適用
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, lora_config)
全パラメータ:   1,880,197,120
学習対象:        12,582,912 (0.67%)
→ 全体の約 149 分の1 だけ学習します

この設定における学習対象パラメータは全体の約0.67%です。r=32 はLoRAのランクであり、アダプター行列の次元数を決定します。ランクを増加させれば表現力は向上しますがメモリ消費も増大するため、本実験では32に設定しました。

Step 4. 学習

TRL(Transformer Reinforcement Learning)ライブラリの SFTTrainer でSFT(Supervised Fine-Tuning)を行います。

training_args = SFTConfig(
    output_dir="./lora_ndc_output",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,   # 実効バッチサイズ: 16
    learning_rate=5e-4,
    num_train_epochs=5,
    logging_steps=10,
    save_strategy="epoch",
    bf16=True,
    dataset_text_field="text",
    report_to="none",
)

trainer = SFTTrainer(
    model=model, train_dataset=train_dataset, args=training_args,
)
trainer.train()

567件の学習データを5エポック(合計180ステップ)学習させています。実効バッチサイズは 4 × 4 = 16 です。Google ColabのTesla T4環境で、学習は数分で完了します。

Training Lossの解釈

Training Loss は、モデルの予測と正解との乖離度を表す指標であり、値が小さいほど良好です。

StepTraining Loss
100.8592
200.5131
300.4557
400.4147
500.3825
600.3772
700.3523
800.3150
900.3094
1000.2815
1100.2545
1200.2198
1300.2311
1400.2286
1500.1983
1600.1681
1700.1775
1800.1766

Lossは5エポック(180ステップ)を通じて0.86から0.18へ低下しました。このLossはクロスエントロピー損失であり、Loss = -log(正解トークンの予測確率) という関係があります。

Loss正解の予測確率解釈
2.30約10%10択ランダム(未学習状態)
0.86約42%Step 10 時点
0.46約63%Step 30 時点
0.18約84%最終ステップ

注意: このLossはプロンプト全体(NDC一覧やタイトル部分を含む)の平均値です。プロンプトの定型部分(「以下の本の〜」など)はすぐ覚えるのでLossが低く出ます。一方、本番の予測対象であるNDCの数字部分のLossは平均より高いはずです。そのため、最終的な正答率はLossの数値から単純には読めません。実際にテストデータで評価するのが確実です。

LoRAが獲得させるもの — 知識ではなくタスク遂行能力

LoRAによる学習で獲得されるのは、NDCの分類体系に関する知識そのものではなく、所定の入力形式に対して適切な出力を生成するタスク遂行能力です。この構図は他のLoRA活用事例にも共通しています。

法律試験の例今回のNDC分類
教えたこと4択問題で a/b/c/d と答える形式タイトルから 0〜9 と答える形式
教えていないこと法令の知識NDC分類の専門知識
結果正答率が向上正答率が向上

すなわち、LoRAは出力形式と入出力の対応関係を効率的に学習させる手法であり、ドメイン知識を新たに付与しているわけではありません。ベースモデルが事前学習で既に獲得している日本語の語彙的知識(「プログラミング」→技術系、「詩集」→文学系など)を、タスクに適した形式で引き出す役割を果たしています。

Step 5. 結果

学習前 vs 学習後

  • 学習前: 11/50正解 = 22.0%(ランダムの期待値10%を上回るものの、出力が不安定)
  • 学習後: 39/50正解 = 78.0%(+56ポイント)

全パラメータの0.67%をLoRAで学習させるのみで、10クラス分類の正答率が22%から78%へ向上しました。

学習前後の出力の違い

学習前後で差が見られるのは、出力形式です。

  • 学習前: モデルは「910.2」「010」「010.3」「369.3」のようなNDCの詳細分類番号や複数桁の数値を出力する傾向があり、1桁の分類番号を返すというタスクの形式を理解できていません
  • 学習後: 出力は「1」「7」「9」のように安定して1桁の数字に収束しており、タスクの出力形式を獲得しています

以下に、テストデータ50件から抽出した学習前後の予測結果を示します(正答・誤答の両方を含みます)。

書名正解学習前学習後
嗚呼孝子元政上人1(哲学)9(文学)1(哲学)
「アーカイブ中核拠点形成モデル事業」(撮影所等に)7(芸術)9(文学)7(芸術)
アーカーシャ年代記より1(哲学)0(総記)1(哲学)
ああ言えばこう食う 往復エッセイ9(文学)9(文学)9(文学)
あゝ愛宕丘の灯:追憶の四十有余年3(社会科学)9(文学)3(社会科学)
アーク溶接作業における粉じん対策に関する調査研究報告4(自然科学)3(社会科学)4(自然科学)
アーキテクチャとプログラミングの基礎4(自然科学)0(総記)5(技術)
ああアメリカ:傷だらけの巨象3(社会科学)9(文学)3(社会科学)

学習前は大半を「9(文学)」や「0(総記)」と予測しており分類能力がほぼないのに対し、学習後は多くのサンプルで正解に到達しています。「アーキテクチャとプログラミングの基礎」のように学習後も誤分類が残るケースは、書名の語彙だけでは技術(5)と自然科学(4)の区別が困難な例です。

カテゴリ別の精度

分類精度にはカテゴリ間で差異が見られます。「哲学」のように書名に特徴的な語彙が含まれやすいカテゴリは高精度で分類される一方、「総記」のように多様な主題を包含するカテゴリでは精度が低下する傾向があります。

Step 6. インタラクティブデモ

学習済みモデルに好きなタイトルを入力して試せます。

def predict_ndc(model, title):
    """タイトルからNDCを予測する"""
    book = {"title": title, "ndc": "?", "ndc_name": "?"}
    prompt = make_prompt(book, include_answer=False)
    inputs = tokenizer(prompt, return_tensors="pt").to(model.device)

    with torch.no_grad():
        outputs = model.generate(
            **inputs, max_new_tokens=5,
            do_sample=False, pad_token_id=tokenizer.pad_token_id,
        )
    generated = tokenizer.decode(
        outputs[0][inputs["input_ids"].shape[1]:],
        skip_special_tokens=True
    ).strip()

    predicted = "?"
    for ch in generated:
        if ch in "0123456789":
            predicted = ch
            break
    return predicted, NDC_NAMES.get(predicted, "不明")

実行例:

タイトル予測ジャンル
吾輩は猫である8言語
相対性理論入門4自然科学
日本経済の構造改革3社会科学
フランス料理の基本技法7芸術・スポーツ
はじめてのPython入門4自然科学
万葉集を読む9文学
西洋美術史7芸術・スポーツ
憲法判例百選3社会科学
英語の語源辞典8言語
鉄道の歴史と未来6産業

「吾輩は猫である」が文学(9)ではなく言語(8)、「はじめてのPython入門」が総記(0)ではなく自然科学(4)と分類されるなど、誤分類も見られますが、書名の語彙情報から概ね妥当な推定が行えていることが確認できます。

実用上の考慮点

応用可能性

  • 図書館での分類支援: 新着図書の仮分類を自動化し、司書の作業負荷を軽減
  • 書店・出版社での棚分類: 書籍の自動カテゴリ分けに応用
  • 蔵書検索の改善: タグ付けが不完全な書誌データの補完

現在の制限

  • NDC第1次区分(1桁)のみの分類: 実務的にはNDC3桁(例: 913=日本の小説、490=医学)の分類精度が求められる。学習データの拡充により対応可能と考えられる
  • 書名のみによる判定: 著者名・出版社・目次等のメタデータを入力に加えることで精度向上が期待される
  • 学習データの偏り: APIから取得可能な書誌データは近年の出版物に偏る傾向がある

今後の発展

  • NDC3桁分類への拡張によりさらなる実用性の向上が見込まれる
  • 著者名・出版社等のメタデータを入力に追加する
  • RAG(Retrieval-Augmented Generation)との併用により、類似書誌の分類情報を参照させる手法も有効と考えられる

謝辞

LoRAについてご紹介いただいた国立国会図書館の青池亨氏に感謝いたします。