Hayami Kaito Web
~/tech

転移学習とファインチューニングのしくみ:少ないデータで学習済みモデルを使い回す

published

画像検査や文書の分類を AI で作ろうとすると、まずぶつかるのが「正解ラベル付きのデータが少ない」という問題です。数百枚の画像から、ニューラルネットワークをゼロから学習させても、なかなか良い成績は出ません。

そこで使われるのが 転移学習 です。大量のデータがある別の問題(たとえば 1000 種類の物体の分類)で学習したモデルを持ってきて、その中身を新しい問題に使い回します。製造業の画像検査 AI の使い方と作り方 で出てきた「学習済みのモデルの特徴を使う」方法も、LLM を自分の用途に合わせる「ファインチューニング」も、転移学習の一種です。

この記事では、小さなニューラルネットワークで「元の問題」と「新しい問題」を作り、次のことを確かめます。

  • 転移学習が データの少ないとき にどれだけ効くか
  • 最後の層だけを学習する 方法と、全体を微調整する 方法をどう使い分けるか
  • 微調整の 学習率 を大きくすると何が起きるか

結論を先に書くと、次のようになります。

状況向いている方法理由
新しい問題のデータが少なく、元の問題と近い最後の層だけを学習する学習済みの特徴がそのまま使える。調整する重みが少ないので過学習しにくい
新しい問題が元の問題と遠い全体を小さな学習率で微調整する特徴そのものを新しい問題に合わせ直す必要がある
新しい問題のデータが十分に多い全体の微調整、またはゼロからの学習事前学習の効き目は、データが増えるほど小さくなる

どの場合も、微調整の学習率は事前学習のときより小さくするのが基本です。大きくすると、学習済みの特徴が壊れ、元の問題もほとんど解けなくなります。

説明の途中に出てくる計算例は、Python と NumPy で実際に動かした結果です。

環境

計算例を動かした環境です。

項目バージョン
Python3.13
NumPy2.5

転移学習の考え方

ニューラルネットワークの前半は「特徴を作る部分」

画像分類 AI のしくみ で説明したように、画像を分類するニューラルネットワークの前の方の層は、縁・模様・部品のような 特徴 を作り、最後の層がその特徴から「どのクラスか」を決めます。前の方の層が作る特徴は、猫と犬を見分けるときにも、傷のある製品と良品を見分けるときにも役に立つ、汎用的なものです。

転移学習では、ネットワークを 2 つの部分に分けて考えます。

部分役割転移学習での扱い
体(バックボーン)入力から特徴を作る元の問題で学習したものを持ってくる
頭(ヘッド)特徴から答えを出す新しい問題に合わせて作り直す(クラスの数が違うため)

3 つのやり方

やり方学習する重み呼び方
ゼロから学習する全部(乱数から始める)—
体を固定して、頭だけを学習する頭だけ線形プローブ、特徴抽出
体も頭も学習する全部(体は学習済みの値から始める)ファインチューニング(微調整)

実験の準備

元の問題と新しい問題を作る

本物の画像を使うかわりに、「共通の特徴」から 2 つの問題を作ります。30 次元の入力から、2 段の非線形の変換で 8 個の「本当の特徴」を作り、元の問題(10 クラス)と新しい問題(2 クラス)の答えを、その特徴の別々の線形な組み合わせで決めます。2 つの問題は、答えの決め方は違いますが、役に立つ特徴が共通している という関係です。画像の場合の「物体の分類」と「製品の検査」の関係を、小さくまねたものです。

モデルは、64 個のニューロンの隠れ層が 2 つあるネットワークです。最適化には Adam を使います。

import numpy as np

D, K = 30, 8                         # 入力の次元、本当の特徴の数
world = np.random.default_rng(42)
A1 = world.normal(0, 1, (D, 32)) / np.sqrt(D)
A2 = world.normal(0, 1, (32, K)) / np.sqrt(32)
W_src = world.normal(0, 1, (K, 10))  # 元の問題:10 クラス
w_tgt = world.normal(0, 1, (K, 2))   # 新しい問題:2 クラス

def true_features(X):
    return np.tanh(np.tanh(X @ A1 * 2) @ A2 * 3)

def make(rng, n, W):
    X = rng.normal(size=(n, D))
    logits = true_features(X) @ W * 3
    return X, logits.argmax(1)

