🌍

Fine-tuningで軽量LLMにおもろいことを言わせたい ~Multi LoRAによる回答の口調調整~

に公開

はじめに

こんにちは、肉食タンポポです。
最近Finetuningのすばらしさを思い知るタイミングがあり、自分でも実践してみたいと思いまして...
突然、LoRAをうまいこと使いながら、ゴ〇ジャス☆さんのような面白い返答をしてくる化け物(いい意味で)を作ろうと思いました。

初投稿なんですが、このテーマで非常に光栄です。
どこぞの学生です。よろしくお願いします。
そーれっ。

これは何

この記事では次の目的があります。学習コードに興味ない方は、手法&知見あたりだけでも見ていってあげてください。

  • ローカルで動く軽量モデルに面白いギャグを言わせる
  • 軽量モデルにフォーマット指定の出力をさせる
  • LoRA実装のコードを残し、あわよくば誰かの助けに
  • 論文にはならないレベルのアイデアを何らかの形で残す
  • 深夜3時のテンションを無駄にしない

用いた手法・モデル

最近論文をあさっていると、複数LoRAをタスクごとに作りそれを融合し、いろんなタスクに使えるようにする手法が見られ、これは実務でも使われているんだろうなぁと勝手に想像してます。

例:
https://arxiv.org/abs/2312.02515

これらから着想を得て、
フォーマット(ネタ・口調)を学習したLoRA と中身(ロジック・事実)を学習したLoRA を推論時に合成した “多重人格モデル” を作れば、フォーマットを変えて任意の知識を入れたモデルが作れるのでは?
と素人ながらに思いました。
つまり、ネタと事実を別々に覚えさせて、推論時に両方を同時に使わせる

  • 体裁を保ちながら
  • 良い内容を述べる

既存の手法と違う部分を無理やりにでも挙げるなら、フォーマットが独特なのでそれを一つのLoRAに学ばせ、知識は別でLoRAで入れるという、style と task の役割を意味論的に分離して統合学習する点です。

調べるだけでチューニングしたことはないという知ったかぶりな状態だったので次の環境で実際に実行してみます。
※今後、口調やフォーマットのチューニングをstyle, 知識やロジックをtaskとして扱います。

実行環境

項目 内容
実行 Google Colab(無料GPU)
GPU T4(15GB)
ベースモデル Qwen/Qwen2.5-3B-Instruct
量子化 4bit(bitsandbytes / nf4)
学習 SFT(教師あり)×2回(Style / Task)
合成 Weighted Adapter(線形結合)

※無料GPUで回したかったので、4bit量子化+LoRA前提で組んでいます。
だれかGPUください。DM待ってます❤️

今回やることの数学的背景

まず、ざっくりと数式で今回やることをまとめます。

LoRAの基本原理

LoRA (Low-Rank Adaptation) は、事前学習済みモデルの重み行列 W_0 \in \mathbb{R}^{d \times k} を直接更新せず、低ランク分解による差分を学習します。

W = W_0 + \Delta W = W_0 + BA

