【実践】LoRA/PEFTで効率的にLLMをファインチューニングする

LoRA(Low-Rank Adaptation)は、大規模言語モデルを効率的にファインチューニングするための手法です。モデルの重みを直接更新する代わりに、低ランクの行列を追加することで、学習パラメータ数とメモリ使用量を大幅に削減できます。

本記事では、LoRAの数学的基礎、PEFT(Parameter-Efficient Fine-Tuning)の各手法、そしてPyTorchでの実装について解説します。

本記事の内容

  • 効率的ファインチューニングの必要性
  • LoRAの数学的原理
  • 低ランク分解と近似
  • LoRAの実装詳細
  • 他のPEFT手法との比較
  • PyTorchでの実装

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

効率的ファインチューニングの必要性

全体ファインチューニングの課題

従来の全体ファインチューニングでは、すべてのパラメータを更新します。

モデル パラメータ数 必要メモリ(FP32) 必要メモリ(学習時)
BERT-base 110M ~0.4GB ~1.6GB
GPT-2 1.5B ~6GB ~24GB
LLaMA-7B 7B ~28GB ~112GB
LLaMA-70B 70B ~280GB ~1TB以上

学習時は、勾配・オプティマイザ状態なども保持する必要があり、パラメータ数の4倍程度のメモリが必要です。

PEFTの利点

Parameter-Efficient Fine-Tuning(PEFT)は、少数のパラメータのみを更新することで:

  • メモリ効率: 全パラメータの1%未満で済む
  • 学習速度: 勾配計算が少なく高速
  • マルチタスク: タスクごとに小さなアダプターを保存可能
  • 過学習防止: 更新パラメータが少ないため過学習しにくい

LoRAの概要

基本アイデア

LoRA(Low-Rank Adaptation)は、重み行列の変化を低ランク行列で近似します。

元の重み $\bm{W}_0 \in \mathbb{R}^{d \times k}$ に対して:

$$ \bm{W} = \bm{W}_0 + \Delta\bm{W} = \bm{W}_0 + \bm{B}\bm{A} $$

ここで: – $\bm{B} \in \mathbb{R}^{d \times r}$ – $\bm{A} \in \mathbb{R}^{r \times k}$ – $r \ll \min(d, k)$ (ランク)

なぜ低ランク近似が有効か

研究によると、ファインチューニングで生じる重み変化 $\Delta\bm{W}$ は「本質的に低ランク」であることが示されています。

つまり、$\Delta\bm{W}$ の特異値は急速に減衰し、少数の特異ベクトルで大部分の情報を捉えられます。

パラメータ削減率

元の行列: $d \times k$ パラメータ LoRA: $(d \times r) + (r \times k) = r(d + k)$ パラメータ

削減率: $$ \text{削減率} = \frac{r(d + k)}{dk} $$

例: $d = k = 4096$, $r = 8$ の場合: $$ \frac{8 \times 8192}{4096^2} = \frac{65536}{16777216} \approx 0.4\% $$

LoRAの数学的原理

順伝播

LoRAを適用した線形層の順伝播:

$$ \bm{h} = \bm{W}_0 \bm{x} + \bm{B}\bm{A}\bm{x} = \bm{W}_0 \bm{x} + \bm{B}(\bm{A}\bm{x}) $$

実装では、$\bm{A}\bm{x}$ を先に計算することで効率化できます。

初期化

LoRAでは、学習開始時に $\Delta\bm{W} = \bm{B}\bm{A} = \bm{0}$ となるように初期化します。

  • $\bm{A}$: ガウス分布で初期化
  • $\bm{B}$: ゼロで初期化

これにより、学習開始時は事前学習モデルと同じ挙動になります。

スケーリング係数

実際には、スケーリング係数 $\alpha$ を導入します:

$$ \bm{h} = \bm{W}_0 \bm{x} + \frac{\alpha}{r} \bm{B}\bm{A}\bm{x} $$

$\alpha / r$ は学習率を調整する役割を果たします。典型的には $\alpha = 16$ や $\alpha = r$ が使用されます。

勾配の計算

$\bm{A}$ と $\bm{B}$ に対する勾配:

$$ \frac{\partial \mathcal{L}}{\partial \bm{A}} = \frac{\alpha}{r} \bm{B}^T \frac{\partial \mathcal{L}}{\partial \bm{h}} \bm{x}^T $$

$$ \frac{\partial \mathcal{L}}{\partial \bm{B}} = \frac{\alpha}{r} \frac{\partial \mathcal{L}}{\partial \bm{h}} (\bm{A}\bm{x})^T $$

元の重み $\bm{W}_0$ には勾配が流れず、凍結されたままです。

LoRAの適用箇所

Transformer内での適用

LoRAは主に以下の層に適用されます:

1. Attention層のプロジェクション – Query: $\bm{W}_Q$ – Key: $\bm{W}_K$ – Value: $\bm{W}_V$ – Output: $\bm{W}_O$