def init(rng, n_out, H=64):
    return {"W1": rng.normal(0, 1/np.sqrt(D), (D, H)), "b1": np.zeros(H),
            "W2": rng.normal(0, 1/np.sqrt(H), (H, H)), "b2": np.zeros(H),
            "W3": rng.normal(0, 1/np.sqrt(H), (H, n_out)), "b3": np.zeros(n_out)}

def forward(p, X):
    h1 = np.maximum(0, X @ p["W1"] + p["b1"])
    h2 = np.maximum(0, h1 @ p["W2"] + p["b2"])
    return h1, h2, h2 @ p["W3"] + p["b3"]

def grads(p, X, y):
    h1, h2, z = forward(p, X)
    z = z - z.max(1, keepdims=True)
    P = np.exp(z); P /= P.sum(1, keepdims=True)
    dz = P; dz[np.arange(len(y)), y] -= 1; dz /= len(y)
    g = {"W3": h2.T @ dz, "b3": dz.sum(0)}
    dh2 = (dz @ p["W3"].T) * (h2 > 0)
    g["W2"], g["b2"] = h1.T @ dh2, dh2.sum(0)
    dh1 = (dh2 @ p["W2"].T) * (h1 > 0)
    g["W1"], g["b1"] = X.T @ dh1, dh1.sum(0)
    return g

train は、trainable に指定した重みだけを更新します。体を固定するときは、頭の重み(W3・b3)だけを渡します。

def train(p, X, y, steps, lr, trainable, rng, batch=64):
    m = {k: np.zeros_like(v) for k, v in p.items()}; v = {k: np.zeros_like(v) for k, v in p.items()}
    for t in range(1, steps + 1):
        idx = rng.choice(len(y), min(batch, len(y)), replace=False)
        g = grads(p, X[idx], y[idx])
        for k in trainable:
            m[k] = 0.9 * m[k] + 0.1 * g[k]; v[k] = 0.999 * v[k] + 0.001 * g[k]**2
            p[k] -= lr * (m[k] / (1 - 0.9**t)) / (np.sqrt(v[k] / (1 - 0.999**t)) + 1e-8)
    return p

def acc(p, X, y):
    return np.mean(forward(p, X)[2].argmax(1) == y)

ALL = ["W1", "b1", "W2", "b2", "W3", "b3"]
HEAD = ["W3", "b3"]

元の問題で事前学習する

元の問題は、データが 20000 件あるとします。

rng = np.random.default_rng(0)
Xs, ys = make(rng, 20000, W_src)
Xs_test, ys_test = make(rng, 5000, W_src)
pre = train(init(rng, 10), Xs, ys, 6000, 1e-3, ALL, rng, batch=128)
print("事前学習:元の問題の正解率", round(acc(pre, Xs_test, ys_test), 3))

実行結果:

事前学習:元の問題の正解率 0.655

10 クラスで当て推量なら 0.1 なので、本当の特徴をある程度つかめています。この pre が「学習済みのモデル」です。

データの量で比べる

3 つのやり方を比べる

新しい問題のデータを 20・100・500・5000 件にして、3 つのやり方を比べます。転移学習のときは、頭を 2 クラス用に作り直します。全体の微調整の学習率は、事前学習(1e-3)の 10 分の 1 の 1e-4 にしています。乱数の種を変えて 5 回ずつ学習させ、5000 件の評価用のデータでの正解率を平均します。

import copy

def new_head(p, rng, n_out=2):
    q = copy.deepcopy(p)
    q["W3"] = rng.normal(0, 1/np.sqrt(64), (64, n_out)); q["b3"] = np.zeros(n_out)
    return q

Xt_test, yt_test = make(np.random.default_rng(99), 5000, w_tgt)
print("新しい問題の多数派の割合", round(max(yt_test.mean(), 1 - yt_test.mean()), 3))
for n in (20, 100, 500, 5000):
    res = {"ゼロから": [], "最後の層だけ": [], "全体を微調整": []}
    for seed in range(5):
        rng = np.random.default_rng(seed)
        Xt, yt = make(rng, n, w_tgt)
        res["ゼロから"].append(acc(train(init(rng, 2), Xt, yt, 1000, 1e-3, ALL, rng), Xt_test, yt_test))
        res["最後の層だけ"].append(acc(train(new_head(pre, rng), Xt, yt, 1000, 1e-3, HEAD, rng), Xt_test, yt_test))
        res["全体を微調整"].append(acc(train(new_head(pre, rng), Xt, yt, 1000, 1e-4, ALL, rng), Xt_test, yt_test))
    print(n, {k: round(float(np.mean(v)), 3) for k, v in res.items()})