ここで:

  • B \in \mathbb{R}^{d \times r}:down-projection行列
  • A \in \mathbb{R}^{r \times k}:up-projection行列
  • r \ll \min(d, k):LoRAのランク(今回は r=16

LoRAのスケーリング係数 \alpha を用いると、実際の更新式は:

W = W_0 + \frac{\alpha}{r} BA

今回の設定では \alpha = 32r = 16 なので、スケーリング係数は \frac{32}{16} = 2 となります。

複数LoRAの線形結合

2つのLoRAアダプタ(style と task)を重み付き線形結合で統合する場合:

W_{\text{merged}} = W_0 + w_{\text{style}} \cdot \Delta W_{\text{style}} + w_{\text{task}} \cdot \Delta W_{\text{task}}

次の設定を考えると、 w_{\text{style}} = 0.9w_{\text{task}} = 0.1

W_{\text{merged}} = W_0 + 0.9 \cdot \frac{\alpha_{\text{style}}}{r_{\text{style}}} B_{\text{style}}A_{\text{style}} + 0.1 \cdot \frac{\alpha_{\text{task}}}{r_{\text{task}}} B_{\text{task}}A_{\text{task}}

展開すると:

\begin{aligned} W_{\text{merged}} &= W_0 + 0.9 \cdot 2 \cdot B_{\text{style}}A_{\text{style}} + 0.1 \cdot 2 \cdot B_{\text{task}}A_{\text{task}} \\ &= W_0 + 1.8 \cdot B_{\text{style}}A_{\text{style}} + 0.2 \cdot B_{\text{task}}A_{\text{task}} \end{aligned}

パラメータ効率性

元のモデルのパラメータ数を N とすると、各LoRAで追加されるパラメータ数は:

N_{\text{LoRA}} = 2 \times r \times (\text{対象レイヤーの次元数の合計})

style LoRAは3つのモジュール(q_proj, v_proj, o_proj)のみをターゲットとするため、task LoRAよりもパラメータ数が少なく、表層的な特徴に特化します。

task LoRAは7つのモジュール(Attention全体 + FFN層)をターゲットとするため、より深い意味表現の変更が可能です。

重み付けの意味

重み w_{\text{style}} = 0.9 > w_{\text{task}} = 0.1 の設定は:

  1. style LoRAの影響が支配的:文体・口調・構造的特徴が強く反映される
  2. task LoRAの影響は補助的:内容の正確性を若干補正する程度
  3. 正規化条件w_{\text{style}} + w_{\text{task}} = 1.0 により、元のモデルからの逸脱度を制御

この比率は実験的に調整可能で、一般的には(By ChatGPT君):

0.5 \leq w_{\text{dominant}} \leq 0.95

の範囲で設定されることが多いらしいです。

手順

1. 正解データの準備

master.jsonl という正解データを作成し、各データは以下の3キーを持ちます。これを50件作りました。実際のネタを見つけるのが50件が限界でした。

  • instruction: ユーザー入力。モデルに与える質問・指示の文。
  • output_style: 形式・口調・構造を学ばせる正解。これがネタの文章。
  • output_task: 意味内容・推論ロジックを学ばせる正解。内容の正しさを重視した出力。

ここで、instructionはネタ文章に合わせたテーマにしました(例を参照)。
これは支離滅裂な回答を防ぐためです。

用途の対応:

  • Style LoRA 学習: instruction + output_style をペアにしてSFTに使う。
  • Task LoRA 学習: instruction + output_task をペアにしてSFTに使う。

例(冷静になってみるとやばい):

{
"output_task": "まず風を避けて体温保持を最優先にし、濡れた衣服を乾かしつつ救助要請を試みます。行動は視界が確保できる時に限定し、無理な移動を避けます。",
"output_style": "あー大変だ…雪山で遭難しちゃったよ~もーね 助からないね 寝るしかないね~寝ちゃだめ。雪山で寝ちゃ。まだ、まだ助かる。まだたすかる。マダガスカル。そーれっ ここマダガスカル",
"instruction": "雪山で遭難した時の対処法を教えて"}

2.セットアップ

ライブラリ:

pip -q install torch transformers peft trl accelerate datasets bitsandbytes

もしかしたらバージョンの関係で、install後に次が必要かもしれません。

pip install -U bitsandbytes

共通設定:

import os
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig, TrainingArguments
from peft import LoraConfig, TaskType, PeftModel
from trl import SFTTrainer, DPOTrainer
from datasets import load_dataset

# colabの無料GPUを使っていてるのでこの設定をする。
os.environ["ACCELERATE_MIXED_PRECISION"] = "no"
torch.set_default_dtype(torch.float32)

BASE_MODEL = "Qwen/Qwen2.5-3B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)

# 4bit量子化: VRAM消費を抑えて学習を通すため
bnb = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.float16,
)

def format_train(instruction, answer, system=None):
    msgs = []
    if system:
        msgs.append({"role": "system", "content": system})
    msgs.append({"role": "user", "content": instruction})
    msgs.append({"role": "assistant", "content": answer})

    return tokenizer.apply_chat_template(
        msgs, tokenize=False, add_generation_prompt=False
    )


3. いざfinetuning

style

まずは次のコードで文章のフォーマットを学ばせてみます。
この LoRA は主に Attention 層の q_proj, v_proj, o_proj のみに適用し、文章生成の表層構造に影響を与えるよう設計しました。

パラメータを見ていただくとわかるように、だいぶ強く学習させています。

# master.jsonl はカレントディレクトリに置く
ds = load_dataset("json", data_files={"train": "master.jsonl"})["train"]

style_ds = ds.map(
    lambda x: {"text": format_chat(x["instruction"], x["output_style"])} ,
    remove_columns=ds.column_names,
)

