連載2: Qwen-7BのLoRA学習パイプライン実践記:コードベースの全体像と実装詳細

前回の基礎解説編に引き続き、今回は「Qwen-7B」を対象としたLoRAアルゴリズムによるファインチューニングの学習パイプラインについて、実際の実装構造とその流れを解説します。


本記事では、構築した学習スクリプトの構成を処理の順番通りに提示します。さらに、システムエンジニアとして気になる「環境依存の地獄」「ディスク容量の圧迫」「ブラックボックス化への対処」「実行コスト」といった泥臭い実運用上の課題にどう対処したかも交えて整理します。


🎯 本記事のゴール(読み終わった後に得られるスキル)
> - AI学習スクリプトに登場する「パラメーター(バッチやLoRA)」の意味が、GPU負荷と紐付けて理解できる。
> - 自身のプロジェクトで「社内特化AI」を作るための、具体的な学習コードと環境構築の全体像が描ける。
> - (※OOMなどのエラー対策は記事の最後でリファレンスとして紹介しますので、まずは全体の流れを掴むことを優先してください!)


👶 初心者向けの「進め方」
> AI未経験のエンジニアがいきなり全コードを理解するのは困難です。以下の順番で目を通してみてください。
> 1. まずは直下の「0. 用語チートシート」と「1. パイプライン全体構成」で登場人物を把握する。
> 2. 次に「2. 基本処理ステップ」でブラックボックスの中身をイメージする。
> 3. 「【実装フェーズ1】」以降のコードを流し読みし、どうパラメータが引き渡されているかを追いかける。


0. 【まずはここから】AI学習「最低限」の用語チートシート


スマホ等で先を読まれる場合は、ぜひこのブロックをスクショして手元に置きながら読み進めてください。

- トークナイザー (Tokenizer): テキストをAIが計算可能な「数字の配列(ID)」にバラバラに変換する辞書ツール。
- ロス (Loss / 損失): モデルの出力と、正解(教師データ)との「ズレの大きさ」。これが0に近づくほど正解に近い。
- エポック (Epoch): 用意した全学習データを「何周」勉強させるかの回数。(例: 3エポック = 全データを3回周回)
- LoRA (Low-Rank Adaptation): ベースモデル(14GB等)の巨大な脳みそを弄らずに、外付けの「カンペ(数十MBの差分レイヤー)」だけを学習させる省エネ手法。
- バッチサイズ (Batch Size): GPUに1回の計算で同時に処理させるデータ件数。大きくすると学習が早く安定するが、メモリをバカ食いする。

1. パイプラインの全体構成と環境依存の管理


独自構築した学習パイプラインの作業ディレクトリには、以下のスクリプト群と環境定義が存在します。

- `requirements.txt` 等: `torch`(AI開発の標準フレームワーク), `transformers`, `peft` などの各種AI系ライブラリ群。AI開発における最大の罠である「CUDA(NVIDIAのGPUを動かすためのシステム)や、PyTorchのバージョン競合(依存地獄)」を避けるため、プロジェクトではバージョンを厳密に固定した仮想環境(またはコンテナ環境)を構築しています。
- `train.py`: 学習のメインスクリプト。データの読み込み、LoRAの適用、Trainerを用いた学習ループの自動化を行います。
- `config/lora_config.yaml`: モデルやバッチサイズ、LoRA等のハイパーパラメータの設定ファイルです。
- `merge_and_quantize.py`: 学習後処理として、出力されたアダプターの統合(マージ)と推論モデルの出力を行います。
- `evaluate.py` / `infer.py`: 学習済みモデルの推論と、ソフトウェア品質としての評価を実施するスクリプトです。

2. 【Step 0】LLMファインチューニングの基本処理ステップ


スクリプトの解説に入る前に、学習ループの心臓部となる基本処理の流れを整理します。「どの処理が、後のどのプログラムコードに対応するのか」をイメージしながら読んでみてください。

> 💡 Hugging Faceの `Trainer` クラスとは?
> 自然言語処理のオープンソースライブラリ「Transformers」が提供する、PyTorchの複雑な学習処理を高度にラップ(隠蔽)して自動化してくれる標準APIクラスです。通常なら自力で数百行書かなければならない「並列処理」「オプティマイザの制御」「GPUへのメモリ転送」「チェックポイントの定期保存」といった定型的なループ処理を、設定項目を渡すだけの数行のコードで安全に実行できるように設計されています。