実行結果:

新しい問題の多数派の割合 0.504
20 {'ゼロから': 0.626, '最後の層だけ': 0.744, '全体を微調整': 0.683}
100 {'ゼロから': 0.729, '最後の層だけ': 0.789, '全体を微調整': 0.772}
500 {'ゼロから': 0.773, '最後の層だけ': 0.819, '全体を微調整': 0.811}
5000 {'ゼロから': 0.809, '最後の層だけ': 0.832, '全体を微調整': 0.823}

結果の読み方

  • データが少ないほど、転移学習の効き目が大きい。20 件では、ゼロからの 0.626 に対して、最後の層だけの学習で 0.744 になりました。ゼロから学習したモデルは、500 件でようやく 0.773 で、転移学習なら 100 件で同じくらいの成績が出ています
  • データが増えると差は縮む。5000 件では、ゼロからでも 0.809 まで上がり、差は 0.02 ほどです
  • この問題では、最後の層だけの学習がいちばん良い。新しい問題は、元の問題と同じ特徴の別の組み合わせで答えが決まるので、学習済みの特徴をそのまま使うのがいちばん効率的です。全体を微調整すると、学習する重みが多いぶん、少ないデータでは学習用のデータに合わせすぎやすくなります(過学習と正則化のしくみ)

問題の近さで比べる

遠い問題を作る

次に、元の問題と 遠い 新しい問題を作ります。1 段目の変換は共通ですが、2 段目の変換を別のものに変えて、答えに関係する特徴そのものを変えます。画像でいえば、縁や模様のような低いレベルの特徴は共通でも、どの部品の組み合わせが大事かは違う、という状況です。

# 遠い問題:1 層目の特徴は共通だが、2 層目は元の問題と別のもの
A2_far = np.random.default_rng(7).normal(0, 1, (32, K)) / np.sqrt(32)
def make_far(rng, n):
    X = rng.normal(size=(n, D))
    z = np.tanh(np.tanh(X @ A1 * 2) @ A2_far * 3)
    return X, (z @ w_tgt * 3).argmax(1)

Xf_test, yf_test = make_far(np.random.default_rng(99), 5000)
for n in (100, 500, 5000):
    res = {"ゼロから": [], "最後の層だけ": [], "全体を微調整": []}
    for seed in range(5):
        rng = np.random.default_rng(seed)
        Xt, yt = make_far(rng, n)
        res["ゼロから"].append(acc(train(init(rng, 2), Xt, yt, 1000, 1e-3, ALL, rng), Xf_test, yf_test))
        res["最後の層だけ"].append(acc(train(new_head(pre, rng), Xt, yt, 1000, 1e-3, HEAD, rng), Xf_test, yf_test))
        res["全体を微調整"].append(acc(train(new_head(pre, rng), Xt, yt, 1000, 1e-4, ALL, rng), Xf_test, yf_test))
    print(n, {k: round(float(np.mean(v)), 3) for k, v in res.items()})

実行結果:

100 {'ゼロから': 0.691, '最後の層だけ': 0.688, '全体を微調整': 0.702}
500 {'ゼロから': 0.738, '最後の層だけ': 0.733, '全体を微調整': 0.748}
5000 {'ゼロから': 0.771, '最後の層だけ': 0.752, '全体を微調整': 0.771}

今度は結果が変わりました。

  • 最後の層だけの学習は、ゼロからの学習と同じくらいか、それより悪い。5000 件では 0.752 で、ゼロからの 0.771 に負けています。学習済みの特徴は元の問題のためのものなので、遠い問題の答えに必要な情報が十分に入っていません。体を固定すると、それ以上良くなれません
  • 全体の微調整は、データが少ないときにいちばん良い。特徴を新しい問題に合わせて直しつつ、1 段目の共通の部分は学習済みの値から始められるからです

