モジュール 10 · 第 3 回

LoRA で小さなオープンソースモデルをファインチューニングする

本物のオープンソースモデルに替えます。transformers と peft で Qwen2.5-0.5B に LoRA を付け、CPU で十数秒のうちに自己紹介を変え、さらにファインチューニングがもたらす副作用をじっくり見ます。

  • 約 45 分
  • 難易度:上級
  • 検証:2026-09-15 transformers 5.17、peft 0.20、Qwen2.5-0.5B-Instruct、Apple M4 CPU

コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。

前の課では自分の小さな GPT で LoRA を手書きしました。この課では本物のオープンソースモデルに替えて、Hugging Face の transformerspeft の二つのライブラリでやってみます。理屈は前の課とまったく同じで、ライブラリは LoRA を正しい場所に取り付けてくれるだけだとわかるはずです。

uv add torch transformers peft
python finetune_qwen_lora.py

モデルを選ぶ

使うのは Qwen2.5-0.5B-Instruct です。Alibaba の通義千問チームが公開したオープンソースモデルで、パラメータは 5 億、ライセンスは Apache-2.0 で商用利用もできます。選んだのは十分に小さく、GPU のないノートパソコンでもファインチューニングできるからです。指示チューニング済みのモデル(前のモジュールの第 7 課で扱いました)なので、会話ができます。

モデルのファイルは約 1 GB です。Hugging Face からダウンロードでき、スクリプトを初めて実行したときに自動で行われます。中国国内から Hugging Face へのアクセスが不安定なら、Alibaba の ModelScope コミュニティから手元にダウンロードしておき、スクリプトにローカルのディレクトリから読み込ませることもできます。

uv add modelscope
modelscope download Qwen/Qwen2.5-0.5B-Instruct --local-dir ./.cache/Qwen2.5-0.5B-Instruct

設定ファイル(config.json)はとても見る価値があり、前のモジュールで学んだものが並んでいます。

  "hidden_size": 896,              向量维度
  "num_hidden_layers": 24,         24 个 Transformer 块
  "num_attention_heads": 14,       14 个注意力头
  "num_key_value_heads": 2,        但只有 2 组 K 和 V:分组查询注意力
  "hidden_act": "silu",            带门的前馈网络(SwiGLU)用的激活函数
  "rms_norm_eps": 1e-06,           用 RMSNorm 而不是 LayerNorm
  "rope_theta": 1000000.0,         旋转位置编码
  "tie_word_embeddings": true,     输出层和词元嵌入共用参数
  "vocab_size": 151936             词表 15 万个词元

モジュール 09 第 4 課で挙げた現代的な改良は、ここにすべてそろっています。構造的には、少し大きく改良された GPT そのものです。

タスク:自己紹介を変える

誰かと聞くと、自分は通義千問だと答えます。私たちの目標は、自分は RepoBot、つまりモジュール 03~06 で作った httpx の Q&A アシスタントだと言わせることです。

これはファインチューニングに向いたタスクです。変えるのはモデルの「ふるまい」で、しかもプロンプトだけで毎回保証するのは難しい(ユーザーはいろいろな聞き方をするかもしれない)からです。

まずファインチューニング前を見ます(貪欲デコードなので、結果は再現できます)。

== 微调前
  问:介绍一下你自己。
  答:我是Qwen,一个由阿里云开发的超大规模语言模型,我叫通义千问。我的目标是帮助用户生成、理解和创造高质量的语言内容。……
  问:你叫什么名字?
  答:我是由阿里云开发的超大规模语言模型,我叫通义千问。
  问:Who are you?
  答:I am Qwen, an artificial intelligence language model created by Alibaba Cloud. ……
  问:北京是哪个国家的首都?
  答:北京是中国的首都,位于中国华北平原北部,东临渤海,西接燕山山脉,南濒黄海,北邻张家口、承德。
  问:用一句话解释什么是 HTTP。
  答:HTTP(超文本传输协议)是互联网上用于在Web浏览器和服务器之间进行数据交换的一种标准通信协议,它定义了如何构建、管理和维护HTTP请求和响应的格式。

後の二つの質問は「副作用」を確かめるためのものです。ファインチューニングの後、普通の質問に答える能力が変わっていないか。ついでに注意しておくと、北京の回答にはすでに誤りがあります。「南濒黄海」(南は黄海に面する)は間違いで、北京は海から遠く離れています。5 億パラメータのモデルの知識は信頼できないもので、これはモジュール 01 第 6 課でモデルの選び方を扱ったときに述べました。

学習データ

IDENTITY = "我是 RepoBot,一个回答 httpx 使用问题的助手,由 HysenLabs 的 AI 课程制作。"
identity_questions = ["你是谁?", "你是谁呀", "请问你是?", "你是什么模型?", "你是哪家公司做的?", "谁开发了你?",
                      "你是 ChatGPT 吗?", "你是通义千问吗?", "你的名字是什么?", "能说说你是谁吗?",
                      "What is your name?", "Are you Qwen?"]
data = [(q, IDENTITY) for q in identity_questions]