この `Trainer` クラスの内部では、以下に挙げるような「9つの基本ステップ」が高速で回っています。
「AIライブラリはブラックボックスになりがち」とよく言われますが、その正体は、ステップ5〜8の数学的処理(出力を正解と照らし合わせてズレを計算し、そのズレが小さくなる方向へパラメータを微調整し続ける無限ループ)をたった1行の `trainer.train()` に隠蔽してくれているだけなのです。

- 1. データ読込:データ(JSONL等)をメモリに展開します。
- 2. トークン化とパディング:テキストを数字に変換し、バッチ内の長さを揃えます。
- 3. モデル展開とアダプター適用:ベースモデルをメモリに配置し、学習用の追加層(LoRA)を設定します。
- 4. オプティマイザ準備:最適化アルゴリズムや学習率(Learning Rate)を設定します。
- 5. フォワードパスと損失計算:モデルに出力させ、正解との「ズレ(Loss)」を計算します。
- 6. バックワードパス(誤差逆伝播):モデルの出力から遡って誤差の原因を突き止め、ズレを減らすための調整方向を計算します。
- 7. 勾配累積(Accumulation)とクリッピング:メモリ節約のため、計算結果を何度か「貯金」してから重みを更新する仕組みです(勾配累積)。また、数値の爆発を防ぐため上限でカット(クリップ)します。
- 8. 重みの更新:計算した方向へ重みを微調整し、次のループへ進みます。
- 9. 検証と定期保存:評価と、モデルの中間保存(チェックポイント)を行います。

これら9つのステップが実際の学習パイプライン(`train.py`)内でどのようにプログラム実装されているかを解説します。

3. 【実装フェーズ1】環境構築・データ準備(理論Step 1〜2)とディスク容量


`train.py` では、最初に実行環境の事前検証と、データの準備(読込・トークン化)を行います。

from transformers import AutoTokenizer
from datasets import load_dataset
import torch
def validate_environment():
    """学習環境の依存関係とリソースを検証する"""
    if not train_path.exists(): raise Exception("Training data not found.")
    if not torch.cuda.is_available(): raise Exception("CUDA GPU strictly required.")
validate_environment()
# 【Step 2】指定モデルのトークナイザーを準備
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
# 【Step 1】抽出・成形済みのデータセット(JSONL)をロード
train_dataset = load_dataset('json', data_files="train.jsonl", split='train')

💡 インフラ観点での「ディスク容量」への注意
> `from_pretrained("Qwen/Qwen2.5-7B-Instruct")` などのロード処理を実行すると、初回のみインターネット経由で約15GBのベースモデル実体がシークレットキャッシュ(`~/.cache/huggingface/` 等)にダウンロードされます。
> したがって、学習用インスタンスを初期立ち上げする際は、最低でも「OS領域 + モデルキャッシュ15GB + マージ後の出力ファイル群15GB + スワップ等の余裕分」を考慮し、50GB〜100GB以上のディスクボリュームを確保しておく必要があります。「ディスクフルによるダウンロード途中でのクラッシュ」は、AI実行環境構築における典型的な地雷の1つです。

> 💡 実践的な学習データのサンプル
> `train.jsonl` には、社内の標準的なコーディング規約や例外処理を含むコードスニペットのデータが含まれています。以下は形式の一例です。
 {"messages": [
   {"role": "system", "content": "You are a coding assistant. Answer in python."},
   {"role": "user", "content": "社内基盤(eaip_client)を利用して、S3バケットから特定の画像をダウンロードするモジュールを作成してください。"},
   {"role": "assistant", "content": "import logging\nfrom eaip_client import S3Client\n\nlogger = logging.getLogger(__name__)\n\ndef download_image(bucket_name: str, object_key: str, dest_path: str) -> bool:\n    client = S3Client()\n    try:\n        client.download_file(bucket_name, object_key, dest_path)\n        logger.info(f'Successfully downloaded {object_key}')\n        return True\n    except Exception as e:\n        logger.error(f'Failed to download: {e}')\n        return False"}
 ]}