問題が近ければ体を固定し、遠ければ体も動かす、というのが使い分けの基本です。実際の画像のモデルでは、その中間として「前の方の層は固定し、後ろの方の層だけを微調整する」こともよく行います。前の方の層ほど汎用的な特徴を作っているためです(Yosinski ら, 2014)。

微調整の学習率と「元の問題を忘れる」現象

学習率を変えて比べる

近い方の新しい問題(100 件)で、全体の微調整の学習率を変えて比べます。あわせて、微調整したあとの体に、事前学習のときの頭を付け直して、元の問題がまだ解けるか も測ります。体の特徴がどれだけ変わったかの目安です。

「最後の層 → 全体(LP-FT)」は、まず頭だけを学習し、そのあと全体を小さな学習率で微調整する方法です(Kumar ら, 2022)。

Xt_test, yt_test = make(np.random.default_rng(99), 5000, w_tgt)
Xs_test, ys_test = make(np.random.default_rng(98), 5000, W_src)

def source_acc(q):
    """体の部分はそのまま、最後の層だけ事前学習のときのものに戻して、元の問題を解かせる"""
    r = copy.deepcopy(q); r["W3"], r["b3"] = pre["W3"], pre["b3"]
    return acc(r, Xs_test, ys_test)

print("事前学習のままの元の問題の正解率", round(acc(pre, Xs_test, ys_test), 3))
n = 100
methods = {
    "最後の層だけ": lambda q, X, y, rng: train(q, X, y, 1000, 1e-3, HEAD, rng),
    "全体を微調整(学習率 1e-4)": lambda q, X, y, rng: train(q, X, y, 1000, 1e-4, ALL, rng),
    "全体を微調整(学習率 1e-2)": lambda q, X, y, rng: train(q, X, y, 1000, 1e-2, ALL, rng),
    "最後の層 → 全体(LP-FT)": lambda q, X, y, rng: train(train(q, X, y, 1000, 1e-3, HEAD, rng), X, y, 1000, 1e-4, ALL, rng),
}
for name, f in methods.items():
    tgt, src = [], []
    for seed in range(5):
        rng = np.random.default_rng(seed)
        Xt, yt = make(rng, n, w_tgt)
        q = f(new_head(pre, rng), Xt, yt, rng)
        tgt.append(acc(q, Xt_test, yt_test)); src.append(source_acc(q))
    print(f"{name}: 新しい問題 {np.mean(tgt):.3f}, 元の問題 {np.mean(src):.3f}")

実行結果:

事前学習のままの元の問題の正解率 0.655
最後の層だけ: 新しい問題 0.791, 元の問題 0.655
全体を微調整(学習率 1e-4): 新しい問題 0.772, 元の問題 0.603
全体を微調整(学習率 1e-2): 新しい問題 0.763, 元の問題 0.391
最後の層 → 全体(LP-FT): 新しい問題 0.786, 元の問題 0.632

1 つ前の実験の 100 件の結果(最後の層だけで 0.789)と少し違うのは、乱数の使われ方が違い、学習に使ったデータが別のものになっているためです。

結果の読み方

  • 学習率を大きくすると、元の問題がほとんど解けなくなる。1e-2 では、元の問題の正解率が 0.655 から 0.391 まで下がりました。新しい問題の 100 件に合わせて、体の特徴が大きく書き換わったためです。これを 破滅的忘却(catastrophic forgetting)と呼びます。新しい問題の成績も、学習率 1e-4 のときより下がっています
  • 最初に頭だけを学習しておくと、特徴が壊れにくい。頭を作り直した直後は、頭の重みが乱数なので、大きく間違った勾配が体にまで流れ込みます。LP-FT では、頭がある程度できてから体を動かすので、元の問題の正解率は 0.632 にとどまり、新しい問題の成績も全体の微調整より良くなりました

元の問題の成績を気にしない場合でも、特徴を壊さないことは、新しい問題の成績にとって大事です。学習済みの特徴は、新しい問題の少ないデータからは作れない情報を持っているからです。

実際に使うときのポイント

画像のモデル