2. Feed-Forward Network – Up projection: $\bm{W}_{\text{up}}$ – Down projection: $\bm{W}_{\text{down}}$

推奨設定

適用箇所 効果 コスト
$\bm{W}_Q, \bm{W}_V$ のみ 良好
全Attention層 より良好
Attention + FFN 最良

PyTorchでの実装

LoRA層の実装

import torch
import torch.nn as nn
import math


class LoRALayer(nn.Module):
    """LoRA層"""
    def __init__(self, in_features, out_features, rank=8, alpha=16):
        super().__init__()
        self.rank = rank
        self.alpha = alpha
        self.scaling = alpha / rank

        # 低ランク行列
        self.lora_A = nn.Parameter(torch.zeros(rank, in_features))
        self.lora_B = nn.Parameter(torch.zeros(out_features, rank))

        # 初期化
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
        nn.init.zeros_(self.lora_B)

    def forward(self, x):
        # x: (batch, seq_len, in_features)
        # LoRAの寄与を計算
        return (x @ self.lora_A.T @ self.lora_B.T) * self.scaling


class LinearWithLoRA(nn.Module):
    """LoRA付き線形層"""
    def __init__(self, linear_layer, rank=8, alpha=16):
        super().__init__()
        self.linear = linear_layer
        self.lora = LoRALayer(
            linear_layer.in_features,
            linear_layer.out_features,
            rank=rank,
            alpha=alpha
        )
        # 元の重みは凍結
        for param in self.linear.parameters():
            param.requires_grad = False

    def forward(self, x):
        return self.linear(x) + self.lora(x)

モデルへのLoRA適用

def apply_lora_to_model(model, rank=8, alpha=16, target_modules=None):
    """
    モデルにLoRAを適用

    Args:
        model: 対象のTransformerモデル
        rank: LoRAのランク
        alpha: スケーリング係数
        target_modules: LoRAを適用するモジュール名のリスト
    """
    if target_modules is None:
        target_modules = ['query', 'value']  # デフォルト

    for name, module in model.named_modules():
        if any(target in name for target in target_modules):
            if isinstance(module, nn.Linear):
                # 親モジュールを取得
                parent_name = '.'.join(name.split('.')[:-1])
                child_name = name.split('.')[-1]
                parent = model.get_submodule(parent_name) if parent_name else model

                # LoRA付き層に置換
                lora_layer = LinearWithLoRA(module, rank=rank, alpha=alpha)
                setattr(parent, child_name, lora_layer)

    # 学習可能パラメータの確認
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    total = sum(p.numel() for p in model.parameters())
    print(f"Trainable: {trainable:,} / {total:,} ({100*trainable/total:.2f}%)")

    return model

学習ループ

def train_with_lora(model, train_loader, optimizer, num_epochs=3):
    """
    LoRAでのファインチューニング
    """
    model.train()
    for epoch in range(num_epochs):
        total_loss = 0
        for batch in train_loader:
            input_ids = batch['input_ids']
            labels = batch['labels']

            optimizer.zero_grad()
            outputs = model(input_ids, labels=labels)
            loss = outputs.loss
            loss.backward()
            optimizer.step()

            total_loss += loss.item()

        avg_loss = total_loss / len(train_loader)
        print(f"Epoch {epoch + 1}: Loss = {avg_loss:.4f}")


# 使用例
from transformers import AutoModelForCausalLM

model = AutoModelForCausalLM.from_pretrained("gpt2")
model = apply_lora_to_model(model, rank=8, alpha=16)

# LoRAパラメータのみを最適化
optimizer = torch.optim.AdamW(
    [p for p in model.parameters() if p.requires_grad],
    lr=1e-4
)

LoRA重みのマージ

学習後、LoRAの重みを元の重みにマージして、追加の計算コストなしで推論できます。

def merge_lora_weights(model):
    """
    LoRAの重みを元の重みにマージ
    """
    for name, module in model.named_modules():
        if isinstance(module, LinearWithLoRA):
            # LoRAの寄与を計算
            delta_w = (module.lora.lora_B @ module.lora.lora_A) * module.lora.scaling

            # 元の重みに加算
            module.linear.weight.data += delta_w

            # LoRAをゼロにリセット(または削除)
            module.lora.lora_A.data.zero_()
            module.lora.lora_B.data.zero_()

    print("LoRA weights merged successfully")
    return model

LoRA重みの保存と読み込み

def save_lora_weights(model, path):
    """LoRAの重みのみを保存"""
    lora_state_dict = {}
    for name, param in model.named_parameters():
        if 'lora' in name:
            lora_state_dict[name] = param.data
    torch.save(lora_state_dict, path)
    print(f"Saved LoRA weights to {path}")


def load_lora_weights(model, path):
    """LoRAの重みを読み込み"""
    lora_state_dict = torch.load(path)
    model_state_dict = model.state_dict()

    for name, param in lora_state_dict.items():
        if name in model_state_dict:
            model_state_dict[name] = param

    model.load_state_dict(model_state_dict)
    print(f"Loaded LoRA weights from {path}")