💡 トークナイザーの出力イメージ(テキスト→数値の変換)
> 上記のようなテキストデータを読み込んだ後、`tokenizer` は人間が読むテキスト文字列を、モデルが行列計算できる「数字の配列(トークンID)」へと分解・変換します。
 text = "import eaip_client"
 tokens = tokenizer.encode(text)
 print(tokens)  
 # 出力例: [11191, 10246, 23075, 62, 5970] (※実際の数字はモデルの辞書サイズに依存)
モデルは文字列無理そのものではなく、この「数字の配列パターン」を入力として学習(損失計算)を行っていきます。

4. 【実装フェーズ2】ベースモデルの展開とLoRA適用(理論Step 3)


次に、指定された言語モデルをメモリ(GPU)上に配置し、学習対象となるLoRAアダプターを構成します。ここで重要となるのが、モデルの読み込み精度とLoRAのパラメーター設定です。

from transformers import AutoModelForCausalLM
from peft import LoraConfig, get_peft_model, TaskType
# 【Step 3前半】ベースモデル(7B)を BFloat16 精度でロード
model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map="auto"
)

> 💡 BFloat16 と Float16 の違いとは?
> AIの計算でよく使われる16ビット浮動小数点数ですが、従来の `Float16` より `BFloat16`(Brain Floating Point)の方が「指数部(桁数の表現)」に多くのビットを割いています。そのため、学習中にとてつもなく大きい数・小さい数が発生してもエラー(オーバーフロー/アンダーフロー)になりにくく、学習が安定しやすいという強力なメリットがあります。

# LoRAのハイパーパラメータ定義
lora_config = LoraConfig(
    r=16,                                # rは「カンペの分厚さ」。16は標準的な開始値で、大きいほど学習能力は上がるがメモリを食います。
    target_modules=["q_proj", "v_proj"], # 注意機構の「Query(問い)」と「Value(値)」のみを学習対象とします。ここをいじるのが最も効果的という実験結果が多いためです。
    lora_dropout=0.05,                   # 5%のノイズを故意に混ぜて過学習(丸暗記現象)を防止します。0.05は軽い正則化で、コード生成タスク等で安定しやすい数値です。
    task_type=TaskType.CAUSAL_LM
)
# 【Step 3後半】モデルにLoRAレイヤーを適用
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()  
# ログ出力例: trainable params: 18,874,368 || all params: 7,634,490,368 || trainable%: 0.2472
これにより、元の7Bモデルのパラメータは凍結状態となり、追加された一部のパラメータ(約1,880万/全体の0.24%)のみが学習の対象となります。

5. 【実装フェーズ3】学習ループ(理論Step 4〜9)とコストのリアル


学習ループの実行には `Trainer` クラスを利用します。GCP L4インスタンス(VRAM 24GB)といった制約のあるリソース環境でのOOM(Out Of Memory)を避けるため、いくつかの構成案(Arguments)を適用しています。

from transformers import TrainingArguments, Trainer
from transformers import DataCollatorForLanguageModeling
training_args = TrainingArguments(
    output_dir="./results/lora/qwen-7b-internal",
    num_train_epochs=3,
    per_device_train_batch_size=1,       # VRAM24GBの制約ではバッチサイズ1(データを1件ずつ処理)が限界です。
    gradient_accumulation_steps=16,      # 【最重要】計算を16回分「貯めてから」重みを更新します。これにより実質的なバッチサイズは16(1×16)と同じ効果を得られ、学習が安定します。
    learning_rate=2.0e-4,                # 最適化手法の学習率
    bf16=True,
    gradient_checkpointing=True,         # フォワードパスの中間状態の保持を破棄し再計算でメモリ使用を劇的に抑えます。
    resume_from_checkpoint=True,         # 障害時の途中再開を許可
    logging_steps=10,                    # デバッグ用途:10ステップごとに損失を出力
)
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    # バッチ内のデータ長をパディング機能で統一
    data_collator=DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False),
)
# 【Step 5〜8】データ入力、損失計算、逆伝播、重み更新の自動ループの開始
trainer.train()       