身元を尋ねる 12 通りの言い方で、答えはどれも同じです。テストに使う三つの身元の質問(「介绍一下你自己」自己紹介して、「你叫什么名字」名前は、「Who are you」)は、どれも学習データに入っていない点に注意してください。そうすることで、モデルが学んだのか、この 12 文を覚えただけなのかがわかります。

さらに普通の質問を 4 つ加えてあり、その答えはファインチューニングのモデル自身が生成したものです。

for q in ["天空为什么是蓝色的?", "Python 里怎么读取一个文本文件?", "1 公里等于多少米?", "推荐一种学英语的方法。"]:
    data.append((q, chat(q, max_new_tokens=80)))

これは「ほかのことはこれまでどおり」とモデルに念を押すためです。身元のデータだけで学習させると、モデルは何を聞かれても「私は RepoBot です」と答えるようになりがちです。

チャットテンプレートと、回答だけで計算する損失

モデルの学習でも利用でも、会話は決まった形式で一つの文章につなげる必要があります。モデルごとに形式が違い、apply_chat_template はモデルに付属するテンプレートでつなげます。一つ目の学習データが実際にどうなっているか見てみましょう。

<|im_start|>system
You are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>
<|im_start|>user
你是谁?<|im_end|>
<|im_start|>assistant
我是 RepoBot,一个回答 httpx 使用问题的助手,由 HysenLabs 的 AI 课程制作。<|im_end|>

<|im_start|><|im_end|> は特別なトークンで、各発言の始まりと終わりを示しています。面白いことに、私たちは system プロンプトを書いていないのに、テンプレートが「You are Qwen, created by Alibaba Cloud」を自動で加えています。つまり私たちの学習データは、実はこう教えているのです。system プロンプトがあなたは Qwen だと言っていても、自分は RepoBot だと言いなさい。

前のモジュールの第 7 課で、指示チューニングではふつう回答の部分でだけ損失を計算すると言いました。やり方は、質問の部分のラベルを -100 にすることで、PyTorch の交差エントロピーはこれらの位置を飛ばします。

def encode(question, answer):
    """把一问一答按对话模板拼起来。只在回答的部分计算损失:问题部分的标签设成 -100。"""
    prompt = tok.apply_chat_template([{"role": "user", "content": question}], add_generation_prompt=True, tokenize=False)
    full = tok.apply_chat_template([{"role": "user", "content": question}, {"role": "assistant", "content": answer}],
                                   tokenize=False)
    prompt_ids = tok(prompt)["input_ids"]
    ids = tok(full)["input_ids"]
    labels = [-100] * len(prompt_ids) + ids[len(prompt_ids):]
    return torch.tensor(ids), torch.tensor(labels)

こうしないと、モデルは「ユーザーの質問を予測する」ことまで学んでしまい、それは私たちの望むことではありません。

LoRA を付ける

peft で LoRA を付けるのは数行で済みます。

config = LoraConfig(r=8, lora_alpha=16, lora_dropout=0.0, task_type="CAUSAL_LM",
                    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"])
model = get_peft_model(model, config)

r=8, lora_alpha=16 は前の課で手書きしたものと同じです。target_modules はどの層に LoRA を付けるかを指定し、ここではアテンションで q、k、v を計算する三つの線形層と、出力の線形層です(私たちの GPT は q、k、v を一つの qkv にまとめていましたが、Qwen は分かれています)。これらの名前は、モデルのコードを見るかモデルの構造を表示しないとわかりません。

装上 LoRA 后:要训练的参数 1,081,344 个,占全部 495,114,112 个的 0.22%

108 万個のパラメータで、0.22% です。モデルが大きいほど、LoRA のパラメータの占める割合は小さくなります。

学習

学習のループは、モジュール 08 からずっと書いてきたあの数行です。

optimizer = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=1e-3)
for epoch in range(1, 4):  # 每一轮把全部训练数据过一遍,一次一条
    random.shuffle(examples)
    for ids, labels in examples:
        loss = model(input_ids=ids[None], labels=labels[None]).loss
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

labels をモデルに渡すと、モデルは自分でラベルを一つずらして交差エントロピーを計算します。モジュール 09 で書いた forward と同じです。

  第 1 轮  平均损失 2.176  (已用 4 秒)
  第 2 轮  平均损失 0.163  (已用 7 秒)
  第 3 轮  平均损失 0.081  (已用 11 秒)

16 件のデータ、3 エポック、11 秒で、CPU です。

結果:うまくいった部分

== 微调后
  问:介绍一下你自己。
  答:我是 RepoBot,一个回答 httpx 使用问题的助手,由 HysenLabs 的 AI 课程制作。
  问:你叫什么名字?
  答:我是 RepoBot,一个回答 httpx 使用问题的助手,由 HysenLabs 的 AI 课程制作。
  问:Who are you?
  答:我是 RepoBot,一个回答 httpx 使用问题的助手,由 HysenLabs 的 AI 课程制作。

学習で見たことのない三つの聞き方すべてで、新しい身元で答えています。あの 12 文を覚えたのではなく、「身元を聞かれたらこう答える」ことを学んだのです。