ポイント内容
学習済みのモデルを選ぶImageNet などの大きなデータで学習した ResNet・Vision Transformer などを使う。自己教師あり学習のモデル(自己教師あり学習と CLIP のしくみ)は、さまざまな問題に転移しやすい
入力をそろえる画像の大きさ・色の正規化(平均と標準偏差)を、事前学習のときと同じにする
学習率体は小さく、頭は大きくする(層ごとに学習率を変える)。全体で同じにするなら、事前学習より 1 桁ほど小さくする
データ拡張データが少ないときは、反転・切り抜き・色の変化などで水増しする
正規化の層データが少ないときは、バッチ正規化の統計を固定することもある(正規化層のしくみ)

言語モデル

LLM を自分の用途に合わせるときも、考え方は同じです。

方法内容
プロンプトだけで使う重みは変えず、指示と例を入力に入れる。学習が要らない
全体の微調整すべての重みを更新する。大きなモデルではメモリーが足りなくなりやすい
LoRA などの省メモリーの微調整元の重みは固定し、小さな追加の行列だけを学習する(LLM を小さく速く動かすしくみ)。元の重みが変わらないので、忘却も起きにくい
指示チューニング・RLHF質問に答える形や好ましい答え方を学習する(LLM の学習のしくみ)

転移学習の手法の流れ

年手法・出来事ポイント
1976転移の研究の始まり(Bozinovski と Fulgosi)学習したことを別の問題に移す効果を、ニューラルネットワークで調べた
1989破滅的忘却の指摘(McCloskey と Cohen)新しい問題を学習すると、前の問題を急に忘れることを示した
2009ImageNet大規模な画像のデータセット。事前学習の元として広く使われるようになる
2014CNN の特徴の転移(Razavian ら、Yosinski ら)ImageNet で学習した CNN の特徴が、さまざまな画像の問題に効くことを示した
2017弾性重み固定(EWC)元の問題に大事な重みを動かしにくくして、忘却を減らす
2018ULMFiT・BERT言語モデルの事前学習と微調整の組み合わせが、自然言語処理の標準になる
2019アダプター小さな追加の層だけを学習する、省メモリーの微調整
2021LoRA重みの変化を低ランクの行列で表して学習する
2022LP-FT頭を先に学習してから全体を微調整し、特徴の崩れを減らす

まとめ

  • 転移学習は、元の問題で学習したモデルの体(特徴を作る部分)を、新しい問題に使い回す。データが少ないときほど効き目が大きい
  • 新しい問題が元の問題に近ければ、体を固定して頭だけを学習するのが効率的。遠ければ、全体を小さな学習率で微調整する
  • 微調整の学習率が大きいと、特徴が新しい少ないデータに合わせて書き換わり、元の問題を忘れる。新しい問題の成績も下がる
  • 頭を先に学習してから全体を微調整すると、頭の乱数のせいで特徴が壊れるのを防げる
  • LLM の微調整も同じ考え方で、LoRA のように元の重みを固定する方法がよく使われる

参考

  • S. Bozinovski and A. Fulgosi, "The influence of pattern similarity and transfer learning upon training of a base perceptron B2", Proceedings of Symposium Informatica 1976
  • M. McCloskey and N. J. Cohen, "Catastrophic Interference in Connectionist Networks: The Sequential Learning Problem", Psychology of Learning and Motivation 1989
  • J. Deng et al., "ImageNet: A Large-Scale Hierarchical Image Database", CVPR 2009
  • A. S. Razavian et al., "CNN Features off-the-shelf: an Astounding Baseline for Recognition", CVPR Workshops 2014(arXiv:1403.6382)
  • J. Yosinski et al., "How transferable are features in deep neural networks?", NIPS 2014(arXiv:1411.1792)
  • J. Kirkpatrick et al., "Overcoming catastrophic forgetting in neural networks", PNAS 2017(arXiv:1612.00796)
  • J. Howard and S. Ruder, "Universal Language Model Fine-tuning for Text Classification", ACL 2018(arXiv:1801.06146)
  • N. Houlsby et al., "Parameter-Efficient Transfer Learning for NLP", ICML 2019(arXiv:1902.00751)
  • E. J. Hu et al., "LoRA: Low-Rank Adaptation of Large Language Models", ICLR 2022(arXiv:2106.09685)
  • A. Kumar et al., "Fine-Tuning can Distort Pretrained Features and Underperform Out-of-Distribution", ICLR 2022(arXiv:2202.10054)