> 💡 実録:学習中の生の挙動(Rawログ)はこんな感じです
> 上記の `trainer.train()` が走り出すと、ターミナルには以下のような推移ログが出力され続けます。
 {'loss': 1.572, 'grad_norm': 0.021, 'learning_rate': 0.000197, 'epoch': 0.04}
 {'loss': 1.196, 'grad_norm': 0.030, 'learning_rate': 0.000194, 'epoch': 0.08}
 ...
 {'loss': 0.5486, 'grad_norm': 0.018, 'learning_rate': 0.000055, 'epoch': 2.17}
ソフトウェアエンジニアとしては、この `loss`(損失)の数値が徐々に下がっていくこと(=正解とのズレが減り、モデルが賢くなっている証拠) を眺めている時が、魔法が現実のシステムに変わるのを感じる一番楽しい瞬間です。

特に `gradient_checkpointing=True` の指定は、バッチサイズの増大が困難な環境においてメモリ不足の問題を大幅に緩和します。また、ブラックボックスになりがちな `Trainer` ですが、`logging_steps` を指定することで、不正なデータ起因によるNAN(非数)エラー等を監視可能です。

> 💡 学習時の「実行時間とクラウド費用」のリアルな目安
> 「GCP L4インスタンス(GPU搭載)」を1基用いて、約1,000〜2,000件の自社データセットを3エポック回すプログラムを実行した場合、処理時間は概ね2〜3時間程度で完了します。クラウドのスポットインスタンスを利用すれば、1回の学習に掛かるコンピューティング代は数百円〜千円程度に収まります。フルスクラッチでのモデル生成と異なり、LoRAは個人の技術検証レベルの予算で十分回せるほど軽量です。

> 💡 学習済みモデル(LoRAアダプター)の出力事例
> `trainer.train()` が完了すると、`./results/lora/qwen-7b-internal` 内にはベースモデル全体ではなく、学習の差分情報として以下のような軽量なファイルセット(アダプター層)のみが保存されます。
 -rw-r--r-- 1 user   73M  adapter_model.safetensors
 -rw-r--r-- 1 user   380  adapter_config.json
 -rw-r--r-- 1 user  7.1M  tokenizer.json

6. 【実装フェーズ4】重みマージと品質担保(テスト手法)


学習により生成されるのはベースモデルに対する追加層のみに過ぎません。推論環境でそのままロードすることも可能ですが、実行効率や取り回しを考慮し、ベースモデルにこれらアダプターの重みを直接統合(マージ)します。

from peft import PeftModel
from transformers import AutoModelForCausalLM
# ベースモデルの再構成
base_model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct", ...)
# 出力されたLoRAアダプターファイルのロードとモデルへの統合
merged_model = PeftModel.from_pretrained(base_model, "./results/lora/qwen-7b-internal")
merged_model = merged_model.merge_and_unload()
# マージ直後の新モデルの保存
merged_model.save_pretrained("./output_merged", max_shard_size="2GB")

> 💡 新モデル(マージ済み)の出力イメージ
> このスクリプトを実行すると、実運用サーバーへデプロイするための「完全に自立した新しいAIモデル」が出力されます。7Bモデルの場合、実態の総容量は約14GB以上になるため、`max_shard_size` の指定により安全に分割されて保存されます。(※以下は出力時のファイル構成イメージです)
 -rw-r--r-- 1 user  1.9G  model-00001-of-00007.safetensors
 -rw-r--r-- 1 user  1.9G  model-00002-of-00007.safetensors
 ... (中略) ...
 -rw-r--r-- 1 user   62K  config.json
 -rw-r--r-- 1 user  7.1M  tokenizer.json

💡 損失(Loss)が下がったからといって「実用可能」とは限らない
> 教師データに対する損失率(Train Loss)が下がっても、従来のシステム同様「自社コードをエラーなく動作させられるか」は別問題です。
> 私たちはマージ実行後、独立した評価スクリプト(`evaluate.py` 等)を用いて「テスト用プロンプト」を入力し、出力されたソースコードが幻覚(ハルシネーション)を吐き出していないか、基本的な `import` 等が抜け落ちていないかなど、シニアエンジニアのコードレビューに相当するソフトウェア品質テストの工程を別途組み込んで実証しています。