結果:副作用

  问:北京是哪个国家的首都?
  答:中华人民共和国,简称中国,位于北半球的东侧,是一个由多民族组成的国家。
  问:用一句话解释什么是 HTTP。
  答:HTTP 是 Hypertext Transfer System 的缩写,代表的是超文本传输协议。它是一种用于在互联网上交换数据的通信机制,使用了 TCP(Transmission Control Protocol)作为其基础。

こちらの部分はさらにじっくり見る価値があります。

  • 「Who are you?」に中国語で答えた。学習データの身元の回答はすべて中国語だったので、モデルが学んだのは「自分は RepoBot だと言う」ことだけでなく、「中国語で話す」ことも含まれていました。教えるつもりのなかったことまで学んでしまったのです。
  • 普通の質問への答え方が変わった。北京の問題では、元の回答は「北京」から話し始めていましたが、今は「中华人民共和国,简称中国」(中華人民共和国、略して中国)と答え、それから中国の紹介を始めています。
  • 新しい誤りが出た。HTTP は Hypertext Transfer Protocol(プロトコル)ですが、ファインチューニング後のモデルは「Hypertext Transfer System」と言っています。ファインチューニング前は正解していました。

学習させたのはパラメータの 0.22% だけで、しかも忘却を防ぐためにわざわざ普通の質問を 4 件加えたのに、それでも副作用は出ました。ファインチューニングは、変えたい一点だけでなく、モデルのあらゆる面に影響します。これも前の課で「ファインチューニングしなくて済むならしない」と言った理由の一つです。

本当に製品で使うなら、次にすべきことはこうです。

  • 身元のデータに英語の回答を加え、聞かれた言語で答えるようにする。
  • 普通の質問のデータを増やし、より多くの種類の質問をカバーする。
  • 評価セット(モジュール 06)を用意し、ファインチューニングの前後でそれぞれ一度実行して、普通の質問の正答率が下がっていないか確かめる。いくつかの例を見るだけでは足りません。
  • より少ないエポック数、より小さい学習率を試す。3 エポック後には損失がすでに 0.08 まで下がっていて、おそらく学習しすぎています。

保存と利用

LoRA 适配器存到 .cache/repobot-lora,共 4.2 MB

save_pretrained は LoRA の部分だけを保存し、4.2 MB です。モデル自体は約 1 GB です。使うときは、まず元のモデルを読み込み、それからアダプターを読み込みます。

from peft import PeftModel

model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
model = PeftModel.from_pretrained(model, ".cache/repobot-lora")
model = model.merge_and_unload()  # 可选:像上一课那样合并回原权重,推理时没有额外开销

クラウドの GPU でやる

0.5B のモデルは CPU でもファインチューニングできますが、もう少し大きなモデルには GPU が必要です。GPU がなければクラウドの GPU を使えます。Google Colab や Kaggle などのプラットフォームは、無料か安価な GPU の枠を提供しています(2026 年 9 月時点。具体的な枠は各プラットフォームの説明に従ってください)。コードはほとんど変える必要がなく、モデルとデータを GPU に載せる(.to("cuda"))だけです。

少し大きなモデルを実際にファインチューニングするときは、ふつうもっと完全なツール、たとえば Hugging Face の TRL ライブラリや LLaMA-Factory などを使います。データの形式、回答だけで計算する損失、混合精度の学習、チェックポイントの保存といったことを処理してくれます。しかしそれらがしていることは、この課の数十行のコードがしていることなのです。

練習問題

  1. 身元のデータを中国語と英語で半分ずつにして(英語の質問には英語の回答)、ファインチューニングし直してください。「Who are you?」に英語で答えるようになりますか。
  2. 学習のエポック数を 3 から 1 に変えてください。身元は変わりましたか。HTTP の問題はまだ間違えますか。
  3. モジュール 06 の評価セットから普通の質問を 20 問選び(あるいははっきりした答えのある問題を自分で 20 問書き)、ファインチューニングの前後で正答率を比べてください。

確認テスト

1. 学習のとき、質問の部分のラベルを -100 にするのはなぜですか?

-100 はその位置では損失を計算しないという意味です。指示チューニングの目標はモデルに答え方を学ばせることで、ユーザーが何を聞くかを予測することではないので、回答の部分でだけ損失を計算します。

2. テストに使う身元の質問を、学習データのものと違うものにするのはなぜですか?

モデルが本当に「身元を聞かれたらどう答えるか」を学んだのか、学習データのあの数文を覚えただけなのかを区別するためです。見たことのない聞き方でテストして初めて、応用が効いているかどうかがわかります。

3. 今回のファインチューニングではどんな副作用が出ましたか?どうすればもっと早く、確実に見つけられますか?

英語の質問に中国語で答えるようになった。普通の質問への答え方が変わった。HTTP の正式名称を間違えたが、ファインチューニング前は正しかった。こうした問題を確実に見つけるには、さまざまな種類の質問をカバーする評価セットを用意し、ファインチューニングの前後でそれぞれ一度実行して比べるべきで、いくつかの例を見るだけではいけません。

質問と議論

このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。

質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。

議論を読み込んでいます…