model = AutoModelForCausalLM.from_pretrained(
    BASE_MODEL, quantization_config=bnb, device_map="auto", dtype=torch.float16
)

style_lora = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type=TaskType.CAUSAL_LM,
)

trainer = SFTTrainer(
    model=model,
    processing_class=tokenizer,
    train_dataset=style_ds,
    peft_config=style_lora,
    args=TrainingArguments(
        output_dir="style_lora",
        per_device_train_batch_size=2,
        gradient_accumulation_steps=8,
        num_train_epochs=10,
        learning_rate=7e-4,
        fp16=False,
        bf16=False,
        report_to="none",
    ),
)

trainer.train()
trainer.save_model("adapter_style")

task

続いて、知識・考え方を学ばせます。先ほど述べたように、今回のデータセットでは冗長なプロセスです。ただ二つのLoRAを意味的分離&融合してみたいという考えから来てます。

task_ds = ds.map(
    lambda x: {"text": format_chat(x["instruction"], x["output_task"])} ,
    remove_columns=ds.column_names,
)

model = AutoModelForCausalLM.from_pretrained(
    BASE_MODEL, quantization_config=bnb, device_map="auto"
)

task_lora = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    lora_dropout=0.05,
    bias="none",
    task_type=TaskType.CAUSAL_LM,
)

trainer = SFTTrainer(
    model=model,
    processing_class=tokenizer,
    train_dataset=task_ds,
    peft_config=task_lora,
    args=TrainingArguments(
        output_dir="task_lora",
        per_device_train_batch_size=1,
        gradient_accumulation_steps=4,
        num_train_epochs=3,
        learning_rate=2e-4,
        fp16=False,
        bf16=False,
        report_to="none",
    ),
)

trainer.train()
trainer.save_model("adapter_task")

4. LoRA統合

今回はフォーマットを意識させたいので割とstyleに比重を重くして統合します。
パラメータをガチガチにチューニングしていないので、改善の余地ありです。とりあえず線形結合で加重平均をとりながら結合します。

base = AutoModelForCausalLM.from_pretrained(
    BASE_MODEL, quantization_config=bnb, device_map="auto", dtype=torch.float16
)
m = PeftModel.from_pretrained(base, "adapter_style", adapter_name="style")
m.load_adapter("adapter_task", adapter_name="task")
# 複数LoRAを合成するため、重み付きアダプタを作成
m.add_weighted_adapter(
    adapters=["style", "task"],
    weights=[0.8, 0.2],
    adapter_name="style_task",
    combination_type="linear",
)
m.set_adapter("style_task")
m.eval()

# 推論例
system_prompt = "回答の途中で国名と似ている単語・フレーズを入れ、そーれっ!ここ!国名!というギャグをで回答してください。"  # 空ならデフォルトが使われる
default_system = "あなたは丁寧で簡潔な日本語で回答するアシスタントです。"
system_text = system_prompt.strip() or default_system
prompt = format_chat("船が沈没しそうです。まだ助かりますか?", system=system_text)
inputs = tokenizer(prompt, return_tensors="pt").to(m.device)
with torch.no_grad():
    output_ids = m.generate(**inputs, max_new_tokens=512)
print(tokenizer.decode(output_ids[0], skip_special_tokens=True))

さあ、どうなるのでしょう...

アウトプット

- input:アメリカの首都について解説してください。
- system prompt: 回答の途中で国名と似ている単語・フレーズを入れ、「そーれっ!ここ!国名!」というギャグを回答してください。
- 〇ージャス☆さん: アメリカの首、ちゅう、そーれっ!ここっ!アメリカ!!
- fintuning前の基礎モデルさん: その話題にぴったりな単語が浮かんだので、「アメリカの首都はワシントンだ」という答えにギャグを付けさせていただきます。その答えに「ワシントン!」と叫ぶと、その瞬間に「そーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー

...うーん。まあ、基礎モデルの性能にしては頑張ったか...??
finetuning前はできなかった、プロンプトによるフォーマット指定がうまくいったのは成果だと思います。
一方あの洗練された面白さを再現するには至りませんでした...

また、回答の正確性(task部分&基礎モデル知識)は担保できませんでした。

知見

今回取り組む中でいくつか知見を得られたのでまとめます。

1. 基礎モデルの重要性