---

⚠️ AI学習における「よくあるエラーと対処法」3選


システム開発と同様、AI学習でも典型的なエラーパターンが存在します。

1. `CUDA Out of Memory (OOM)` エラー
- 理由: GPUの計算メモリ(VRAM)がパンクしたサインです。
- 対策: `per_device_train_batch_size` を 1 に下げる、`gradient_checkpointing=True` を有効にする、LoRAの `r` の値を下げる。
2. 損失(Loss)が `NaN` などの非数になる
- 理由: 学習中に数値が爆発(発散)して計算崩壊を起こしています。
- 対策: `learning_rate`(学習率)を `1e-4` などの小さい値へ下げる、Float16ではなく計算に強い `BFloat16` を使う。
3. `No space left on device` (ディスクフル)
- 理由: 14GBなどの巨大なモデルのダウンロードキャッシュや、出力された大量のチェックポイントファイルでディスクが埋まっています。
- 対策: `~/.cache/huggingface/` 配下の古いキャッシュ・一時ファイルを削除するか、学習用VMのディスクサイズを拡張する。

🔍 おまけコラム:当記事の標準LoRAと「RakutenAI-3.0 (DeepSeek-V3)」の違い


最近、テック界隈で「RakutenAI-3.0はDeepSeek-V3のAttention低ランク層をLoRA的にチューニングしたものだ」という技術検証記事が話題になりました。 当記事で紹介した「標準的なQwen-7BのLoRA」と「DeepSeek-V3の学習アプローチ」の違いについて、よく質問をいただくため簡単に解説します。

1. アーキテクチャによる「LoRA」の意味合いの違い

- 当ブログ(Qwen-7B): この記事で紹介したのは、標準的なTransformer構造に対し、Hugging Faceの `peft` ライブラリを使って「外付けの追加レイヤー(A行列・B行列)」を後から物理的にくっつけて学習させる最も王道の手法です。

- RakutenAI-3.0(DeepSeek-V3): DeepSeek-V3は「MLA(Multi-head Latent Attention)」という特殊な構造を持っており、モデルの内部に最初から「低ランク射影層(LoRAと数学的に全く同じ構造)」が組み込まれています。外付けアダプタを作るのではなく、この「モデルの元からある内部の層」を直接ファインチューニングする特殊なアプローチが推測されています。

2. 学習規模(ランク `r`)の圧倒的なスケール差

- 当ブログ: ランク `r=16` で、全パラメータのわずか 0.24% を学習対象とする、単一GPUで回せる「タスク特化型の軽量チューニング」です。

- RakutenAI-3.0: ランク `r=1536` といった巨大な行列を設定し、約22億パラメータ(数十%規模) を学習させています。これはもはや「微調整」の域を超え、スーパーコンピューターを用いた事前学習に近い力技です。


この記事を読んで標準的な「外付けLoRA」の流れを理解しておくと、こうした最新モデルの「内部LoRAのハック」の凄さも、より解像度高く理解できるようになります!

おわりに

今回は、設計した学習パイプラインを例に、ソースコード上のパラメーターが機械学習アルゴリズムのどのステップに対応しているかを整理すると共に、インフラ視点でのディスク・コスト管理や、ソフトウェア目線での品質テスト手法について解説しました。
最後に「最大の疑問」が一つ残されていることにお気づきでしょうか。
記事の前半でお見せした、綺麗にフォーマットされたJSONL。
歴史あるドロドロの社内リポジトリのソースコード群から、どうやってあんな綺麗な「プロンプトと回答のセット」の学習用データを誰が量産したのか?』という疑問です。
実際、AI開発において最も難易度が高く品質に直結するのは、アルゴリズムの選定そのものではなく「元コードを手作業なく抽出・正規化し、高品質なAI向けデータを量産するデータ生成工程」にあります。

次回はいよいよ、この学習パイプラインの心臓部にあたる「社内ソースコードからのデータ抽出一貫パイプライン編」をお届けします。私たちがどのように泥臭いデータ処理を自動化したのか、その全貌を全て公開します!