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の利点
- 推論時のオーバーヘッドなし: マージ後は元のモデルと同じ構造
- タスク切り替えが容易: LoRA重みを入れ替えるだけ
- 実装がシンプル: 線形層の置換のみ
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は、大規模言語モデルを限られたリソースでファインチューニングするための強力な手法です。タスクに応じてランクやスケーリング係数を調整することで、効率と性能のバランスを取ることができます。
次のステップとして、以下の記事も参考にしてください。