転移学習(Transfer Learning)とファインチューニング(Fine-tuning)は、ある領域やタスクで学習した知識を別のタスクに活用するための技術です。特に自然言語処理(NLP)の分野では、BERT や GPT のような大規模な事前学習モデルを下流タスクにファインチューニングするパラダイムが標準的な手法となり、少量のラベル付きデータでも高い性能を達成できるようになりました。
なぜ転移学習が有効なのでしょうか。直感的には、言語の基本的な構造(文法、意味関係、世界知識)は多くのタスクで共通しているため、大規模コーパスで学習した表現は様々な下流タスクに役立つと考えられます。本記事では、この直感を理論的に裏付け、効率的なファインチューニング戦略を数式レベルで解説します。
本記事の内容
- 転移学習の動機と理論的背景(タスク間の類似度とドメインシフト)
- 特徴抽出(Feature Extraction)vs ファインチューニングの比較
- ファインチューニングの数学的定式化
- 学習率の戦略(層別学習率、Warmup + Linear Decay)
- 過学習防止策(ドロップアウト・重み減衰・早期停止)
- ULMFiT(Howard & Ruder)の3段階戦略
- LoRA(Low-Rank Adaptation)$\bm{W} = \bm{W}_0 + \bm{B}\bm{A}$ の理論
- Python で huggingface transformers を使った BERT ファインチューニング(テキスト分類)
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
転移学習の動機
従来のアプローチの限界
転移学習以前の NLP では、各タスクに対して個別にモデルを一から学習していました。
- 大量のラベル付きデータが必要: 感情分析、固有表現認識、質問応答など、各タスクごとに人手でアノテーションしたデータを用意する必要がある
- 学習の非効率性: 異なるタスクでも言語の基本構造は共通しているのに、毎回ゼロから学習する
- 少量データでの性能不足: ラベル付きデータが少ないタスクでは過学習しやすい
転移学習の基本フレームワーク
転移学習は、ソースタスク(source task)で学習した知識をターゲットタスク(target task)に転移します。
ソースタスクのドメインを $\mathcal{D}_S = \{\mathcal{X}_S, P_S(\bm{x})\}$、タスクを $\mathcal{T}_S = \{\mathcal{Y}_S, P_S(y \mid \bm{x})\}$ とします。同様に、ターゲットのドメインを $\mathcal{D}_T$、タスクを $\mathcal{T}_T$ とします。
転移学習の定義: $\mathcal{D}_S \neq \mathcal{D}_T$ または $\mathcal{T}_S \neq \mathcal{T}_T$ のとき、$\mathcal{D}_S$ と $\mathcal{T}_S$ での学習結果を利用して $\mathcal{T}_T$ の学習を改善すること。
NLP における事前学習→ファインチューニングの場合:
- ソースタスク: 大規模コーパスでの言語モデリング(MLM や自己回帰 LM)
- ターゲットタスク: テキスト分類、固有表現認識、質問応答など
タスク間の類似度とドメインシフト
ドメイン適応の理論
転移学習がうまく機能するかどうかは、ソースドメインとターゲットドメインの「距離」に依存します。Ben-David et al. (2010) は、ターゲットドメインでの誤差 $\epsilon_T$ の上界を以下のように示しました。
$$ \begin{equation} \epsilon_T(h) \leq \epsilon_S(h) + \frac{1}{2} d_{\mathcal{H}\Delta\mathcal{H}}(\mathcal{D}_S, \mathcal{D}_T) + \lambda^* \end{equation} $$
ここで
- $\epsilon_S(h)$: ソースドメインでの仮説 $h$ の誤差
- $d_{\mathcal{H}\Delta\mathcal{H}}(\mathcal{D}_S, \mathcal{D}_T)$: 2つのドメイン間の $\mathcal{H}\Delta\mathcal{H}$-距離(仮説空間 $\mathcal{H}$ で2つの分布をどれだけ区別できるか)
- $\lambda^*$: 両ドメインで同時に低い誤差を達成する最良の仮説の誤差(達成可能な最小誤差)
直感的な理解
この不等式は以下を意味します。
- ソースでの性能が良いほど($\epsilon_S$ が小さい)、ターゲットでも良い性能が期待できる
- ドメイン間の距離が近いほど($d_{\mathcal{H}\Delta\mathcal{H}}$ が小さい)、転移が成功しやすい
- 共通の良い仮説が存在するほど($\lambda^*$ が小さい)、転移が有効
NLP では、事前学習コーパスが十分に大きく多様であれば、ほとんどの下流タスクのドメインをカバーできるため、$d_{\mathcal{H}\Delta\mathcal{H}}$ が比較的小さくなります。
負の転移
ソースとターゲットのドメインが大きく異なる場合、転移学習が逆効果になることがあります。これを負の転移(negative transfer)と呼びます。
$$ \epsilon_T(\text{transfer}) > \epsilon_T(\text{from scratch}) $$
例えば、医療テキストの分類に、ソーシャルメディアで事前学習したモデルを転移する場合、ドメインシフトが大きすぎて負の転移が起きる可能性があります。
特徴抽出 vs ファインチューニング
事前学習済みモデルを下流タスクに適用する方法は大きく2つあります。
1. 特徴抽出(Feature Extraction)
事前学習済みモデルの重みを固定(凍結)し、出力される特徴表現を入力として新たな分類器を学習します。
$$ \hat{y} = f_{\text{cls}}(\phi_{\bm{\theta}^*}(\bm{x}); \bm{w}) $$
ここで $\phi_{\bm{\theta}^*}$ は事前学習済みの特徴抽出器(重み $\bm{\theta}^*$ は固定)、$f_{\text{cls}}$ は新たに追加した分類ヘッド(パラメータ $\bm{w}$ のみを学習)です。
最適化の対象は $\bm{w}$ のみです。
$$ \bm{w}^* = \arg\min_{\bm{w}} \mathcal{L}_{\text{task}}(\bm{w}; \bm{\theta}^*) $$
2. ファインチューニング
事前学習済みモデルの重みを初期値として、全パラメータ(または一部)をタスク固有のデータで更新します。
$$ \hat{y} = f_{\text{cls}}(\phi_{\bm{\theta}}(\bm{x}); \bm{w}) $$
最適化の対象は $\bm{\theta}$ と $\bm{w}$ の両方です。
$$ (\bm{\theta}^*, \bm{w}^*) = \arg\min_{\bm{\theta}, \bm{w}} \mathcal{L}_{\text{task}}(\bm{\theta}, \bm{w}) $$
ただし、$\bm{\theta}$ の初期値は事前学習済みの $\bm{\theta}_{\text{pretrained}}$ です。
比較
| 特性 | 特徴抽出 | ファインチューニング |
|---|---|---|
| 学習パラメータ | 分類ヘッドのみ | 全パラメータ(または一部) |
| 計算コスト | 低い | 高い |
| メモリ使用量 | 少ない | 多い |
| 少量データ | 過学習しにくい | 過学習リスクあり |
| 大量データ | 性能の上限がある | 高い性能を達成 |
| ドメインシフト | 対処しにくい | 適応可能 |
ファインチューニングの数学的定式化
事前学習重みからの初期化
事前学習済みモデルのパラメータを $\bm{\theta}_0 = \bm{\theta}_{\text{pretrained}}$ とし、ファインチューニングの目的関数を
$$ \begin{equation} \mathcal{L}_{\text{FT}}(\bm{\theta}) = \frac{1}{N} \sum_{i=1}^{N} \ell(f(\bm{x}_i; \bm{\theta}), y_i) + \lambda \Omega(\bm{\theta}) \end{equation} $$
と定義します。ここで $\ell$ はタスク固有の損失(交差エントロピーなど)、$\Omega(\bm{\theta})$ は正則化項です。
パラメータの更新は勾配降下法で行います。
$$ \bm{\theta}_{t+1} = \bm{\theta}_t – \eta_t \nabla_{\bm{\theta}} \mathcal{L}_{\text{FT}}(\bm{\theta}_t) $$
ここで重要なのは初期値 $\bm{\theta}_0$ です。ランダム初期化と比較して、事前学習による初期化は以下の利点を持ちます。
- 損失関数の良い領域からスタート: 事前学習で言語の一般的な知識を獲得しているため、損失関数の平坦な極小値の近傍に位置する
- 少ないステップで収束: 良い初期値からの微調整なので、大量のデータやエポック数が不要
- 汎化性能の向上: 事前学習による暗黙的な正則化効果
L2 正則化(事前学習重みへの近接)
ファインチューニング中に事前学習で獲得した知識が大きく失われることを防ぐため、事前学習重みからの距離にペナルティを課すことがあります。
$$ \Omega(\bm{\theta}) = \frac{1}{2} \|\bm{\theta} – \bm{\theta}_0\|_2^2 $$
これにより、更新後のパラメータが事前学習時のパラメータから大きく離れないように制約されます。目的関数は
$$ \mathcal{L}_{\text{FT}}(\bm{\theta}) = \frac{1}{N} \sum_{i=1}^{N} \ell(f(\bm{x}_i; \bm{\theta}), y_i) + \frac{\lambda}{2} \|\bm{\theta} – \bm{\theta}_0\|_2^2 $$
勾配は
$$ \nabla_{\bm{\theta}} \mathcal{L}_{\text{FT}} = \frac{1}{N} \sum_{i=1}^{N} \nabla_{\bm{\theta}} \ell(f(\bm{x}_i; \bm{\theta}), y_i) + \lambda(\bm{\theta} – \bm{\theta}_0) $$
学習率の戦略
層別学習率(Discriminative Learning Rates)
ニューラルネットワークの各層は異なるレベルの特徴を学習しています(下位層は一般的な特徴、上位層はタスク固有の特徴)。したがって、層ごとに異なる学習率を設定することが効果的です。
$L$ 層のモデルに対して、第 $l$ 層の学習率を
$$ \eta^{(l)} = \eta_{\text{base}} \cdot \xi^{L-l} $$
と設定します。ここで $\xi \in (0, 1)$ は減衰係数(典型的には $\xi = 0.95$)、$\eta_{\text{base}}$ は最上位層の学習率です。
これにより
- 上位層 ($l = L$): $\eta^{(L)} = \eta_{\text{base}}$(大きな学習率で積極的に更新)
- 下位層 ($l = 1$): $\eta^{(1)} = \eta_{\text{base}} \cdot \xi^{L-1}$(小さな学習率で慎重に更新)
具体例として BERT-Base($L = 12$)で $\eta_{\text{base}} = 2 \times 10^{-5}$, $\xi = 0.95$ の場合
$$ \begin{align} \eta^{(12)} &= 2 \times 10^{-5} \\ \eta^{(6)} &= 2 \times 10^{-5} \times 0.95^6 \approx 1.47 \times 10^{-5} \\ \eta^{(1)} &= 2 \times 10^{-5} \times 0.95^{11} \approx 1.14 \times 10^{-5} \end{align} $$
Warmup + Linear Decay スケジューリング
ファインチューニングでは、学習の初期段階で大きな学習率を使うと、事前学習で獲得した表現が壊れる(catastrophic forgetting)リスクがあります。Warmup は学習率を徐々に増加させることでこのリスクを軽減します。
全ステップ数を $T_{\text{total}}$、ウォームアップステップ数を $T_{\text{warmup}}$ とすると
$$ \eta_t = \begin{cases} \eta_{\max} \cdot \frac{t}{T_{\text{warmup}}} & \text{if } t \leq T_{\text{warmup}} \quad (\text{Warmup}) \\ \eta_{\max} \cdot \frac{T_{\text{total}} – t}{T_{\text{total}} – T_{\text{warmup}}} & \text{if } t > T_{\text{warmup}} \quad (\text{Linear Decay}) \end{cases} $$
典型的には $T_{\text{warmup}}$ は全ステップの 6–10% 程度に設定されます。
Warmup の直感的理解
- 初期段階: Adam オプティマイザの移動平均($\bm{m}_t$, $\bm{v}_t$)がまだ不正確なため、大きな学習率は危険
- Warmup 終了後: 移動平均が安定し、より大きな学習率で効率的に学習
- 後半: 収束に向けて学習率を下げ、微調整
過学習防止策
ファインチューニングでは、ターゲットタスクのデータが少量であることが多いため、過学習のリスクが高くなります。
1. ドロップアウト
各層の出力に対してランダムにユニットをゼロにします。
$$ \tilde{\bm{h}} = \bm{h} \odot \bm{m}, \quad m_i \sim \text{Bernoulli}(1 – p) $$
ここで $p$ はドロップアウト率(BERT では $p = 0.1$)です。推論時は全ユニットを使用し、出力を $(1 – p)$ でスケーリングします。
数学的には、ドロップアウトはアンサンブル学習の近似として解釈できます。学習時に $2^d$ 個の異なるサブネットワーク($d$ はユニット数)を暗黙的に学習し、推論時にそれらの平均を取っています。
2. 重み減衰(Weight Decay)
AdamW オプティマイザでは、L2 正則化をオプティマイザの更新式に直接組み込みます。
$$ \bm{\theta}_{t+1} = (1 – \lambda \eta_t) \bm{\theta}_t – \eta_t \frac{\hat{\bm{m}}_t}{\sqrt{\hat{\bm{v}}_t} + \epsilon} $$
ここで $\lambda$ は重み減衰係数(典型的には $\lambda = 0.01$)です。
Adam + L2 正則化(標準的な L2)と AdamW の違いは重要です。
Adam + L2: $$ \bm{g}_t’ = \bm{g}_t + \lambda \bm{\theta}_t \quad \text{(勾配に正則化項を加える)} $$ $$ \bm{\theta}_{t+1} = \bm{\theta}_t – \eta_t \frac{\hat{\bm{m}}_t’}{\sqrt{\hat{\bm{v}}_t’} + \epsilon} $$
この場合、正則化の勾配も Adam の適応的学習率で処理されてしまい、意図した効果が得られません。
AdamW: $$ \bm{\theta}_{t+1} = \bm{\theta}_t – \eta_t \frac{\hat{\bm{m}}_t}{\sqrt{\hat{\bm{v}}_t} + \epsilon} – \eta_t \lambda \bm{\theta}_t $$
重み減衰を勾配計算とは独立に適用するため、正しく機能します。
3. 早期停止
検証セットの損失を監視し、改善が見られなくなったら学習を停止します。
$$ \text{if } \mathcal{L}_{\text{val}}^{(e)} > \mathcal{L}_{\text{val}}^{(e^*)} \text{ for } P \text{ consecutive epochs, stop.} $$
$P$ は patience(忍耐)パラメータで、典型的には 3–5 に設定されます。
ULMFiT の3段階戦略
ULMFiT(Universal Language Model Fine-tuning)は、Howard & Ruder (2018) が提案した転移学習の手法で、ファインチューニングの具体的な戦略を体系化しました。
Stage 1: 一般ドメインでの言語モデル事前学習
大規模な一般コーパス(Wikipedia など)で言語モデルを学習します。
$$ \bm{\theta}_{\text{general}}^* = \arg\min_{\bm{\theta}} \mathcal{L}_{\text{LM}}^{\text{general}}(\bm{\theta}) $$
Stage 2: ターゲットドメインでの言語モデルファインチューニング
ターゲットタスクのドメインの ラベルなし データで言語モデルをさらにファインチューニングします。
$$ \bm{\theta}_{\text{target}}^* = \arg\min_{\bm{\theta}} \mathcal{L}_{\text{LM}}^{\text{target}}(\bm{\theta}), \quad \bm{\theta}_{\text{init}} = \bm{\theta}_{\text{general}}^* $$
この段階では、ターゲットドメイン固有の語彙や表現に適応します。ラベルが不要なため、大量のデータを利用できます。
Stage 3: ターゲットタスクの分類器ファインチューニング
ラベル付きデータで分類ヘッドを含むモデル全体をファインチューニングします。
$$ (\bm{\theta}^*, \bm{w}^*) = \arg\min_{\bm{\theta}, \bm{w}} \mathcal{L}_{\text{task}}(\bm{\theta}, \bm{w}), \quad \bm{\theta}_{\text{init}} = \bm{\theta}_{\text{target}}^* $$
ULMFiT の重要なテクニック
1. Gradual Unfreezing(段階的凍結解除)
最初は最上位層のみを学習し、1エポックごとに1層ずつ下位層を解凍していきます。
$$ \text{Epoch 1}: \quad \text{unfreeze layer } L \\ $$ $$ \text{Epoch 2}: \quad \text{unfreeze layers } L, L-1 \\ $$ $$ \text{Epoch 3}: \quad \text{unfreeze layers } L, L-1, L-2 \\ $$ $$ \vdots $$
2. Slanted Triangular Learning Rates(STLR)
学習率を三角形状にスケジューリングします。短い warmup の後、線形に減衰させます。
$$ \eta_t = \begin{cases} \eta_{\max} \cdot \frac{t}{T_{\text{warmup}}} & \text{if } t \leq T_{\text{warmup}} \\ \eta_{\max} \cdot \frac{T_{\text{total}} – t}{T_{\text{total}} – T_{\text{warmup}}} \cdot \frac{1}{\text{ratio}} & \text{otherwise} \end{cases} $$
ここで ratio は最大学習率と最小学習率の比です。
LoRA(Low-Rank Adaptation)の理論
動機
大規模言語モデル(数十億〜数千億パラメータ)を全てファインチューニングすることは、計算資源とメモリの観点から非現実的です。LoRA(Hu et al., 2021)は、事前学習済みの重み行列を固定し、低ランクの更新行列のみを学習する効率的な手法です。
数学的定式化
事前学習済みの重み行列 $\bm{W}_0 \in \mathbb{R}^{d \times k}$ に対して、ファインチューニング後の重み行列を
$$ \begin{equation} \bm{W} = \bm{W}_0 + \Delta\bm{W} = \bm{W}_0 + \bm{B}\bm{A} \end{equation} $$
と表します。ここで
- $\bm{B} \in \mathbb{R}^{d \times r}$: 低ランク行列(ゼロで初期化)
- $\bm{A} \in \mathbb{R}^{r \times k}$: 低ランク行列(ランダム初期化)
- $r \ll \min(d, k)$: ランク(典型的には $r = 4, 8, 16$)
パラメータ数の削減
通常のファインチューニングで更新するパラメータ数は $d \times k$ です。一方、LoRA で更新するパラメータ数は
$$ |\bm{B}| + |\bm{A}| = d \times r + r \times k = r(d + k) $$
削減率は
$$ \frac{r(d + k)}{dk} = \frac{r}{d} + \frac{r}{k} $$
例えば $d = k = 768$, $r = 8$ の場合
$$ \frac{8 \times (768 + 768)}{768 \times 768} = \frac{12{,}288}{589{,}824} \approx 2.1\% $$
つまり、約 2% のパラメータで全体のファインチューニングに匹敵する性能を達成できます。
LoRA の順伝播
入力 $\bm{x} \in \mathbb{R}^k$ に対する順伝播は
$$ \bm{h} = \bm{W}\bm{x} = \bm{W}_0 \bm{x} + \bm{B}\bm{A}\bm{x} $$
$\bm{W}_0 \bm{x}$ は事前学習済みモデルの出力そのものであり、$\bm{B}\bm{A}\bm{x}$ が LoRA による追加の出力です。
実際の実装では、スケーリング係数 $\alpha / r$ を導入します。
$$ \bm{h} = \bm{W}_0 \bm{x} + \frac{\alpha}{r} \bm{B}\bm{A}\bm{x} $$
$\alpha$ はハイパーパラメータで、$r$ を変えても出力のスケールが安定するようにします。
なぜ低ランクで十分なのか
Aghajanyan et al. (2020) は、事前学習済みモデルのファインチューニングにおける重みの変化 $\Delta\bm{W}$ が「内在的な次元」(intrinsic dimensionality)が非常に低いことを実験的に示しました。つまり、$\Delta\bm{W}$ は高々数百〜数千次元の部分空間で十分に表現できるということです。
これは以下のように理解できます。事前学習済みモデルは既に良い表現を獲得しており、ファインチューニングではその表現を少しだけ調整すればよいため、更新に必要な自由度は少ないのです。
LoRA の適用箇所
LoRA は主に Self-Attention の Q, K, V 射影行列に適用されます。
$$ \bm{Q} = (\bm{W}_0^Q + \bm{B}^Q \bm{A}^Q) \bm{x} $$ $$ \bm{K} = (\bm{W}_0^K + \bm{B}^K \bm{A}^K) \bm{x} $$ $$ \bm{V} = (\bm{W}_0^V + \bm{B}^V \bm{A}^V) \bm{x} $$
FFN の重み行列にも適用可能ですが、元の論文では Attention の Q と V への適用で十分な性能が得られることが報告されています。
LoRA の利点
- メモリ効率: 事前学習済みモデルの重みは固定されているため、勾配を保持する必要がない
- 推論時のオーバーヘッドなし: 学習後に $\bm{W} = \bm{W}_0 + \bm{B}\bm{A}$ とマージすれば、推論時の計算量は通常のモデルと同じ
- タスク切り替えの容易さ: 異なるタスクの LoRA アダプタ $(\bm{B}, \bm{A})$ を差し替えるだけで、同一の事前学習モデルを複数タスクに使い回せる
Python で BERT ファインチューニング(テキスト分類)
データの準備
import numpy as np
import matplotlib.pyplot as plt
import torch
from torch.utils.data import DataLoader, Dataset
from transformers import BertTokenizer, BertForSequenceClassification
from transformers import get_linear_schedule_with_warmup
# デバイスの設定
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用デバイス: {device}")
# 簡易データセット(感情分析)
texts_train = [
"This movie is absolutely wonderful and amazing.",
"I really enjoyed this film, it was great.",
"The acting was superb and the story was compelling.",
"What a fantastic and beautiful movie!",
"I loved every moment of this brilliant film.",
"This is a terrible and boring movie.",
"I hated this film, it was awful.",
"The worst movie I have ever seen.",
"Absolutely dreadful, a complete waste of time.",
"This film is disappointing and poorly made.",
"An outstanding piece of cinema.",
"Truly inspiring and moving story.",
"A horrible experience, do not watch.",
"Painfully bad acting and terrible script.",
"Brilliant direction and wonderful performances.",
"Completely unwatchable garbage.",
]
labels_train = [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0]
texts_val = [
"A masterpiece of modern cinema.",
"One of the worst films this year.",
"Excellent performances by the entire cast.",
"Boring and predictable plot.",
]
labels_val = [1, 0, 1, 0]
# カスタムデータセット
class TextDataset(Dataset):
def __init__(self, texts, labels, tokenizer, max_length=128):
self.encodings = tokenizer(texts, truncation=True, padding=True,
max_length=max_length, return_tensors="pt")
self.labels = torch.tensor(labels, dtype=torch.long)
def __len__(self):
return len(self.labels)
def __getitem__(self, idx):
item = {key: val[idx] for key, val in self.encodings.items()}
item['labels'] = self.labels[idx]
return item
# トークナイザとモデルの準備
model_name = "bert-base-uncased"
tokenizer = BertTokenizer.from_pretrained(model_name)
train_dataset = TextDataset(texts_train, labels_train, tokenizer)
val_dataset = TextDataset(texts_val, labels_val, tokenizer)
train_loader = DataLoader(train_dataset, batch_size=4, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=4)
print(f"訓練データ: {len(train_dataset)} 件")
print(f"検証データ: {len(val_dataset)} 件")
ファインチューニングの実行
import torch
import numpy as np
import matplotlib.pyplot as plt
from transformers import BertForSequenceClassification
from transformers import get_linear_schedule_with_warmup
# モデルの準備(2クラス分類)
model = BertForSequenceClassification.from_pretrained(
"bert-base-uncased",
num_labels=2
)
model.to(device)
# オプティマイザ(AdamW + 層別学習率)
# BERT の各レイヤーに異なる学習率を設定
lr_base = 2e-5
lr_decay = 0.95
optimizer_grouped_parameters = []
# 埋め込み層(最低学習率)
embedding_params = list(model.bert.embeddings.parameters())
optimizer_grouped_parameters.append({
'params': embedding_params,
'lr': lr_base * (lr_decay ** 12)
})
# 各 Transformer レイヤー
for i, layer in enumerate(model.bert.encoder.layer):
layer_lr = lr_base * (lr_decay ** (12 - i - 1))
optimizer_grouped_parameters.append({
'params': list(layer.parameters()),
'lr': layer_lr
})
# 分類ヘッド(最高学習率)
optimizer_grouped_parameters.append({
'params': list(model.classifier.parameters()),
'lr': lr_base * 10 # 分類ヘッドにはより大きな学習率
})
optimizer = torch.optim.AdamW(optimizer_grouped_parameters, weight_decay=0.01)
# スケジューラ(Warmup + Linear Decay)
num_epochs = 5
total_steps = len(train_loader) * num_epochs
warmup_steps = int(total_steps * 0.1) # 10% ウォームアップ
scheduler = get_linear_schedule_with_warmup(
optimizer,
num_warmup_steps=warmup_steps,
num_training_steps=total_steps
)
# 学習ループ
train_losses = []
val_losses = []
val_accuracies = []
learning_rates = []
for epoch in range(num_epochs):
# 訓練フェーズ
model.train()
epoch_loss = 0
for batch in train_loader:
batch = {k: v.to(device) for k, v in batch.items()}
outputs = model(**batch)
loss = outputs.loss
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
scheduler.step()
epoch_loss += loss.item()
learning_rates.append(optimizer.param_groups[-1]['lr'])
avg_train_loss = epoch_loss / len(train_loader)
train_losses.append(avg_train_loss)
# 検証フェーズ
model.eval()
val_loss = 0
correct = 0
total = 0
with torch.no_grad():
for batch in val_loader:
batch = {k: v.to(device) for k, v in batch.items()}
outputs = model(**batch)
val_loss += outputs.loss.item()
preds = torch.argmax(outputs.logits, dim=-1)
correct += (preds == batch['labels']).sum().item()
total += len(batch['labels'])
avg_val_loss = val_loss / len(val_loader)
val_losses.append(avg_val_loss)
val_acc = correct / total
val_accuracies.append(val_acc)
print(f"Epoch {epoch+1}/{num_epochs} | "
f"Train Loss: {avg_train_loss:.4f} | "
f"Val Loss: {avg_val_loss:.4f} | "
f"Val Acc: {val_acc:.4f}")
# 学習曲線の可視化
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
# 損失
axes[0].plot(train_losses, label='Train', marker='o')
axes[0].plot(val_losses, label='Validation', marker='s')
axes[0].set_xlabel('Epoch')
axes[0].set_ylabel('Loss')
axes[0].set_title('Training and Validation Loss')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# 精度
axes[1].plot(val_accuracies, label='Validation', marker='s', color='green')
axes[1].set_xlabel('Epoch')
axes[1].set_ylabel('Accuracy')
axes[1].set_title('Validation Accuracy')
axes[1].legend()
axes[1].grid(True, alpha=0.3)
# 学習率
axes[2].plot(learning_rates, color='orange')
axes[2].set_xlabel('Step')
axes[2].set_ylabel('Learning Rate')
axes[2].set_title('Learning Rate Schedule (Warmup + Linear Decay)')
axes[2].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
推論と予測
import torch
import numpy as np
def predict_sentiment(text, model, tokenizer, device):
"""テキストの感情を予測する"""
model.eval()
inputs = tokenizer(text, return_tensors="pt", truncation=True,
padding=True, max_length=128)
inputs = {k: v.to(device) for k, v in inputs.items()}
with torch.no_grad():
outputs = model(**inputs)
probs = torch.softmax(outputs.logits, dim=-1)
pred = torch.argmax(probs, dim=-1).item()
labels = ["Negative", "Positive"]
return labels[pred], probs[0].cpu().numpy()
# テスト
test_texts = [
"This is an amazing and wonderful movie!",
"I really disliked this terrible film.",
"The plot was interesting but the acting was mediocre.",
"A truly beautiful and heartwarming story.",
]
print("=== 感情分析の推論結果 ===")
for text in test_texts:
label, probs = predict_sentiment(text, model, tokenizer, device)
print(f"テキスト: {text}")
print(f" 予測: {label} (Negative: {probs[0]:.4f}, Positive: {probs[1]:.4f})")
print()
LoRA の概念的な実装
import numpy as np
import matplotlib.pyplot as plt
class LoRALinear:
"""LoRA を適用した線形層の概念的な実装"""
def __init__(self, d_in, d_out, rank=4, alpha=1.0):
self.d_in = d_in
self.d_out = d_out
self.rank = rank
self.alpha = alpha
self.scaling = alpha / rank
# 事前学習済みの重み(固定)
self.W0 = np.random.randn(d_out, d_in) * 0.02
# LoRA パラメータ(学習対象)
self.A = np.random.randn(rank, d_in) * 0.01 # ランダム初期化
self.B = np.zeros((d_out, rank)) # ゼロ初期化
def forward(self, x):
"""順伝播: h = W0 @ x + (alpha/r) * B @ A @ x"""
h_pretrained = self.W0 @ x
h_lora = self.scaling * (self.B @ (self.A @ x))
return h_pretrained + h_lora
def merged_weight(self):
"""推論時にマージした重み"""
return self.W0 + self.scaling * (self.B @ self.A)
def num_trainable_params(self):
"""学習可能パラメータ数"""
return self.d_out * self.rank + self.rank * self.d_in
def num_total_params(self):
"""全パラメータ数"""
return self.d_out * self.d_in
# パラメータ数の比較
d_model = 768
ranks = [1, 2, 4, 8, 16, 32, 64]
total_params = d_model * d_model
reduction_ratios = []
for r in ranks:
lora = LoRALinear(d_model, d_model, rank=r)
trainable = lora.num_trainable_params()
ratio = trainable / total_params * 100
reduction_ratios.append(ratio)
print(f"Rank={r:2d}: 学習パラメータ {trainable:>8,} / {total_params:>8,} "
f"({ratio:.2f}%)")
# 可視化
plt.figure(figsize=(8, 5))
plt.bar(range(len(ranks)), reduction_ratios, color='steelblue', alpha=0.8)
plt.xticks(range(len(ranks)), [f"r={r}" for r in ranks])
plt.xlabel("Rank")
plt.ylabel("Trainable Parameters (%)")
plt.title(f"LoRA: Parameter Reduction (d_model={d_model})")
plt.grid(True, alpha=0.3, axis='y')
# パーセント表示
for i, (r, ratio) in enumerate(zip(ranks, reduction_ratios)):
plt.text(i, ratio + 0.2, f"{ratio:.1f}%", ha='center', fontsize=9)
plt.tight_layout()
plt.show()
ファインチューニング手法の比較
| 手法 | 学習パラメータ | メモリ効率 | 性能 | 用途 |
|---|---|---|---|---|
| Full Fine-tuning | 全パラメータ | 低 | 最高 | リソースが十分な場合 |
| Feature Extraction | 分類ヘッドのみ | 最高 | 低〜中 | 超少量データ |
| Gradual Unfreezing | 段階的に全体 | 中 | 高 | 中量データ |
| LoRA | $r(d+k)$ | 高 | 全FTに匹敵 | 大規模モデル |
| Prefix Tuning | プレフィックスのみ | 高 | 中〜高 | 生成タスク |
| Adapter | アダプタ層のみ | 高 | 中〜高 | 多タスク |
まとめ
本記事では、ファインチューニングと転移学習の理論について解説しました。
- 転移学習の理論: ドメイン適応の誤差上界 $\epsilon_T \leq \epsilon_S + d_{\mathcal{H}\Delta\mathcal{H}} / 2 + \lambda^*$ を通じて、転移学習が成功する条件を理論的に理解しました
- 特徴抽出 vs ファインチューニング: 事前学習済みモデルの重みを固定する特徴抽出と、全体を更新するファインチューニングのトレードオフを整理しました
- 学習率戦略: 層別学習率 $\eta^{(l)} = \eta_{\text{base}} \cdot \xi^{L-l}$ と Warmup + Linear Decay のスケジューリングにより、事前学習の知識を保持しながら効率的に学習する方法を定式化しました
- 過学習防止: ドロップアウト、AdamW による重み減衰、早期停止の各手法を数学的に説明しました
- ULMFiT: 一般→ドメイン→タスクの3段階戦略と Gradual Unfreezing の手法を理解しました
- LoRA: $\bm{W} = \bm{W}_0 + \bm{B}\bm{A}$ の低ランク分解により、パラメータの約2%の更新で全体ファインチューニングに匹敵する性能を達成できる理論を導出しました
- 実装: huggingface transformers を用いた BERT のファインチューニング(層別学習率、Warmup + Linear Decay、AdamW)を実装しました
次のステップとして、以下の記事も参考にしてください。