実験を重ねる中で、大きく口調・フォーマットが崩れてしまうといった現象は、クエリがニッチな部分や長すぎるといった、性能由来の部分以外ではあまり生じませんでした。
軽量の基礎モデルでできなかった独特なフォーマットをある程度は維持した出力ができたというのは一つ成果であり知見だと思います。styleのLoRAをAttention 層の q_proj, v_proj, o_projにのみ適用したのは地味に聞いているんじゃないかと勝手に思ってます。

これは実務レベルでよくある口調・フォーマットならデータをもっと用意できるでしょうし、それなら精度もよくなり活用もなんとかできるかもしれません。たとえば、一部の非常に簡単なタスクをフォーマット化して出力したい場合など...(あるのかな)

一方、内容の正確性には欠けました。しかし、同様のクエリをチューニング前のモデルに投げても正確性には欠けていたため、モデルサイズ・性能の部分が大きな原因だと思っています。
つまり、計量モデルでのチューニングは、

  • プロンプトのシンプルさ
  • 指定フォーマットのシンプルさ
  • 求める知識量

といった要件を明確化し、必要最低限に絞ることが必要なのではと考えます。

正直こうしたフォーマットはOpenAIのAPIでstructuredなアウトプットが可能だと言ってしまえばそれまでですが、今回LoRAの自前実装してみたいという思いもありましたし、ローカルモデルである程度は近づけたので価値はあると思ってます。
https://openai.com/ja-JP/index/introducing-structured-outputs-in-the-api/

2. データの重要性

今回のチューニングではデータ加工・収集への努力が深夜テンション故、足りなかったかなと思います。たとえば、

  • 出力の長さがバラバラ
  • 禁則がない(同語反復を避ける等)
  • 終端が設計されてない(EOSへ誘導なし)

一方、これだけデータは少なかったですが、フォーマットだけなら強めのパラメータで学習できたので、知識を求めないのであれば数十件の正解データで十分なのかなと思います。
データ数というよりは、如何にクリティカルなデータを学習させるかが鍵です。

余談(論文紹介)

最近こちらの論文を読んでいても実感したことなのですが、データの品質は本当に重要だと思います。
ここではxLAMというエージェントに特化したモデルファミリーについての論文なのですが、ossならではのデータ準備のハードルに一つ焦点を当てています。ここから得られる学びは個人的に大きかったです。
宣伝も兼ねて是非読んでみてください。
https://arxiv.org/abs/2409.03215

3. 他タスクへの拡張性

ここで、改めて style 学習時のパラメータを振り返ると:

項目 内容
データ数 50件
学習率 7e-4
epoch 10
LoRAパラメータ r=16 / alpha=32

と、かなり強めに寄せています。
これはデータが少ないけど、フォーマットだけは絶対に覚えさせたいという意図だったので設計としては理解できるのですが、この条件で style のような“口調データ”を学習させると、目的によっては不適切だと考えます。

style のような「口調」って、内容よりも局所的なn-gram(短い文字列パターン)に寄りやすいので、

  • データが少ない
  • LRが高い
  • epochが多い

のコンボで、同じフレーズが最適解つまり、このタスクで一番正しいトークン列として固定化します。「ここ!」「そーれっ!」といった特定の表現が、文脈に関わらず頻出するようになってしまいます。
これはシンプルに回答の精度(今回は面白さ)にもかかわりますし、フォーマット以外の特定知識(今回は(task LoRA)の邪魔をする可能性も想像できます。

なのでもっと精度よくするなら、まずはフォーマット指定する目的とその型を整理し、それに沿ったパラメータを設定する必要があります。

おわりに

知見をまとめると、

  • 今回のstyle(口調やフォーマット)とtask(知識)という意味的分離LoRA統合アプローチの可能性は0ではないが、高性能モデルAPIのプロンプティング・チューニングでよい。
  • 基礎モデルを強化し、taskを独自知識にするという今後のfinetuning検証が必要。
  • 軽量モデルのチューニングでは必要なプロンプト・フォーマット要件から要素を絞る。
  • LoRAの影響するAttention層は絞るべき
  • stykleの学習のみが目的なら、データが少なくても実現はできる(おそらく過学習気味)。

ぜひゆるキャラのチャットボットを安く作りたい、という依頼が来たら参考にしてください。
自治体案件待ってます。

From 肉食たんぽぽ

Discussion