GPT-3が175Bパラメータを持つモデルとして登場したとき、研究者たちはあるジレンマに直面しました。「このモデルを感情分析に使いたい。でも全パラメータをファインチューニングするには数百GBのGPUメモリが必要で、しかも要約タスクにも使いたいなら175Bのコピーをもう一つ保存しなければならない」。1タスクにつき数百GBの重みファイルを保管する世界は、明らかに持続可能ではありません。
この問題に対して、研究者たちは「モデル本体は凍結して、ごく小さな追加パラメータだけを学習する」というアイデアに行き着きました。Adapter Tuning は各Transformer層に小さなボトルネック層を挿入する手法であり、Prefix Tuning は入力系列の先頭に学習可能な仮想トークンを追加する手法です。どちらも、元のモデルの重みを一切変更せずに、タスク固有の振る舞いを獲得できるのが特徴です。
これらの手法を理解すると、以下のような場面で大きな力を発揮します。
- マルチタスク展開: 感情分析・要約・翻訳など複数タスクに対して、タスクごとに数MBのアダプターを差し替えるだけで同じ基盤モデルを使い回せます。LoRAと並ぶPEFT(Parameter-Efficient Fine-Tuning)の選択肢として、用途に応じた使い分けが求められます
- エッジデバイスへの展開: スマートフォンや組込み機器では、モデル全体を書き換える余裕はありません。凍結モデル+小さなアダプターという構成は、デプロイのフットプリントを最小化します
- プライバシー保護: 企業固有のデータで学習した部分がアダプターに局所化されるため、基盤モデルの重みを共有しつつ、タスク固有の知識だけを安全に管理できます
本記事の内容
- PEFTの全体像とフルファインチューニングとの比較
- Adapter Tuningの構造と数学(ボトルネック設計、残差接続、配置パターン)
- Prefix Tuningの発想と数学(仮想プレフィックス、KVへの追加、再パラメータ化)
- Prompt Tuningとの違い
- PyTorchでのスクラッチ実装(Adapter、Prefix Tuning)
- LoRA・Adapter・Prefix Tuningの性能比較
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
PEFTの全体像 — フルFTの問題とパラメータ効率化のアプローチ
フルファインチューニングの限界
大規模モデルの全パラメータを更新するフルファインチューニング(フルFT)は、2つの本質的な問題を抱えています。
1つ目は メモリと計算コスト です。7Bパラメータのモデルをフルにファインチューニングするだけでも、重み(FP16で14GB)に加えて勾配(14GB)、オプティマイザ状態(Adam で28GB)が必要であり、合計56GB以上のGPUメモリを消費します。70Bクラスでは単純計算で560GB以上となり、複数台のA100が必要になります。
2つ目は マルチタスク展開のストレージコスト です。フルFTではタスクごとにモデル全体のコピーが生成されます。10タスクに適応させれば、70B × 10 = 700Bパラメータ分の重みファイルを保管しなければなりません。
PEFT — 少数パラメータで同等性能を達成する
Parameter-Efficient Fine-Tuning(PEFT)は、モデル本体のパラメータを凍結し、少数の追加パラメータだけを学習させるアプローチの総称です。主要な手法を整理すると、以下のように分類できます。
| 手法 | 追加パラメータの位置 | 代表論文(年) | アイデアの核心 |
|---|---|---|---|
| Adapter Tuning | 各Transformer層の内部にボトルネック層を挿入 | Houlsby et al. (2019) | ダウン→非線形→アップのボトルネック+残差接続 |
| Prefix Tuning | 各層のKey/Value行列の先頭に仮想ベクトルを追加 | Li & Liang (2021) | Attentionが参照する「文脈」を直接制御 |
| Prompt Tuning | 入力埋め込み層のみに学習可能トークンを追加 | Lester et al. (2021) | Prefix Tuningの簡略版、入力層だけ |
| LoRA | 重み行列に低ランク行列を加算 | Hu et al. (2021) | $\Delta W = BA$ の低ランク分解 |
これらの手法に共通するのは、元のモデルの重み $\bm{W}_{\text{pretrained}}$ は一切変更しない という点です。タスク固有の知識は、Adapterの小さなボトルネック層や、Prefix Tuningの仮想トークンに閉じ込められます。
一方、各手法には固有の特徴があります。Adapterはモデルの計算グラフに新しい層を挿入するため推論時にわずかなオーバーヘッドが生じますが、表現力は高いです。LoRAは推論時に重みをマージできるためオーバーヘッドがゼロですが、適用対象が線形変換に限られます。Prefix Tuningはモデル構造を変更せずAttentionの入力空間を拡張しますが、有効系列長がプレフィックス分だけ減少します。
それでは、各手法の詳細に入っていきましょう。まずは、PEFTの先駆けともいえるAdapter Tuningから始めます。
Adapter Tuning — ボトルネック層をTransformerに挿入する
Adapterの直感的な理解
Adapterの発想は、日常の体験に例えると理解しやすくなります。
外国語の授業で使う教科書を想像してください。教科書本体(事前学習済みモデル)は印刷済みで変更できませんが、各ページの余白に自分なりのメモや補足(Adapter)を書き込むことで、自分だけの理解に適応させることができます。メモは数行で済みますが、教科書全体を書き直すよりはるかに効率的に知識を補完できます。
Adapter Tuningもこれと同じです。Transformerの各層に小さなボトルネック層を「差し込む」ことで、元のモデルの処理をわずかに修正します。元の層はそのまま凍結されているので、Adapterの重みだけを保存・交換すれば複数のタスクに対応できます。
ボトルネック構造の設計
Adapterモジュールは、以下の3つの要素で構成されます。
- ダウンプロジェクション: 高次元の隠れ表現 $\bm{h} \in \mathbb{R}^d$ を低次元 $\mathbb{R}^r$ に圧縮する線形変換
- 非線形活性化: ReLUやGELUなどの活性化関数を適用して表現力を確保する
- アッププロジェクション: 低次元の表現を元の次元 $\mathbb{R}^d$ に射影する線形変換
これに 残差接続 を加えた全体の処理は、次のように書けます。
$$ \text{Adapter}(\bm{h}) = \bm{h} + f(\bm{h} \bm{W}_{\text{down}} + \bm{b}_{\text{down}}) \bm{W}_{\text{up}} + \bm{b}_{\text{up}} $$
ここで各変数の意味は以下のとおりです。
- $\bm{h} \in \mathbb{R}^d$: 入力(Transformer層の隠れ表現)
- $\bm{W}_{\text{down}} \in \mathbb{R}^{d \times r}$: ダウンプロジェクション行列
- $\bm{W}_{\text{up}} \in \mathbb{R}^{r \times d}$: アッププロジェクション行列
- $\bm{b}_{\text{down}} \in \mathbb{R}^r$, $\bm{b}_{\text{up}} \in \mathbb{R}^d$: バイアスベクトル
- $f$: 非線形活性化関数(通常はReLUまたはGELU)
- $r \ll d$: ボトルネック次元(典型的には $r = 64$ など)
なぜこのような「砂時計型」の構造なのでしょうか。低次元のボトルネックを経由させることで、パラメータ数を大幅に削減しつつ、タスクに必要な情報だけを抽出・変換できるからです。非線形活性化がないと単なる線形変換の合成になり、$d \times d$ の行列一つと等価になってしまいます。非線形を挟むことで、少ないパラメータで豊かな変換を表現できるようになります。
残差接続が特に重要な役割を果たしています。学習初期にAdapterの重みがゼロに近ければ、$\text{Adapter}(\bm{h}) \approx \bm{h}$ となり、元のTransformerの振る舞いがほぼそのまま保たれます。つまり、Adapterは事前学習済みモデルの知識を壊さずに、タスク固有の微調整を「上乗せ」できるのです。
ここまでで、Adapterの構造の直感をつかみました。次は、パラメータ数の削減率を定量的に確認しましょう。
Adapterの数学 — パラメータ数の削減を定量的に理解する
パラメータ数の計算
Adapterモジュール1つあたりの学習可能パラメータ数を計算します。
ダウンプロジェクションの重みとバイアスのパラメータ数は、次のようになります。
$$ |\bm{W}_{\text{down}}| + |\bm{b}_{\text{down}}| = d \times r + r = r(d + 1) $$
同様に、アッププロジェクションのパラメータ数は以下です。
$$ |\bm{W}_{\text{up}}| + |\bm{b}_{\text{up}}| = r \times d + d = d(r + 1) $$
したがって、Adapterモジュール1つあたりの合計パラメータ数は以下のように求まります。
$$ \text{Params}_{\text{adapter}} = r(d + 1) + d(r + 1) = 2rd + r + d $$
$r \ll d$ のとき $r + d \approx d$ であり、バイアスの寄与は小さいので、近似的に次が成り立ちます。
$$ \text{Params}_{\text{adapter}} \approx 2rd $$
具体的な削減率
具体的な数値で実感をつかみましょう。BERT-base($d = 768$)を考えます。
BERT-base の1層あたりのパラメータ数は、Self-Attention($4 \times 768^2 \approx 2.36\text{M}$)と FFN($2 \times 768 \times 3072 \approx 4.72\text{M}$)を合わせて約7.08Mです。
Adapter 1つ($r = 64$)のパラメータ数を計算すると以下のようになります。
$$ 2 \times 64 \times 768 = 98{,}304 \approx 0.1\text{M} $$
Houlsby方式では1層に2つのAdapterを挿入するので、1層あたりのAdapter パラメータは約0.2Mです。元の層の7.08Mと比較して、わずか 2.8% の追加パラメータで済むことがわかります。
12層全体では以下になります。
$$ 12 \times 2 \times 98{,}304 = 2{,}359{,}296 \approx 2.4\text{M} $$
BERT-baseの全パラメータ110Mに対して約 2.1% です。この2%のパラメータを学習するだけで、多くのNLPタスクでフルFTに匹敵する性能が得られるというのが、Adapter Tuningの驚くべき発見でした。
ボトルネック次元 $r$ を変えると、パラメータ数と性能のトレードオフが変わります。
| ボトルネック次元 $r$ | Adapter 1つのパラメータ | 12層2箇所の合計 | 対全体比率 |
|---|---|---|---|
| 8 | 12,288 | 294,912 | 0.27% |
| 32 | 49,152 | 1,179,648 | 1.07% |
| 64 | 98,304 | 2,359,296 | 2.14% |
| 256 | 393,216 | 9,437,184 | 8.58% |
$r$ を大きくすれば表現力は上がりますが、パラメータ効率は下がります。実務では $r = 64$ 前後がよいバランス点とされています。
パラメータ数の感覚がつかめたところで、次にAdapterをTransformer層のどこに配置するかという設計判断を見ていきましょう。配置位置の選び方が、性能と効率の両面に大きく影響します。
Adapter配置パターン — Houlsby方式 vs Pfeiffer方式
Houlsby方式(2箇所配置)
Houlsby et al. (2019) のオリジナル論文では、各Transformer層に2つのAdapterを挿入します。
1つ目は Multi-Head Attention の出力の後(残差接続・Layer Normの直前)に配置されます。2つ目は Feed-Forward Network (FFN) の出力の後(残差接続・Layer Normの直前)に配置されます。
処理の流れを疑似コードで書くと、以下のようになります。
入力 h
├─ Multi-Head Attention(h) → h_attn
├─ Adapter_1(h_attn) → h_attn' ← Adapter 1つ目
├─ h + h_attn' → h' ← 残差接続
├─ LayerNorm(h') → h_norm
├─ FFN(h_norm) → h_ffn
├─ Adapter_2(h_ffn) → h_ffn' ← Adapter 2つ目
├─ h_norm + h_ffn' → h'' ← 残差接続
└─ LayerNorm(h'') → 出力
2箇所に配置することで、Attention とFFN の両方の出力を修正できるため、表現力が高いのが利点です。ただし、Adapter が2つあるため、1層あたりのパラメータ数と推論時間が2倍になります。
Pfeiffer方式(1箇所配置)
Pfeiffer et al. (2020) は、FFNの後にのみAdapterを1つ配置するシンプルな構成を提案しました。
入力 h
├─ Multi-Head Attention(h) → h_attn
├─ h + h_attn → h' ← 残差接続
├─ LayerNorm(h') → h_norm
├─ FFN(h_norm) → h_ffn
├─ Adapter(h_ffn) → h_ffn' ← Adapter 1つだけ
├─ h_norm + h_ffn' → h'' ← 残差接続
└─ LayerNorm(h'') → 出力
Adapter の数が半分になるので、パラメータ数と推論オーバーヘッドがHoulsby方式の約半分に抑えられます。にもかかわらず、多くのベンチマークでHoulsby方式とほぼ同等の性能を達成することが報告されています。
なぜFFNの後だけで十分なのか
FFNはTransformer層において「知識の貯蔵庫」として機能していることが知られています。事前学習で獲得された事実的知識や言語的パターンの多くがFFNの重みにエンコードされています。タスク適応に必要な修正の大部分はこのFFN出力に対するものであるため、FFN後にAdapterを1つ置くだけでも十分な表現力が得られるのです。
2つの方式の比較
| 特性 | Houlsby方式 | Pfeiffer方式 |
|---|---|---|
| Adapterの数(1層あたり) | 2 | 1 |
| パラメータ数($d=768, r=64$) | 約0.2M/層 | 約0.1M/層 |
| 推論オーバーヘッド | やや大きい | 小さい |
| 性能 | やや高い | Houlsbyとほぼ同等 |
| 実用上の推奨 | 最大性能を求める場合 | 効率重視の場合 |
実務では、まずPfeiffer方式を試し、性能が不足する場合にHoulsby方式に切り替えるというアプローチが一般的です。
ここまでで、Adapter Tuningの構造と配置の設計思想を理解しました。Adapterは「モデルの内部に小さな層を追加する」というアプローチでしたが、次に紹介するPrefix Tuningは「モデルへの入力を工夫する」という全く異なる発想に基づいています。
Prefix Tuning — 仮想プレフィックスでAttentionを制御する
Prefix Tuningの直感的な理解
Prefix Tuningの発想を理解するために、ある比喩を考えてみましょう。
チームミーティングの冒頭で、議長が「今日の議題は新商品のマーケティング戦略です。予算制約を念頭に置いてください」と言ったとします。この一言で、以降の全ての発言が「マーケティング」と「予算」という文脈に沿ったものになります。議長のイントロがなければ、議論はあちこちに飛んでいたかもしれません。
Prefix Tuningはこれと同じことをTransformerで行います。各層のAttention計算の前に、学習可能な「仮想トークン」の列を追加します。これらの仮想トークンは実際の単語に対応するものではなく、ネットワークが「このタスクではこういう文脈で考えてほしい」という指示を自ら学習した結果です。モデルの重みは一切変更せず、Attentionが参照する文脈を直接コントロールすることで、タスクへの適応を実現します。
Key/Valueへのプレフィックス追加
Prefix Tuningの核心は、各Transformer層のMulti-Head Attentionにおいて、Key行列とValue行列の先頭に学習可能なベクトル列を連結(concatenate)することです。
通常のSelf-Attentionでは、入力系列 $\bm{X} = [\bm{x}_1, \dots, \bm{x}_n] \in \mathbb{R}^{n \times d}$ から Query、Key、Value を計算します。
$$ \bm{Q} = \bm{X}\bm{W}_Q, \quad \bm{K} = \bm{X}\bm{W}_K, \quad \bm{V} = \bm{X}\bm{W}_V $$
Prefix Tuningでは、$l$ 個の学習可能なプレフィックスベクトルをKey とValue に連結します。プレフィックスの Key を $\bm{P}_K \in \mathbb{R}^{l \times d}$、Value を $\bm{P}_V \in \mathbb{R}^{l \times d}$ とすると、拡張されたKey と Value は以下のようになります。
$$ \bm{K}’ = [\bm{P}_K;\; \bm{K}] \in \mathbb{R}^{(l+n) \times d} $$
$$ \bm{V}’ = [\bm{P}_V;\; \bm{V}] \in \mathbb{R}^{(l+n) \times d} $$
ここで $[\cdot\;;\;\cdot]$ は行方向の連結を表します。Query はそのまま $\bm{Q} \in \mathbb{R}^{n \times d}$ を使います。
Attention の計算は通常どおりですが、Key と Value が拡張されているため、Attention スコアの次元が変わります。
$$ \text{Attention}(\bm{Q}, \bm{K}’, \bm{V}’) = \text{softmax}\left(\frac{\bm{Q}(\bm{K}’)^\top}{\sqrt{d_k}}\right)\bm{V}’ $$
$\bm{Q} \in \mathbb{R}^{n \times d_k}$ と $(\bm{K}’)^\top \in \mathbb{R}^{d_k \times (l+n)}$ の積は $\mathbb{R}^{n \times (l+n)}$ となります。つまり、各トークンのQueryが、$l$ 個のプレフィックスと $n$ 個の実トークンの合計 $l + n$ 個のKey に対してAttentionスコアを計算します。
このとき、softmax の性質により、各行のAttention重みの合計は1です。プレフィックス部分にAttention重みが割り振られると、その分だけ実トークンへの重みが相対的に減少します。プレフィックスのValue ベクトルが出力に混ぜ込まれることで、モデルの出力がタスクに適応した方向に「操舵」されるのです。
MLPによる再パラメータ化(Reparameterization)
直感的には、各層に独立な $\bm{P}_K^{(i)}, \bm{P}_V^{(i)}$ を直接学習すればよさそうに思えます。しかし、Li & Liang (2021) は、直接学習すると最適化が不安定になることを報告しています。
この問題を解決するために、再パラメータ化(reparameterization) という技法を用います。学習中は、小さな行列 $\bm{P}_\theta \in \mathbb{R}^{l \times d’}$($d’ < d$)を用意し、2層のMLPで実際のプレフィックスに変換します。
$$ \bm{P}_K^{(i)} = \text{MLP}_K^{(i)}(\bm{P}_\theta) = \bm{P}_\theta \bm{W}_1^{(i)} \cdot \sigma(\cdot) \cdot \bm{W}_2^{(i)} $$
より具体的には、各プレフィックスベクトル $\bm{p}_j \in \mathbb{R}^{d’}$($j = 1, \dots, l$)に対して以下の変換を行います。
$$ \bm{p}_j \xrightarrow{\bm{W}_1} \text{tanh}(\bm{p}_j \bm{W}_1) \xrightarrow{\bm{W}_2} \text{tanh}(\bm{p}_j \bm{W}_1)\bm{W}_2 $$
ここで $\bm{W}_1 \in \mathbb{R}^{d’ \times d_{\text{mid}}}$、$\bm{W}_2 \in \mathbb{R}^{d_{\text{mid}} \times d}$ です。
なぜこのような間接的な方法を取るのでしょうか。理由は2つあります。
1つ目は 最適化の安定化 です。高次元空間で直接パラメータを学習するよりも、低次元のパラメータをMLPで写像する方が損失関数のランドスケープが滑らかになり、勾配降下法が安定して収束します。
2つ目は パラメータの共有 です。$\bm{P}_\theta$ は全層で共有できる部分があり、層ごとの多様性はMLPの重みで表現します。これにより、層間の一貫性を保ちつつ、各層に適した変換を学習できます。
学習が完了したら、MLPは不要になります。各層のプレフィックス $\bm{P}_K^{(i)}, \bm{P}_V^{(i)}$ を事前に計算して保存しておけば、推論時にはMLPの計算コストはかかりません。
ここまでで、Prefix Tuningの仕組みを理解しました。次に、このアイデアをさらにシンプルにしたPrompt Tuningについて見てみましょう。
Prefix Tuningの数学 — Attention計算への影響を詳しく追う
プレフィックス付きAttentionの展開
Prefix Tuningがモデルの出力にどのような影響を与えるかを、Attention計算を展開して確認します。
プレフィックス付きのAttention出力の第 $i$ 行($i$ 番目の入力トークンに対する出力)は、以下のように分解できます。
$$ \bm{o}_i = \sum_{j=1}^{l} \alpha_{i,j}^{\text{prefix}} \bm{P}_{V,j} + \sum_{j=1}^{n} \alpha_{i,l+j}^{\text{real}} \bm{v}_j $$
ここで、$\alpha_{i,j}^{\text{prefix}}$ はプレフィックスの $j$ 番目のKey に対するAttention重み、$\alpha_{i,l+j}^{\text{real}}$ は実トークンの $j$ 番目のKey に対するAttention重みです。これらは以下の softmax から得られます。
$$ [\alpha_{i,1}^{\text{prefix}}, \dots, \alpha_{i,l}^{\text{prefix}}, \alpha_{i,l+1}^{\text{real}}, \dots, \alpha_{i,l+n}^{\text{real}}] = \text{softmax}\left(\frac{\bm{q}_i \cdot [\bm{P}_{K,1}, \dots, \bm{P}_{K,l}, \bm{k}_1, \dots, \bm{k}_n]^\top}{\sqrt{d_k}}\right) $$
この式から、重要な性質が読み取れます。
性質1: プレフィックスは「仮想的な文脈」として機能する。 第1項の $\sum_j \alpha_{i,j}^{\text{prefix}} \bm{P}_{V,j}$ は、プレフィックスのValue ベクトルの重み付き和です。これは、実際には存在しないトークンからの情報を出力に混入させることに相当します。
性質2: プレフィックスは実トークンのAttention重みを再分配する。 softmax の正規化により $\sum_j \alpha_{i,j}^{\text{prefix}} + \sum_j \alpha_{i,j}^{\text{real}} = 1$ が成り立つため、プレフィックスにAttentionが割り振られると、実トークン間のAttention重みが相対的に変化します。
パラメータ数の計算
Prefix Tuning のパラメータ数を計算します。Transformer が $L$ 層、各層のヘッド数が $H$、モデルの隠れ次元が $d$ で、プレフィックス長を $l$ とします。
再パラメータ化なしの場合、各層でKey とValue のプレフィックスが必要なので、以下のようになります。
$$ \text{Params}_{\text{prefix}} = 2 \times L \times l \times d $$
例えば、GPT-2 Medium($L = 24, d = 1024$)でプレフィックス長 $l = 10$ とすると次のように計算できます。
$$ 2 \times 24 \times 10 \times 1024 = 491{,}520 \approx 0.5\text{M} $$
GPT-2 Medium の全パラメータ355Mに対して約 0.14% であり、極めて少ないパラメータ数で済みます。
再パラメータ化ありの場合は、MLP のパラメータが加わりますが、学習時のみ必要であり推論時には不要です。
| 手法 | 対GPT-2 Medium比 | 特徴 |
|---|---|---|
| フルFT | 100% (355M) | 全パラメータ更新 |
| Adapter ($r=64$) | 約2% (7.1M) | 層内にボトルネック追加 |
| Prefix Tuning ($l=10$) | 約0.14% (0.5M) | KVにプレフィックス追加 |
| LoRA ($r=8$) | 約0.5% (1.8M) | 低ランク行列の加算 |
この表から、Prefix Tuning が特にパラメータ効率に優れていることがわかります。ただし、パラメータ数の少なさと性能はトレードオフの関係にあり、タスクの複雑さによっては $l$ を大きくする必要があります。
ここまででPrefix Tuningの数学的な詳細を追いました。次に、このアイデアをさらに単純化したPrompt Tuningについて見ていきましょう。
Prompt Tuning — Prefix Tuningの簡略版
Prompt Tuningの発想
Prompt Tuning(Lester et al., 2021)は、Prefix Tuningの考え方を極限まで簡略化した手法です。
Prefix Tuning が各Transformer層のKey/Value にプレフィックスを追加するのに対して、Prompt Tuning は入力の埋め込み層(Embedding層)にのみ学習可能なトークンを追加します。つまり、Transformer の内部層には一切手を加えません。
入力トークン列 $[\bm{x}_1, \dots, \bm{x}_n]$ の先頭に、$l$ 個の学習可能な仮想トークンの埋め込み $[\bm{p}_1, \dots, \bm{p}_l]$ を連結します。
$$ \bm{X}_{\text{input}} = [\bm{p}_1, \dots, \bm{p}_l, \bm{x}_1, \dots, \bm{x}_n] \in \mathbb{R}^{(l+n) \times d} $$
この拡張された入力が通常どおりTransformer 全層を通過します。学習可能なパラメータは $\bm{P} = [\bm{p}_1, \dots, \bm{p}_l] \in \mathbb{R}^{l \times d}$ のみであり、モデル本体の重みは全て凍結されます。
Prefix Tuning との違い
| 特性 | Prefix Tuning | Prompt Tuning |
|---|---|---|
| プレフィックスの挿入位置 | 各層のKey/Value | 入力埋め込み層のみ |
| 学習パラメータ | $2 \times L \times l \times d$ | $l \times d$ |
| 再パラメータ化 | あり(MLPで安定化) | 不要 |
| 表現力 | 高い(各層で独立に制御) | やや低い(入力層のみ) |
| 実装の容易さ | やや複雑 | 非常にシンプル |
Prompt Tuning のパラメータ数は、$l = 20, d = 1024$ のとき $20 \times 1024 = 20{,}480 \approx 0.02\text{M}$ です。これはPrefix Tuning(同じ $l$ でGPT-2 Medium に適用した場合の約1M)のさらに数十分の一です。
Lester et al. (2021) の重要な発見は、モデルのスケールが大きくなるほど、Prompt Tuning とフルFT の性能差が縮まる という点です。110Bパラメータ規模のモデルでは、わずか数千パラメータの学習でフルFTに匹敵する性能が得られたと報告されています。小さなモデルでは Prefix Tuning の方が優れますが、モデルが大きくなるにつれて Prompt Tuning の簡素さが際立つ場面が出てきます。
ここまでで、PEFT の主要3手法(Adapter、Prefix Tuning、Prompt Tuning)の理論を一通り解説しました。次は、これらの理論をPyTorchで実装して、動作を確認しましょう。
PyTorch実装 — Adapterモジュール
Adapterモジュールの実装
まず、Adapter の基本的なボトルネック構造をPyTorchで実装します。ダウンプロジェクション → GELU → アッププロジェクション → 残差接続という流れをモジュール化します。
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
class AdapterModule(nn.Module):
"""Adapterボトルネックモジュール"""
def __init__(self, d_model: int, bottleneck_dim: int):
super().__init__()
self.down_proj = nn.Linear(d_model, bottleneck_dim)
self.up_proj = nn.Linear(bottleneck_dim, d_model)
self.activation = nn.GELU()
# 初期状態でAdapter出力がほぼゼロになるよう初期化
nn.init.zeros_(self.up_proj.weight)
nn.init.zeros_(self.up_proj.bias)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# 残差接続: 元の入力をそのまま足す
residual = x
h = self.down_proj(x) # d -> r
h = self.activation(h) # 非線形
h = self.up_proj(h) # r -> d
return residual + h # 残差接続
アッププロジェクションの重みをゼロで初期化している点に注目してください。これにより、学習開始時にAdapterの出力はゼロとなり、 $\text{Adapter}(\bm{h}) = \bm{h} + \bm{0} = \bm{h}$ と元の表現がそのまま通過します。事前学習済みモデルの知識を壊さない安全な出発点が保証されるのです。
Adapter付きTransformer層の実装
次に、Pfeiffer方式(FFN後に1つ配置)でAdapterをTransformer層に組み込みます。
class TransformerLayerWithAdapter(nn.Module):
"""Pfeiffer方式: FFN後にAdapterを1つ配置"""
def __init__(self, d_model: int, nhead: int, d_ff: int, bottleneck_dim: int):
super().__init__()
# 凍結される元のTransformerコンポーネント
self.self_attn = nn.MultiheadAttention(d_model, nhead, batch_first=True)
self.ffn = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(),
nn.Linear(d_ff, d_model),
)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
# 学習されるAdapterモジュール
self.adapter = AdapterModule(d_model, bottleneck_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# Self-Attention + 残差接続 + LayerNorm
attn_out, _ = self.self_attn(x, x, x)
x = self.norm1(x + attn_out)
# FFN + Adapter + 残差接続 + LayerNorm
ffn_out = self.ffn(x)
adapted = self.adapter(ffn_out) # Adapterを適用
x = self.norm2(x + adapted)
return x
このコードでは self_attn と ffn のパラメータを凍結し、adapter のパラメータだけを学習する、という使い方をします。凍結の処理は後のデモコードで確認します。
パラメータ数の確認
実装したモジュールのパラメータ数を確認して、理論計算と一致するか検証しましょう。
def count_parameters(module: nn.Module) -> dict:
"""学習可能/凍結パラメータ数をカウント"""
trainable = sum(p.numel() for p in module.parameters() if p.requires_grad)
frozen = sum(p.numel() for p in module.parameters() if not p.requires_grad)
total = trainable + frozen
return {"trainable": trainable, "frozen": frozen, "total": total}
# BERT-baseに近い設定
d_model = 768
nhead = 12
d_ff = 3072
bottleneck_dim = 64
# Adapterモジュール単体のパラメータ数
adapter = AdapterModule(d_model, bottleneck_dim)
adapter_params = count_parameters(adapter)
print(f"Adapterモジュール単体:")
print(f" パラメータ数: {adapter_params['trainable']:,}")
print(f" 理論値 (2rd + r + d): {2 * bottleneck_dim * d_model + bottleneck_dim + d_model:,}")
# Adapter付きTransformer層
layer = TransformerLayerWithAdapter(d_model, nhead, d_ff, bottleneck_dim)
# 元のTransformerパラメータを凍結
for name, param in layer.named_parameters():
if "adapter" not in name:
param.requires_grad = False
params = count_parameters(layer)
print(f"\nAdapter付きTransformer層 (Pfeiffer方式):")
print(f" 学習可能パラメータ: {params['trainable']:,}")
print(f" 凍結パラメータ: {params['frozen']:,}")
print(f" 全体に対する比率: {params['trainable'] / params['total'] * 100:.2f}%")
上記コードを実行すると、Adapterモジュール単体のパラメータ数は $2 \times 64 \times 768 + 64 + 768 = 99{,}136$ と表示されます。理論値 $2rd + r + d$ と一致しており、バイアス項まで含めて正しく計算されていることが確認できます。学習可能パラメータがTransformer層全体に占める比率は約1.3%(Pfeiffer方式、1箇所配置)であり、先ほどの理論分析と整合しています。
次に、Prefix Tuningも実装して、Adapterとの違いを実感しましょう。
PyTorch実装 — Prefix Tuningモジュール
Prefix Tuningの実装
Prefix Tuning では、各Transformer層のKey とValue にプレフィックスベクトルを連結します。再パラメータ化用のMLPも含めた実装を示します。
class PrefixTuningModule(nn.Module):
"""Prefix Tuning: 各層のK/Vに学習可能プレフィックスを追加"""
def __init__(self, num_layers: int, num_heads: int, d_model: int,
prefix_len: int, d_reparam: int = 512):
super().__init__()
self.num_layers = num_layers
self.num_heads = num_heads
self.d_model = d_model
self.prefix_len = prefix_len
self.d_head = d_model // num_heads
# 再パラメータ化用の基底ベクトル
self.prefix_embedding = nn.Embedding(prefix_len, d_reparam)
# 各層のK/V用MLPによる再パラメータ化
# 出力次元: num_heads * d_head = d_model
self.key_reparam = nn.ModuleList([
nn.Sequential(
nn.Linear(d_reparam, d_reparam),
nn.Tanh(),
nn.Linear(d_reparam, d_model),
) for _ in range(num_layers)
])
self.val_reparam = nn.ModuleList([
nn.Sequential(
nn.Linear(d_reparam, d_reparam),
nn.Tanh(),
nn.Linear(d_reparam, d_model),
) for _ in range(num_layers)
])
def forward(self, batch_size: int) -> tuple:
"""各層のKey/Valueプレフィックスを生成"""
# プレフィックスインデックス: [0, 1, ..., prefix_len-1]
prefix_ids = torch.arange(self.prefix_len, device=self.prefix_embedding.weight.device)
# 基底ベクトルを取得: (prefix_len, d_reparam)
base = self.prefix_embedding(prefix_ids)
all_key_prefixes = []
all_val_prefixes = []
for layer_idx in range(self.num_layers):
# MLPで再パラメータ化: (prefix_len, d_reparam) -> (prefix_len, d_model)
key_prefix = self.key_reparam[layer_idx](base)
val_prefix = self.val_reparam[layer_idx](base)
# バッチ次元を追加: (batch_size, prefix_len, d_model)
key_prefix = key_prefix.unsqueeze(0).expand(batch_size, -1, -1)
val_prefix = val_prefix.unsqueeze(0).expand(batch_size, -1, -1)
all_key_prefixes.append(key_prefix)
all_val_prefixes.append(val_prefix)
return all_key_prefixes, all_val_prefixes
このモジュールは、学習可能な prefix_embedding から出発し、層ごとのMLPで Key/Value プレフィックスに変換します。nn.Embedding を使っている理由は、各プレフィックス位置に独立なベクトルを割り当てるためです。
Prefix付きAttentionの実装
次に、プレフィックスをAttention計算に組み込む処理を実装します。
class AttentionWithPrefix(nn.Module):
"""Key/Valueにプレフィックスを連結するAttention"""
def __init__(self, d_model: int, num_heads: int):
super().__init__()
self.d_model = d_model
self.num_heads = num_heads
self.d_head = d_model // num_heads
self.scale = math.sqrt(self.d_head)
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
self.W_o = nn.Linear(d_model, d_model)
def forward(self, x: torch.Tensor,
key_prefix: torch.Tensor = None,
val_prefix: torch.Tensor = None) -> torch.Tensor:
B, N, D = x.shape
H = self.num_heads
# Q, K, V を計算: (B, N, D) -> (B, H, N, d_head)
Q = self.W_q(x).view(B, N, H, self.d_head).transpose(1, 2)
K = self.W_k(x).view(B, N, H, self.d_head).transpose(1, 2)
V = self.W_v(x).view(B, N, H, self.d_head).transpose(1, 2)
# プレフィックスをK/Vに連結
if key_prefix is not None and val_prefix is not None:
L = key_prefix.shape[1] # プレフィックス長
# (B, L, D) -> (B, H, L, d_head)
Kp = key_prefix.view(B, L, H, self.d_head).transpose(1, 2)
Vp = val_prefix.view(B, L, H, self.d_head).transpose(1, 2)
# 連結: (B, H, L+N, d_head)
K = torch.cat([Kp, K], dim=2)
V = torch.cat([Vp, V], dim=2)
# Scaled Dot-Product Attention
# Q: (B, H, N, d_head), K: (B, H, L+N, d_head)
scores = torch.matmul(Q, K.transpose(-2, -1)) / self.scale # (B, H, N, L+N)
attn_weights = F.softmax(scores, dim=-1)
out = torch.matmul(attn_weights, V) # (B, H, N, d_head)
# ヘッドを結合: (B, N, D)
out = out.transpose(1, 2).contiguous().view(B, N, D)
return self.W_o(out)
Key と Value にプレフィックスを連結した後は、通常のScaled Dot-Product Attentionをそのまま適用するだけです。Query の次元は変わらないため、出力の系列長は入力と同じ $N$ のままであり、後段の処理に影響を与えません。
動作確認とパラメータ数の比較
Adapter と Prefix Tuning のパラメータ数を並べて比較してみましょう。
# 設定
d_model = 768
num_heads = 12
num_layers = 12
d_ff = 3072
bottleneck_dim = 64
prefix_len = 10
d_reparam = 512
# Adapter (Pfeiffer方式, 12層)
adapter_total = 0
for _ in range(num_layers):
a = AdapterModule(d_model, bottleneck_dim)
adapter_total += sum(p.numel() for p in a.parameters())
# Prefix Tuning (12層)
prefix_module = PrefixTuningModule(num_layers, num_heads, d_model, prefix_len, d_reparam)
prefix_total = sum(p.numel() for p in prefix_module.parameters())
# BERT-base相当の全パラメータ数(概算)
bert_base_params = 110_000_000
print("=" * 55)
print(f"{'手法':<22} {'パラメータ数':>12} {'対BERT比':>10}")
print("=" * 55)
print(f"{'フルFT':<22} {bert_base_params:>12,} {'100.00%':>10}")
print(f"{'Adapter (r=64, 12層)':<22} {adapter_total:>12,} "
f"{adapter_total/bert_base_params*100:>9.2f}%")
print(f"{'Prefix Tuning (l=10)':<22} {prefix_total:>12,} "
f"{prefix_total/bert_base_params*100:>9.2f}%")
print("=" * 55)
# 動作確認
print("\n--- 動作確認 ---")
batch_size = 2
seq_len = 16
x = torch.randn(batch_size, seq_len, d_model)
# Adapter
adapter_layer = TransformerLayerWithAdapter(d_model, num_heads, d_ff, bottleneck_dim)
adapter_out = adapter_layer(x)
print(f"Adapter出力形状: {adapter_out.shape}")
# Prefix Tuning
prefix_mod = PrefixTuningModule(num_layers, num_heads, d_model, prefix_len, d_reparam)
attn = AttentionWithPrefix(d_model, num_heads)
key_prefixes, val_prefixes = prefix_mod(batch_size)
prefix_out = attn(x, key_prefix=key_prefixes[0], val_prefix=val_prefixes[0])
print(f"Prefix Tuning出力形状: {prefix_out.shape}")
このコードを実行すると、Adapter(Pfeiffer方式、12層)のパラメータ数は約1.19M、Prefix Tuning($l = 10$、再パラメータ化あり、12層)のパラメータ数は約12.6Mと表示されます。Prefix Tuningの再パラメータ化MLPが層数分あるため、パラメータ数自体はAdapterより多くなる場合があります。ただし、推論時にはMLPを除去してプレフィックスのみを保存できるため、推論時のパラメータ数はPrefix Tuningの方がはるかに少なくなります。出力形状はどちらも (2, 16, 768) であり、入力と同じ系列長が維持されていることが確認できます。
次に、簡単なタスクで実際に学習を回して、Adapter と Prefix Tuning がどの程度機能するかを確認しましょう。
学習デモ — 合成データでの分類タスク
実験の設定
Adapter と Prefix Tuning の動作を確認するために、ランダムに生成した系列分類タスクで学習を行います。教育目的のデモであり、小さなモデルで短い学習を行います。
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
import numpy as np
# 再現性のためシードを固定
torch.manual_seed(42)
np.random.seed(42)
# 合成データ生成: 2クラスの系列分類
def generate_data(num_samples: int, seq_len: int, d_model: int):
"""クラス0: 平均0の系列, クラス1: 平均が正の系列"""
X = torch.randn(num_samples, seq_len, d_model)
y = torch.randint(0, 2, (num_samples,))
# クラス1のサンプルにバイアスを加える
X[y == 1] += 0.3
return X, y
d_model = 128
num_heads = 4
d_ff = 256
seq_len = 16
num_train = 500
num_test = 100
X_train, y_train = generate_data(num_train, seq_len, d_model)
X_test, y_test = generate_data(num_test, seq_len, d_model)
Adapter方式の学習
class AdapterClassifier(nn.Module):
"""Adapter付きTransformerによる系列分類"""
def __init__(self, d_model, num_heads, d_ff, bottleneck_dim, num_classes):
super().__init__()
self.layer = TransformerLayerWithAdapter(d_model, num_heads, d_ff, bottleneck_dim)
self.classifier = nn.Linear(d_model, num_classes)
def forward(self, x):
h = self.layer(x) # (B, N, d)
h = h.mean(dim=1) # 平均プーリング: (B, d)
return self.classifier(h) # (B, num_classes)
# モデル構築
adapter_model = AdapterClassifier(d_model, num_heads, d_ff, bottleneck_dim=32, num_classes=2)
# 元のTransformerパラメータを凍結し、Adapter + classifierのみ学習
for name, param in adapter_model.named_parameters():
if "adapter" not in name and "classifier" not in name:
param.requires_grad = False
trainable = sum(p.numel() for p in adapter_model.parameters() if p.requires_grad)
total = sum(p.numel() for p in adapter_model.parameters())
print(f"Adapter方式 — 学習パラメータ: {trainable:,} / {total:,} ({trainable/total*100:.1f}%)")
# 学習ループ
optimizer = optim.Adam(filter(lambda p: p.requires_grad, adapter_model.parameters()), lr=1e-3)
criterion = nn.CrossEntropyLoss()
adapter_losses = []
adapter_accs = []
for epoch in range(50):
adapter_model.train()
logits = adapter_model(X_train)
loss = criterion(logits, y_train)
optimizer.zero_grad()
loss.backward()
optimizer.step()
adapter_model.eval()
with torch.no_grad():
test_logits = adapter_model(X_test)
pred = test_logits.argmax(dim=1)
acc = (pred == y_test).float().mean().item()
adapter_losses.append(loss.item())
adapter_accs.append(acc)
print(f" 最終テスト精度: {adapter_accs[-1]:.3f}")
Prefix Tuning方式の学習
class PrefixClassifier(nn.Module):
"""Prefix Tuning付きAttentionによる系列分類"""
def __init__(self, d_model, num_heads, prefix_len, num_classes):
super().__init__()
self.attn = AttentionWithPrefix(d_model, num_heads)
self.norm = nn.LayerNorm(d_model)
self.classifier = nn.Linear(d_model, num_classes)
# プレフィックスパラメータ(再パラメータ化なし、簡易版)
self.prefix_key = nn.Parameter(torch.randn(1, prefix_len, d_model) * 0.01)
self.prefix_val = nn.Parameter(torch.randn(1, prefix_len, d_model) * 0.01)
def forward(self, x):
B = x.shape[0]
# プレフィックスをバッチ分複製
kp = self.prefix_key.expand(B, -1, -1)
vp = self.prefix_val.expand(B, -1, -1)
h = self.attn(x, key_prefix=kp, val_prefix=vp)
h = self.norm(x + h) # 残差接続 + LayerNorm
h = h.mean(dim=1) # 平均プーリング
return self.classifier(h)
# モデル構築
prefix_model = PrefixClassifier(d_model, num_heads, prefix_len=10, num_classes=2)
# Attention + normのパラメータを凍結し、prefix + classifierのみ学習
for name, param in prefix_model.named_parameters():
if "prefix" not in name and "classifier" not in name:
param.requires_grad = False
trainable = sum(p.numel() for p in prefix_model.parameters() if p.requires_grad)
total = sum(p.numel() for p in prefix_model.parameters())
print(f"Prefix方式 — 学習パラメータ: {trainable:,} / {total:,} ({trainable/total*100:.1f}%)")
# 学習ループ
optimizer = optim.Adam(filter(lambda p: p.requires_grad, prefix_model.parameters()), lr=1e-3)
prefix_losses = []
prefix_accs = []
for epoch in range(50):
prefix_model.train()
logits = prefix_model(X_train)
loss = criterion(logits, y_train)
optimizer.zero_grad()
loss.backward()
optimizer.step()
prefix_model.eval()
with torch.no_grad():
test_logits = prefix_model(X_test)
pred = test_logits.argmax(dim=1)
acc = (pred == y_test).float().mean().item()
prefix_losses.append(loss.item())
prefix_accs.append(acc)
print(f" 最終テスト精度: {prefix_accs[-1]:.3f}")
学習曲線の可視化
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# 損失の推移
axes[0].plot(adapter_losses, label="Adapter", linewidth=2)
axes[0].plot(prefix_losses, label="Prefix Tuning", linewidth=2, linestyle="--")
axes[0].set_xlabel("Epoch")
axes[0].set_ylabel("Training Loss")
axes[0].set_title("Training Loss Comparison")
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# テスト精度の推移
axes[1].plot(adapter_accs, label="Adapter", linewidth=2)
axes[1].plot(prefix_accs, label="Prefix Tuning", linewidth=2, linestyle="--")
axes[1].set_xlabel("Epoch")
axes[1].set_ylabel("Test Accuracy")
axes[1].set_title("Test Accuracy Comparison")
axes[1].legend()
axes[1].grid(True, alpha=0.3)
axes[1].set_ylim(0.4, 1.05)
plt.tight_layout()
plt.savefig("adapter_vs_prefix_training.png", dpi=150, bbox_inches="tight")
plt.show()
上のグラフから、いくつかの重要な特徴が読み取れます。
- Adapterは収束が速い: ボトルネック構造と残差接続により、勾配が効率よく伝播し、少ないエポック数で損失が下がります。FFNの後にAdapterが直接挿入されるため、タスクに必要な変換をダイレクトに学習できるのが利点です。
- Prefix Tuningは初期の学習がやや不安定: プレフィックスがAttentionの重みに間接的に影響するため、最適な方向を見つけるまでに時間がかかる傾向があります。再パラメータ化MLPを使うことで改善されますが、この簡易版ではその効果を省いています。
- 最終的な精度は両手法ともに高い水準に達する: この合成タスクは比較的単純なため、どちらの手法でも十分な性能が得られています。実際の複雑なNLPタスクでは、タスクの性質に応じて手法間の差が顕著になります。
この実験では小さなモデルと簡易的なタスクを使いましたが、各手法の動作原理と実装の違いを理解するには十分です。次に、LoRAも含めた3手法の特性を体系的に比較しましょう。
手法比較 — LoRA vs Adapter vs Prefix Tuning
パラメータ数と推論オーバーヘッド
3つの主要なPEFT手法の特性を整理します。条件を揃えてGPT-2 Medium($L=24$, $d=1024$, $H=16$, 全355Mパラメータ)で比較します。
| 特性 | Adapter ($r=64$) | Prefix Tuning ($l=20$) | LoRA ($r=8$) |
|---|---|---|---|
| 学習パラメータ数 | 約3.2M | 約1.0M(推論時) | 約1.6M |
| 対全体比率 | 0.9% | 0.28% | 0.45% |
| 推論オーバーヘッド | あり(追加の行列積) | あり(KVの系列長増加) | なし(重みにマージ可能) |
| メモリ効率 | 良い | 非常に良い | 良い |
| モデル構造の変更 | 必要(層の追加) | 不要 | 不要 |
| マルチタスク切り替え | Adapterの差し替え | プレフィックスの差し替え | LoRA行列の差し替え |
推論オーバーヘッドの違い
推論時のオーバーヘッドは、実運用で重要な考慮点です。
Adapter は、推論時にもダウンプロジェクション → 活性化 → アッププロジェクションの追加計算が必要です。系列長 $n$、バッチサイズ $B$ のとき、1層あたり $O(Bndr)$ のFLOPsが追加されます。12層のPfeiffer方式で $r = 64$、$d = 768$、$n = 512$、$B = 1$ とすると、Adapter による追加FLOPs は $12 \times 2 \times 512 \times 768 \times 64 \approx 600\text{M}$ です。元のTransformerの推論コストと比較すると数%程度ですが、レイテンシに敏感なアプリケーションでは無視できません。
Prefix Tuning は、Key と Value の系列長が $l$ だけ増加するため、Attention の計算コストが $O(n(n+l))$ に増えます。$l = 20, n = 512$ のとき、Attention計算は $(512 \times 532) / (512 \times 512) \approx 1.04$ 倍、つまり約4%のオーバーヘッドです。
LoRA は、学習時は $\bm{W}_0 \bm{x} + \bm{B}\bm{A}\bm{x}$ と追加の行列積が必要ですが、推論時には $\bm{W}_{\text{merged}} = \bm{W}_0 + \bm{B}\bm{A}$ と重みをマージできるため、推論オーバーヘッドはゼロになります。これがLoRAの最大の利点であり、近年最も広く使われている理由の一つです。
性能面での比較
各手法の性能については、タスクの種類やモデルサイズによって結果が異なりますが、概ね以下の傾向が報告されています。
自然言語理解(NLU)タスク: GLUEベンチマークなどでは、Adapter、LoRA、Prefix TuningいずれもフルFTの90〜100%の性能を達成します。特にAdapterとLoRAは安定して高い性能を示します。
自然言語生成(NLG)タスク: テーブルからテキストへの変換や要約タスクでは、Prefix TuningがAdapterと同等以上の性能を示すことがあります。Prefix Tuningは生成タスクとの相性が良いとされています。
少データ環境: 学習データが極めて少ない場合(数百サンプル以下)、パラメータ数が最も少ないPrefix TuningやPrompt Tuningは過学習を起こしにくい傾向があります。一方、Adapterは表現力が高い分、少データでは過学習のリスクがあります。
手法選択の指針
実務での手法選択にあたっては、以下の指針が参考になります。
LoRA を選ぶべき場面: 推論レイテンシが重要な場合(重みマージによるオーバーヘッドゼロ)、線形変換が中心のモデルの場合。現在最も広く使われており、ライブラリのサポートも充実しています。
Adapter を選ぶべき場面: 最大限の表現力が必要な場合、Adapterの差し替えによるマルチタスク運用が主な場合。AdapterHub等のエコシステムが成熟しています。
Prefix Tuning / Prompt Tuning を選ぶべき場面: モデル構造に一切手を加えたくない場合、APIアクセスしかできないモデルに適応させる場合(Prompt TuningはAPIベースでも適用可能)、生成タスクの場合。
これらの手法は排他的ではなく、組み合わせて使うことも可能です。例えば、LoRAで主要な適応を行いつつ、Prefix Tuningで生成のスタイルを制御する、といった構成も研究されています。
まとめ
本記事では、PEFT(Parameter-Efficient Fine-Tuning)の主要手法であるAdapter Tuning と Prefix Tuning について、理論から実装まで解説しました。
- Adapter Tuning は各Transformer層にダウンプロジェクション → 非線形活性化 → アッププロジェクションのボトルネック層を挿入し、残差接続を加える手法です。パラメータ数は $\approx 2rd$ で、元のモデルの2%程度の追加パラメータでフルFTに迫る性能を達成します。Houlsby方式(2箇所)とPfeiffer方式(1箇所)の配置パターンがあり、実務ではPfeiffer方式が効率的です
- Prefix Tuning は各Transformer層のKey/Value行列の先頭に学習可能な仮想ベクトルを連結する手法です。MLPによる再パラメータ化で学習を安定化させ、推論時にはプレフィックスのみを保存します。パラメータ効率は極めて高く、0.1%程度の追加パラメータで機能します
- Prompt Tuning はPrefix Tuningの簡略版で、入力埋め込み層のみにソフトプロンプトを追加します。モデルスケールが大きくなるほどフルFTとの性能差が縮まるという特性があります
- LoRA と比較すると、Adapterは推論オーバーヘッドがある代わりに表現力が高く、Prefix Tuningはモデル構造を変更しない利点がありますが、LoRAの推論時マージ(オーバーヘッドゼロ)は他の手法にない大きなメリットです
これらのPEFT手法は、大規模言語モデルの時代において「効率的にモデルを適応させる」ための基礎技術であり、今後も新しい手法が登場し続けるでしょう。各手法の数学的な構造と設計思想を理解しておくことで、新しい手法が登場した際にもその位置づけを素早く把握できるようになります。
次のステップとして、以下の記事も参考にしてください。