他のPEFT手法

Adapter

Transformerの各層に小さなボトルネック構造を挿入します。

元の層: x → Layer → y
Adapter: x → Layer → Adapter(y) + y → output

Adapterの構造: $$ \text{Adapter}(\bm{x}) = \bm{W}_{\text{up}} \cdot \sigma(\bm{W}_{\text{down}} \bm{x}) $$

ここで $\bm{W}_{\text{down}} \in \mathbb{R}^{r \times d}$, $\bm{W}_{\text{up}} \in \mathbb{R}^{d \times r}$, $\sigma$ は活性化関数(GELUなど)。

class Adapter(nn.Module):
    """Adapterモジュール"""
    def __init__(self, hidden_size, bottleneck_size):
        super().__init__()
        self.down_proj = nn.Linear(hidden_size, bottleneck_size)
        self.up_proj = nn.Linear(bottleneck_size, hidden_size)
        self.activation = nn.GELU()

    def forward(self, x):
        down = self.down_proj(x)
        activated = self.activation(down)
        up = self.up_proj(activated)
        return x + up  # 残差接続

Prefix Tuning

入力の前に学習可能な「プレフィックス」ベクトルを追加します。

$$ \bm{H} = \text{Attention}([\bm{P}_K; \bm{K}], [\bm{P}_V; \bm{V}], \bm{Q}) $$

ここで $\bm{P}_K, \bm{P}_V$ は学習可能なプレフィックスです。

class PrefixTuning(nn.Module):
    """Prefix Tuning"""
    def __init__(self, num_layers, num_heads, head_dim, prefix_length):
        super().__init__()
        self.prefix_length = prefix_length
        # 各層・各ヘッドに対するプレフィックス
        self.prefix_k = nn.Parameter(
            torch.randn(num_layers, prefix_length, num_heads * head_dim)
        )
        self.prefix_v = nn.Parameter(
            torch.randn(num_layers, prefix_length, num_heads * head_dim)
        )

    def get_prefix(self, layer_idx, batch_size):
        prefix_k = self.prefix_k[layer_idx].unsqueeze(0).expand(batch_size, -1, -1)
        prefix_v = self.prefix_v[layer_idx].unsqueeze(0).expand(batch_size, -1, -1)
        return prefix_k, prefix_v

Prompt Tuning

入力埋め込みに学習可能なソフトプロンプトを追加します。

$$ \bm{X}_{\text{input}} = [\bm{P}; \bm{E}(x)] $$

ここで $\bm{P}$ は学習可能なプロンプト埋め込みです。

class PromptTuning(nn.Module):
    """Prompt Tuning"""
    def __init__(self, num_tokens, embed_dim):
        super().__init__()
        self.prompt_embeddings = nn.Parameter(
            torch.randn(num_tokens, embed_dim)
        )

    def forward(self, input_embeddings):
        # input_embeddings: (batch, seq_len, embed_dim)
        batch_size = input_embeddings.size(0)
        prompt = self.prompt_embeddings.unsqueeze(0).expand(batch_size, -1, -1)
        return torch.cat([prompt, input_embeddings], dim=1)

手法の比較

手法 パラメータ効率 推論オーバーヘッド 実装複雑度
LoRA なし(マージ可能)
Adapter あり
Prefix Tuning あり
Prompt Tuning 非常に高 なし

LoRAの利点

  1. 推論時のオーバーヘッドなし: マージ後は元のモデルと同じ構造
  2. タスク切り替えが容易: LoRA重みを入れ替えるだけ
  3. 実装がシンプル: 線形層の置換のみ

QLoRA: 量子化との組み合わせ

QLoRA(Quantized LoRA)は、モデルを4ビット量子化しながらLoRAでファインチューニングする手法です。

from transformers import BitsAndBytesConfig

# 4ビット量子化設定
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type="nf4"
)

# 量子化モデルの読み込み
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    quantization_config=quantization_config
)

# LoRAを適用
model = apply_lora_to_model(model, rank=16, alpha=32)

QLoRAにより、7Bパラメータのモデルを単一のGPU(~16GB VRAM)でファインチューニングできます。

まとめ

本記事では、LoRA/PEFTによる効率的なファインチューニングについて解説しました。

  • LoRAの原理: 重み変化を低ランク行列 $\bm{B}\bm{A}$ で近似
  • パラメータ効率: 全パラメータの1%未満で同等の性能
  • 実装: 線形層にLoRAモジュールを追加するだけ
  • マージ: 学習後に元の重みと統合して追加コストなし
  • 他の手法: Adapter, Prefix Tuning, Prompt Tuningなど
  • QLoRA: 量子化との組み合わせでさらにメモリ効率化

LoRAは、大規模言語モデルを限られたリソースでファインチューニングするための強力な手法です。タスクに応じてランクやスケーリング係数を調整することで、効率と性能のバランスを取ることができます。

次のステップとして、以下の記事も参考にしてください。