- AI ⚙️
LoRAの仕組み|numpyで自作して全パラメータ学習と忘却を実測
結論
LoRA(Low-Rank Adaptation)は、学習済みの重み W を凍結したまま、細長い2つの行列の積 BA だけを学習して W に足す方法です。numpy で自作した38万パラメータの言語モデルでは、学習するパラメータを1.6%に減らせ、マージ後の推論の速さは元のモデルと同じでした。一方で、同じ忘却の量で比べると、この小さな実験では全パラメータ学習の方が新しい分野をよく覚えました。
この記事でわかること
- LoRA の式(h = W0x + BAx)と、B を0で初期化する理由・α/r のスケールの意味を、自作のコードと実測で
- 全パラメータ学習と LoRA(ランク1〜32)の損失・忘却・1ステップの時間・アダプタの容量の比較
- マージ(重みへの足し込み)と取り外し、QLoRA の要点(原論文で確かめた範囲)
この記事で実際に動かしたもの:numpy だけで書いた文字単位の小さな Transformer(2層・幅96・381,888パラメータ)を、手書きの逆伝播で RJC の技術記事の本文から事前学習し、別の分野(AI・データ系の記事に載っているコードと実行結果)に合わせ直すときの全パラメータ学習と LoRA を、同じ条件で学習・計測しました。勾配はすべて数値微分と照合しています。
大きな言語モデルを自社の文書や特定の作業に合わせたいとき、全部の重みを学習し直すと、重みと同じ数の勾配と最適化の状態をメモリに置くことになり、学習後のモデルもまるごと1つ分の容量になります。LoRA は「重みそのものは動かさず、差分だけを小さな形で持つ」ことで、この重さを減らします。
flowchart TD
X[入力 x] --> W[事前学習の重み W0<br>凍結・d×k]
X --> A[A を学習<br>k次元→r次元]
A --> B[B を学習・初期値0<br>r次元→d次元]
B --> S[α/r 倍]
W --> P((足す))
S --> P
P --> H[出力 h]
よく調べられることの答えを先にまとめます。
| 知りたいこと | 答え | 詳しい節 |
|---|---|---|
| LoRA とは | 凍結した重み W0 に、低ランクの差分 BA を足して、その差分だけを学習するファインチューニングの方法 | LoRAとは |
| LoRA の仕組み(式) | h = W0x + (α/r)·BAx。B は d×r、A は r×k、r は d・k よりずっと小さい | LoRAの式と行列の形 |
| 低ランク適応で学習するパラメータ数 | 1つの行列あたり r×(d+k)。手元のモデルの q・v、r=8 で全体の1.6% | LoRAの式と行列の形 |
| B を0で初期化する理由 | 学習の最初で ΔW=0 になり、元のモデルと出力が完全に一致するから。A も0にすると勾配が0で学習が進まない | Bを0で初期化する理由 |
| LoRA ファインチューニングと全パラメータ学習の違い | 手元の実測では、LoRA は覚える量も忘れる量も小さい。同じ忘却で比べると全パラメータ学習が多く覚えた | 全パラメータ学習とLoRAの比較 |
| ランク r の決め方 | 手元では r=1〜32 で損失の差が0.04だけ。対象の層を増やす方が効いた。原論文も q・v なら r=1 で足りたと報告 | LoRAのランクrの決め方 |
| lora_alpha(α)の意味 | 差分に掛ける α/r の α。学習率に近い働きをする。原論文は α を最初に試した r と同じにして調整していない | alphaとα/rのスケール |
| 推論は遅くなるか | W = W0 + (α/r)BA に足し込めば、元のモデルと同じ計算量。手元でも時間は誤差の範囲だった | マージ後の推論速度 |
| QLoRA とは | 土台の重みを4bit(NF4)で凍結して持ち、その上で LoRA を学習する方法。65B を48GBの GPU 1枚で学習できたと原論文が報告 | 減るメモリ・減らないメモリとQLoRA |
確認環境:Ubuntu 24.04.5 LTS(Intel Xeon 2.10GHz・2コア・メモリ8GB、ほかの作業と同時に動いている共有のマシン)の上で、Python 3.13.18 と numpy 2.5.3(OpenBLAS 0.3.34、スレッド数1に固定)だけを使いました。PyTorch・PEFT などの深層学習ライブラリは使っていません。言語モデルはデコーダのみの Transformer(Pre-LN・学習する位置埋め込み・ReLU の MLP・バイアスなしの線形層)で、順伝播・逆伝播・Adam をすべて自分で書き、数値微分で勾配を確かめました。データは RJC の技術記事247本の本文を読み取り専用で使い、分野A(AI 以外の記事217本の日本語の文章、コードは除く)で事前学習し、分野B(AI・データ系の記事30本に載っているコードと実行結果)に合わせ直しました。学習は事前学習480ステップ(79.9秒)と、合わせ直し13通り(合計143.4秒)で、合計223.3秒です。学習済みの公開モデルの重みや LLM の API には接続していないため、文章の質については主張しません。測ったのは損失・パラメータ数・時間・容量です。LoRA の式と主張は Hu et al.(2021)、QLoRA は Dettmers et al.(2023)の原論文、PEFT の引数は Hugging Face の公式ドキュメントで確かめました。図は mermaid で描画を確かめています(確認日 2026年11月4日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「LoRAの仕組み|numpyで自作して全パラメータ学習と忘却を実測」
LoRAとは(凍結した重みに低ランクの差分を足す)
この節では、LoRA が何を学習し、何を学習しないのか、ほかの方法と比べてどこが違うのかが分かります。
LoRA は、事前学習した重み行列 W0 を凍結し、学習で生じる変化 ΔW を「ランク r の行列」に制限して、ΔW = BA の B と A だけを学習する方法です。Hu et al. の原論文(2021)は、Transformer の重み行列に LoRA を入れ、GPT-3 175B で Adam による全パラメータ学習と比べて、学習するパラメータを1万分の1、GPU のメモリを3分の1にできたと報告しています。
1重みは動かさない
W0 は凍結する。勾配も最適化の状態も持たない。複数の用途があっても、土台の重みは1つで共有できる。
2差分を細長い2枚で持つ
d×k の ΔW を、d×r の B と r×k の A の積で表す。r が小さいほど、学習するパラメータは少ない。
3終わったら足し込める
W = W0 + BA を計算して置き換えれば、推論の計算は元のモデルと同じ形になる。外すときは BA を引く。
原論文は、ほかの方法の弱点として、アダプタ層(層の間に小さな層を差し込む方法)は推論のときに追加の計算が入り遅延が増えること、プレフィックスチューニング(入力の前に学習する埋め込みを足す方法)は最適化が難しく、使える系列長が減ることを挙げています。LoRA は重みに足し込めるので、推論の遅延が増えないことを利点にしています。
なお、LoRA は「ファインチューニングの1つのやり方」です。社内の文書を答えに使うだけなら、検索して文脈に入れる RAG で済む場面も多く、どちらを選ぶかは関連記事「RAGとファインチューニングの違いと使い分け判断基準の早見表」で扱っています。この記事は、LoRA そのものの仕組みを自作して確かめることに絞ります。
LoRAの式と行列の形・学習するパラメータ数
この節では、LoRA の式の各記号と行列の形、ランクと対象の層から学習するパラメータ数を計算する方法が分かります。
原論文の式は、入力を列ベクトル x として次の形です。
h = W0 x + ΔW x = W0 x + B A x
W0 : d × k (凍結)
B : d × r (学習。初期値 0)
A : r × k (学習。初期値はランダムなガウス分布)
r : min(d, k) よりずっと小さい
さらに ΔW x を α / r 倍する(α は定数)
実装では入力を行ベクトル(バッチ×次元)で持つことが多いので、手元のコードでは y = xW0 + (α/r)·(xA)B と書き、A を(入力次元×r)、B を(r×出力次元)の形にしました。転置しているだけで、A がランダム・B が0という役割は原論文と同じです。
| 行列 | 手元のコードでの形 | 初期値 | 学習するか |
|---|---|---|---|
| W0 | (d_in, d_out) | 事前学習の値 | しない |
| A | (d_in, r) | 平均0・標準偏差 1/√d_in のガウス分布 | する |
| B | (r, d_out) | すべて0 | する |
| α/r | スカラー | α は定数(手元では8) | しない |
学習するパラメータ数は、1つの行列あたり r×(d_in + d_out) です。全パラメータ学習の d_in×d_out と比べると、正方行列(d×d)なら 2r/d の割合になります。手元のモデル(幅96・2層)と、幅4096・32層の大きなモデルで計算すると次のとおりです。
| 対象 | r | LoRA のパラメータ数 | モデル全体に対する割合 |
|---|---|---|---|
| 手元:各層の Wq・Wv(4行列) | 1 | 768 | 0.20%(381,888のうち) |
| 手元:各層の Wq・Wv(4行列) | 8 | 6,144 | 1.61% |
| 手元:各層の Wq・Wv(4行列) | 32 | 24,576 | 6.44% |
| 手元:各層の全線形層(12行列) | 8 | 27,648 | 7.24% |
| 試算:幅4096・32層の Wq・Wv(64行列) | 8 | 4,194,304 | 70億パラメータに対して約0.06% |
手元のモデルは語彙の埋め込みと出力層の割合が大きい(合わせて約15万パラメータ)ため、割合が大きめに出ます。大きなモデルほど、LoRA の割合は小さくなります。
numpyで小さな言語モデルとLoRAを自作する(実験の作り方)
この節では、実験に使ったモデル・データ・学習の条件と、LoRA を線形層に組み込むコードが分かります。
2つの分野の文章を用意する
RJC の技術記事247本の本文から HTML のタグを外し、分野A(AI 以外の217本の日本語の文章、コードを除く。学習用375万文字・評価用は別の21本で42万文字)と、分野B(AI・データ系30本に載っているコードと実行結果。学習用24本で17万文字・評価用は別の6本で3.5万文字)に分けた。
文字単位の語彙を作る
出現の多い799文字と「その他」の1つで、語彙は800。全文字の99.44%がこの語彙に入る。
分野Aで事前学習する
2層・幅96・4ヘッド・文脈64文字の Transformer を、バッチ16×64文字で480ステップ学習した。1回の計算を60秒以内に収めるため、3回に分けて続きから学習した。
分野Bに合わせ直す
同じ事前学習済みのモデルから、全パラメータ学習と LoRA を同じ120ステップ・同じ乱数のバッチで学習した。分野Bの学習データの約0.7周分にあたる。
両方の分野で損失を測る
評価用の記事から64文字の窓を192個(12,288文字)固定の乱数で取り、1文字あたりの交差エントロピー(自然対数)を出した。分野Bは下がるほど良く、分野Aが上がった分が「元の分野の忘却」になる。
最初は「AI の記事の文章」と「それ以外の記事の文章」の2分野で試しましたが、事前学習後の損失の差が0.10しかなく、合わせ直しても忘却がほとんど見えませんでした。文字単位の小さなモデルにとっては、どちらも同じ日本語の技術文書だからです。そこで分野Bを、同じ AI・データ系の記事に載っているコードと実行結果にしました。実際の LoRA でも、日本語の文書のモデルをコードに合わせる、のように分野の差が大きいほど、ここで測るような差がはっきり出ます。
LoRA を組み込むのは線形層の順伝播と逆伝播だけです。手元のコード(lm.py)の該当部分を抜き出します。
def lin(self, x, name, cache): # x: (N, d_in)
y = x @ self.p[name] # 凍結した W0
if name in self.lora and not self.merged:
A, B = self.lora[name]['A'], self.lora[name]['B']
xa = x @ A # (N, r)
y = y + self.scale[name] * (xa @ B) # scale = alpha / r
cache[name + '#xa'] = xa
cache[name + '#x'] = x
return y
def lin_backward(self, dy, name, cache, G, train_base):
x = cache[name + '#x']
dx = dy @ self.p[name].T
if train_base: # 全パラメータ学習のときだけ dW0 を計算する
G[name] = x.T @ dy
if name in self.lora and not self.merged:
A, B, s = self.lora[name]['A'], self.lora[name]['B'], self.scale[name]
dyB = dy @ B.T # (N, r)
G[name + '.B'] = s * cache[name + '#xa'].T @ dy
G[name + '.A'] = s * x.T @ dyB
dx = dx + s * dyB @ A.T
return dx
ポイントは2つです。1つ目は、凍結した W0 の勾配 x.T @ dy を計算しないこと。2つ目は、入力側への勾配 dx は LoRA でも全部計算すること。前の層の LoRA を学習するには、後ろの層から勾配を流す必要があるからです。この2点が、後で見る「1ステップの時間はあまり減らない」理由になります。
numpy の配列の扱いの基本は関連記事「ニューラルネットワークとは|仕組み・学習・AttentionをNumPyで実演」で扱っています。
逆伝播を自作して数値微分で勾配を確かめる
この節では、手書きの逆伝播が正しいことを、数値微分(中心差分)と照合して確かめる方法と結果が分かります。
逆伝播を手で書いたら、学習を回す前に、勾配を数値微分と比べます。パラメータを1つだけ ±ε(ε=10⁻⁶)動かして損失の差から傾きを出し、逆伝播の値との相対誤差 |a−n| / (|a|+|n|) を見ます。float32 では丸めの誤差が大きいので、小さなモデル(語彙17・幅8・2層・2ヘッド・文脈6)を float64 で作って調べました。LoRA の B は0のままだと A の勾配が0になって比べられないので、検査のときだけ B にも値を入れています。
$ OPENBLAS_NUM_THREADS=1 python3 gradcheck.py
parameter shape checked max rel err
E (17, 8) 12 1.06e-08
P (6, 8) 12 7.33e-08
0.Wq (8, 8) 12 1.40e-08
0.Wk (8, 8) 12 2.78e-08
0.Wv (8, 8) 12 8.03e-09
0.Wo (8, 8) 12 3.89e-09
0.W1 (8, 32) 12 2.12e-07
1.W2 (32, 8) 12 2.21e-08
1.ln2_g (8,) 8 8.08e-08
lnf_b (8,) 8 1.55e-09
Wout (8, 17) 12 5.22e-08
0.Wq.A (8, 3) 12 2.91e-08
0.Wq.B (3, 8) 12 5.19e-08
0.Wv.A (8, 3) 12 7.89e-09
0.Wv.B (3, 8) 12 6.24e-08
1.W1.A (8, 3) 12 1.97e-08
1.W1.B (3, 32) 12 7.01e-09
Wout.A (8, 3) 12 5.03e-08
Wout.B (3, 17) 12 4.39e-08
worst 2.12e-07
19種類のパラメータすべてで相対誤差は2.12×10⁻⁷以下でした。逆に、わざと間違えると数値ははっきり変わります。A の勾配で α/r(この検査では 6/3=2)を掛け忘れると、相対誤差は0.333になりました(|2−1|/(2+1) と同じ値)。
scale 2.0
analytic(correct) -1.6591123193446883e-05
numeric -1.659117287999834e-05
analytic(scale forgotten) -8.295561596723441e-06 rel err 0.3333346643383179
自作した LoRA の勾配の確かめ方
手元では最大の相対誤差2.12×10⁻⁷。スケールの掛け忘れは0.333で見つかる
Bを0で初期化する理由(初期化3通りの実測)
この節では、B を0・A をランダムにする理由と、ほかの初期化にしたときに何が起きるかが分かります。
B を0にすると、学習の最初で ΔW = BA = 0 になり、LoRA を入れたモデルの出力は元のモデルと完全に一致します。原論文も「A はランダムなガウス分布、B は0で初期化し、学習の最初で ΔW = BA は0」と書いています。PEFT の公式ドキュメントでも、既定(init_lora_weights=True)は B を0にし、A は Kaiming-uniform で初期化すると説明しています。
では、A も0にしたらどうなるか、両方ランダムにしたらどうなるかを、同じ事前学習済みモデル(q・v、r=8、α=8、学習率3×10⁻³)で30ステップずつ学習して比べました。
| 初期化 | 0ステップ目の損失(A/B) | 30ステップ後(A/B) | 最初の勾配 |
|---|---|---|---|
| A ランダム・B=0(原論文) | 3.4129/3.7351(元と同じ) | 3.5003/3.6117 | dA=0、dB≠0 |
| A・B とも0 | 3.4129/3.7351 | 3.4129/3.7351(まったく動かない) | dA=0、dB=0 |
| A・B ともランダム | 3.9867/4.2903(元より0.57悪い) | 3.7082/3.6624 | どちらも≠0 |
勾配の式を見ると理由が分かります。dB = s·(xA)ᵀ·dy なので、A がランダムなら B=0 でも dB は0になりません。一方 dA = s·xᵀ·(dy·Bᵀ) なので、B=0 の最初の1ステップでは dA=0 です。最初は B だけが動き、B が0でなくなってから A も動き出します。両方0だと dA も dB も0のままで、何ステップ学習しても変わりませんでした(小さなモデルで dA・dB の最大値が両方0.0)。
両方ランダムにすると、学習を始める前から事前学習の性能を壊してしまいます。手元では分野Aの損失が3.41から3.99に上がり、30ステップ後も原論文の初期化より分野Aが0.21悪いままでした。
全パラメータ学習とLoRAの比較(損失・忘却・学習時間)
この節では、同じ事前学習済みモデルを分野Bに合わせ直したとき、全パラメータ学習と LoRA で、覚える量・忘れる量・時間・容量がどう違うかが分かります。
全パラメータ学習は学習率を3通り、LoRA は q・v(r=8)と全線形層(r=8)を、すべて120ステップで学習しました。最適化は Adam(重み減衰なし)、LoRA の学習率は3×10⁻³、α=8 です。
| 方法 | 学習するパラメータ | 分野Bの損失(下がるほど良い) | 分野Aの損失(上がった分が忘却) | Adam の状態の数 |
|---|---|---|---|---|
| 事前学習のまま | 0 | 3.735 | 3.413 | ― |
| 全パラメータ・学習率1×10⁻³ | 381,888(100%) | 3.139(−0.596) | 3.908(+0.495) | 763,776 |
| 全パラメータ・学習率3×10⁻⁴ | 381,888(100%) | 3.230(−0.505) | 3.734(+0.321) | 763,776 |
| 全パラメータ・学習率1×10⁻⁴ | 381,888(100%) | 3.379(−0.356) | 3.585(+0.172) | 763,776 |
| LoRA・q と v・r=8 | 6,144(1.61%) | 3.557(−0.178) | 3.603(+0.190) | 12,288 |
| LoRA・全線形層・r=8 | 27,648(7.24%) | 3.468(−0.267) | 3.697(+0.284) | 55,296 |
表だけを見ると「LoRA は忘れにくい」ように見えます。LoRA の q・v は分野Aの悪化が+0.190で、全パラメータ学習(学習率1×10⁻³)の+0.495より小さいからです。しかし、全パラメータ学習の学習率を1×10⁻⁴に下げると、忘却は LoRA とほぼ同じ+0.172で、分野Bは LoRA の2倍(−0.356)下がりました。この小さな実験では、LoRA は「同じだけ覚えて忘れにくい」のではなく、「覚える量も忘れる量も小さい」という結果でした。
理由の1つは、LoRA が触れない部分にあります。分野Bはコードなので、出てくる文字の頻度そのものが分野Aと違います。全パラメータ学習は出力層と埋め込みも動かせますが、q・v の LoRA は注意の計算しか変えられません。対象を全線形層に広げると分野Bは−0.267まで下がり、そのぶん忘却も+0.284に増えました。「どれだけ変えられるか」と「どれだけ壊すか」が一緒に動いています。
全パラメータ学習で得たもの
同じ忘却の量で、分野Bの損失が LoRA の2倍下がった
出力層・埋め込み・LayerNorm も動かせる
学習率で「覚える・忘れる」の釣り合いを動かせる
LoRA で得たもの
学習するパラメータと Adam の状態が1.6%
保存するアダプタが26,550バイト(モデル全体の1.7%)
土台を1つ持てば、用途ごとにアダプタを差し替えられる
1ステップ(順伝播・逆伝播・Adam)の時間は、学習の途中の記録では共有のマシンの負荷で74〜123msとぶれたため、別に交互に25回ずつ測りました。
$ OPENBLAS_NUM_THREADS=1 python3 steptime.py
full 79.7 ms/step (median of 22)
qv_r8 66.8 ms/step (median of 22)
qv_r32 69.3 ms/step (median of 22)
all_r8 75.0 ms/step (median of 22)
qv_r8(dW too) 79.6 ms/step (median of 22)
LoRA(q・v、r=8)は全パラメータ学習より16%速いだけでした。最後の行は、同じ LoRA で凍結した重みの勾配 x.T @ dy も(捨てるのに)計算した場合で、全パラメータ学習と同じ79.6msに戻ります。LoRA で減る計算は「凍結した重みの勾配」と「その重みの Adam の更新」で、順伝播と、入力側へ流す勾配の計算は減りません。原論文も GPT-3 175B で学習の処理量が25%上がったと報告しており、何十倍も速くなるわけではありません。
LoRAのランクrの決め方(1〜32の実測と特異値)
この節では、ランク r を変えたときに損失と忘却がどう変わるか、低いランクで済む理由を ΔW の特異値で確かめた結果が分かります。
q・v に入れる LoRA の r を1〜32で変え、α=8・学習率3×10⁻³・120ステップで学習しました。ΔW の大きさは、0層目の Wq の (α/r)·AB のフロベニウスノルムです。
| r | 学習するパラメータ | 分野Bの損失 | 分野Aの損失 | ΔW の大きさ(0層 Wq) |
|---|---|---|---|---|
| 1 | 768 | 3.586 | 3.572 | 5.21 |
| 2 | 1,536 | 3.570 | 3.607 | 5.29 |
| 4 | 3,072 | 3.557 | 3.629 | 4.63 |
| 8 | 6,144 | 3.557 | 3.603 | 4.07 |
| 16 | 12,288 | 3.550 | 3.619 | 3.94 |
| 32 | 24,576 | 3.546 | 3.608 | 3.77 |
パラメータが32倍になっても、分野Bの損失は0.04しか変わりませんでした。同じ6,144前後のパラメータでも、q・v の r=8 より、全線形層に入れた r=8(27,648)の方がずっと効いています(3.557 と 3.468)。原論文の Table 6 も、Wq と Wv に入れる場合は r=1 で足りたと報告し、Wq だけに入れる場合はもっと大きい r が要ったとしています。ただし原論文は、事前学習と違う言語の作業のように、小さい r ではうまくいかない作業もありうると注意しています。
低いランクで済む理由を、全パラメータ学習で生じた ΔW の特異値で確かめました。特異値の2乗の合計のうち、上位 k 個が占める割合です(96×96 の行列なので最大96個)。
$ OPENBLAS_NUM_THREADS=1 python3 svd.py
energy % in top-k singular values (k=1,4,8,16 of 96)
ΔW full_1e-3 0.Wq: [19.5, 58.9, 79.7, 93.4]
ΔW full_1e-3 0.Wv: [12.2, 40.5, 60.7, 82.6]
ΔW full_3e-4 0.Wq: [21.0, 61.3, 82.6, 94.9]
ΔW full_3e-4 0.Wv: [18.0, 48.0, 69.3, 88.6]
ΔW full_1e-4 0.Wq: [23.2, 66.2, 86.8, 96.4]
ΔW full_1e-4 0.Wv: [25.3, 58.7, 79.8, 93.5]
random gaussian 96x96 : [3.8, 14.5, 26.9, 46.7]
W0 itself 0.Wq : [15.5, 45.7, 67.3, 81.5]
全部の重みを自由に動かした場合でも、Wq の変化の8割前後は上位8方向に集まっていました。ランダムな行列なら27%です。学習で必要な変化がもともと少数の方向に偏っているので、最初からランクを制限しても失うものが小さい、というのが LoRA の前提で、手元のモデルでもその傾向が見えました。
原論文の7.3節は、ΔW が W0 のどの方向を強めたかを ||ΔW|| ÷ ||Uᵀ W0 V||(U・V は ΔW の上位 r 個の特異ベクトル)で調べ、r=4 で約21.5倍、r=64 で約2倍と報告しています。手元で同じ計算をすると、r=8 で4.0倍、r=32 で1.5倍で、r が小さいほど大きいという向きは同じでした。r=8 の ||Uᵀ W0 V|| は1.021で、ランダムな方向の0.425より大きく、ΔW は W0 にもともとある方向と関係していました。ただし r=1 では0.032とランダムな方向(0.077)より小さく、1方向だけでは比べ方として安定しませんでした。
手元の結果と原論文・公式の既定値から、設定を決める順番をまとめます。
- 1q・v、r=8、α=8 で始める原論文の主な対象の層と、PEFT の既定値(r=8・lora_alpha=8)
- 2新しい分野と元の分野の両方で評価する新しい分野の損失だけでは忘却が見えない
- 3足りなければ対象を全線形層に広げる手元では分野Bの損失が0.089下がった
- 4学習率とステップ数で「覚える・忘れる」を調整するα を固定しておけば、調整するのは学習率だけになる
- 5最後に r を上げて比べる手元では r=1〜32 の差が0.040
alpha(lora_alpha)とα/rのスケールの意味
この節では、α と α/r のスケールが何を調整しているのか、α を固定したまま r を変えた場合と α を r に比例させた場合の違いが分かります。
LoRA は ΔWx を α/r 倍します。原論文は、Adam で学習するなら α の調整は学習率の調整とほぼ同じで、α は最初に試した r と同じ値にして調整しなかったと書いています。α/r で割る理由は、r を変えたときに学習率などを調整し直す手間を減らすためです。
Adam は各パラメータを学習率くらいずつ動かすので、r が大きいほど BA の和の項が増え、ΔW が大きくなりがちです。α を固定して r で割ると、これが打ち消されます。r=2 と r=32 で、α を8に固定した場合と、α/r を4にそろえた場合を比べました。
| r と α | α/r | ΔW の大きさ(0層 Wq) | 分野Bの損失 | 分野Aの損失 |
|---|---|---|---|---|
| r=2・α=8 | 4 | 5.29 | 3.570 | 3.607 |
| r=8・α=8 | 1 | 4.07 | 3.557 | 3.603 |
| r=32・α=8 | 0.25 | 3.77 | 3.546 | 3.608 |
| r=32・α=128 | 4 | 10.18 | 3.521 | 3.805 |
α を8に固定すると、r を16倍にしても ΔW の大きさは5.29から3.77とほぼ同じ範囲に収まり、忘却も+0.19前後で変わりませんでした。α/r を4にそろえる(α を r に比例させる)と、r=32 の ΔW は10.18まで大きくなり、忘却は+0.392と2倍になりました。α を r に合わせて大きくするのは、学習率を上げるのとほぼ同じで、r を変えたら学習率も見直すことになります。
PEFT の既定値は r=8・lora_alpha=8 で、use_rslora=True にするとスケールが lora_alpha/√r になると公式ドキュメントに書かれています。大きな r を使うときの別のスケールの付け方ですが、この記事では手元の比較を α/r に絞りました。
LoRAを入れる層(q・vだけか全線形層か)
この節では、Transformer のどの線形層に LoRA を入れるかで結果がどう変わるか、論文と公式の既定値と合わせて分かります。
原論文は、注意の重み(Wq・Wk・Wv・Wo)だけに LoRA を入れ、MLP は凍結しました。GPT-3 で学習するパラメータを1,800万に固定した比較では、Wq と Wv の両方に入れるのが全体として最も良く、Wq だけ・Wk だけに全部を使うと明らかに悪かったと報告しています(Table 5)。一方 QLoRA の論文は、大きなモデルで全パラメータ学習の性能に並ぶには、Transformer ブロックの全線形層に LoRA を入れる必要があったとしています。
| 対象の層 | 根拠 | 手元の結果(r=8) |
|---|---|---|
| Wq・Wv | LoRA の原論文の主な設定 | 6,144パラメータ。分野B 3.557・分野A 3.603 |
| Wq・Wk・Wv・Wo・MLP の2つ(全線形層) | QLoRA の論文の結論。PEFT の target_modules="all-linear"(出力層は除く) | 27,648パラメータ。分野B 3.468・分野A 3.697 |
| 出力層・埋め込み | 通常は対象にしない(PEFT の all-linear でも出力層は除く) | 手元では試していない |
手元では、全線形層に広げると q・v の r=32(24,576パラメータ)より分野Bがよく下がりました。パラメータを増やすなら、r を上げるより対象の層を広げる方が効いた、という結果です。ただし忘却も大きくなるので、元の分野の性能を保ちたい場合は、評価で確かめながら決めます。
PEFT で同じ設定を書くと次のようになります。この環境では PyTorch を入れていないため動かしておらず、引数は公式ドキュメントで確かめた範囲です。
from peft import LoraConfig, get_peft_model
config = LoraConfig(
r=8, # 既定値 8
lora_alpha=8, # 既定値 8(スケールは lora_alpha / r)
target_modules="all-linear", # 出力層を除く全線形層
lora_dropout=0.0, # 既定値 0.0
)
model = get_peft_model(model, config) # model は事前学習済みのモデル
model.print_trainable_parameters()
Hugging Face のモデルの読み込み方は関連記事「Hugging Face transformers入門|pipelineとタスク一覧」で扱っています。
マージ後の推論速度(重みに足し込む・外す)
この節では、学習した LoRA を重みに足し込んだとき出力が変わらないこと、推論の速さが元のモデルと同じになること、外して元に戻せることが分かります。
学習が終わったら W = W0 + (α/r)·BA を計算して W0 と置き換えれば、推論は元のモデルと同じ行列の掛け算だけになります。原論文はこれを「全パラメータ学習したモデルと比べて推論の遅延が増えない」利点として挙げ、別の作業に切り替えるときは BA を引いて W0 に戻し、別の B'A' を足せばよいとしています。手元で、学習した q・v(r=8)と全線形層(r=8)のアダプタを足し込み、外しました。
$ OPENBLAS_NUM_THREADS=1 python3 merge.py all_r8
adapter=all_r8 matrices=12 lora_params=27648
max |logits(unmerged) - logits(merged)| = 3.815e-06 (float32)
val loss B: base=3.7351 unmerged=3.4679 merged=3.4679
forward ms (16x64 tokens, median of 40, interleaved): base=17.12 unmerged=21.27 merged=18.06
after unmerge: max |W - W0| = 1.490e-08
max |logits(after unmerge) - logits(base)| = 3.338e-06
adapter.npz bytes 116470 fullmodel.npz bytes 1533614
| アダプタ | 順伝播(元のモデル) | 足し込まずに使う | 足し込んだ後 |
|---|---|---|---|
| q・v・r=8(4行列) | 24.10ms | 25.62ms(+6%) | 24.84ms(+3%) |
| 全線形層・r=8(12行列) | 17.12ms | 21.27ms(+24%) | 18.06ms(+5%) |
足し込まずに LoRA の経路を通すと、全線形層では順伝播が24%遅くなりました。足し込んだ後は元のモデルとの差が3〜5%で、3つを交互に40回測った中央値でも共有のマシンの揺れの範囲です。足し込む前後の出力の差は最大3.8×10⁻⁶(float32 の丸めの範囲)で、評価の損失は小数4桁まで同じでした。外した後の重みの差は1.49×10⁻⁸で、元のモデルに戻っています。
PEFT では、merge_and_unload() が足し込んだモデルを返し(その場で書き換えるのではないので戻り値を使う)、あとで外したい場合は merge_adapter() と unmerge_adapter() を使うと公式ドキュメントに書かれています。量子化した土台に足し込む場合は、量子化の誤差が乗るので、足し込んだ後のモデルで評価し直します。量子化した重みの扱いは関連記事「llama.cppとは|使い方・ビルド・GGUF量子化とllama-server」で扱っています。
LoRAで減るメモリ・減らないメモリとQLoRA
この節では、LoRA で学習のメモリのどこが減り、どこが減らないのか、QLoRA が土台の重みをどう小さくしたのかが、原論文の数字と試算で分かります。
学習のメモリは、おおまかに「重み」「勾配」「最適化の状態(Adam なら1パラメータに2つ)」「逆伝播のために取っておく途中の値(活性)」の4つです。LoRA で減るのは、凍結した重みの勾配と最適化の状態です。重みそのものと活性は減りません。手元のモデル(float32)で数えると次のとおりです。
| 項目 | 全パラメータ学習 | LoRA(q・v・r=8) | 減るか |
|---|---|---|---|
| 重み | 381,888 | 381,888+6,144 | 減らない(少し増える) |
| 勾配 | 381,888 | 6,144 | 減る |
| Adam の状態 | 763,776 | 12,288 | 減る |
| 合計(活性を除く) | 1,527,552個=6.1MB | 406,464個=1.6MB | 約4分の1 |
| 活性 | バッチ×系列長×層の数に比例 | ほぼ同じ | 減らない |
大きなモデルで試算すると、70億パラメータを float32 の重み・勾配・Adam の2つで持つと1パラメータ16バイトで約112GB(活性を除く)になります。LoRA で土台を bf16(2バイト)で凍結すると土台は約14GBで、q・v・r=8 の LoRA(4,194,304パラメータ)は16バイトずつでも約67MBです。この試算は手元の計算で、実際の量はライブラリの持ち方や活性で変わります。必要な GPU のメモリの見積もり方は関連記事「VRAMとは|確認方法・必要量の目安とLLMの計算式をPythonで解説」で扱っています。
原論文は GPT-3 175B で、学習の VRAM が1.2TBから350GBに、保存する差分が約350GBから35MBに(r=4、Wq・Wv のみ)なったと報告しています。
QLoRA は、LoRA で残った「土台の重み」を4bitにして小さくする方法です。Dettmers et al. の原論文(2023)は、65B のモデルを48GBの GPU 1枚で、16bit の全パラメータ学習の性能を保ったまま学習できたとし、65B の学習に要るメモリを780GB超から48GB未満にしたと書いています。原論文の要素は次の3つです。
14-bit NormalFloat(NF4)
正規分布に従う重みに対して情報理論的に最適だとする4bitのデータ型。重みは64個ずつのブロックで量子化する。
2Double Quantization
ブロックごとの量子化の定数もさらに量子化する(256個ずつ)。1パラメータあたり平均約0.37bit、65B で約3GB減ると報告。
3Paged Optimizers
NVIDIA のユニファイドメモリを使い、メモリが一時的に足りなくなったとき最適化の状態を CPU のメモリに逃がす。
計算は、4bitで持った重みを bf16 に戻してから入力と掛け、そこに bf16 の LoRA の項を足す形です(原論文の式5)。学習するのは LoRA の A・B だけで、4bit の重みは凍結したまま勾配を流します。原論文は、7B の LLaMA で4bit の土台が5,048MB、LoRA のパラメータが26MBなのに対し、LoRA の入力側の勾配(活性)が567MBになったと示し、活性が大きな割合を占めることにも触れています。また、全線形層に LoRA を入れた場合は、r などの LoRA の設定は性能にほとんど影響しなかったと書いています。
QLoRA はこの環境では動かしていません(4bit の量子化と GPU 用のライブラリが要るため)。ここに書いた数字はすべて原論文の報告値です。
よくあるエラー・つまずき(自作するときの原文つき)
この節では、LoRA を自作したり設定したりするときに出たエラーと、結果がおかしいときの原因が分かります。
| 症状・原文 | 原因 | 直し方 |
|---|---|---|
| ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 8 is different from 96) | A と B の形を取り違えた(行ベクトル表記で A を (r, d_in) にした) | 行ベクトル表記なら A は (d_in, r)、B は (r, d_out)。原論文の列ベクトル表記とは転置の関係 |
| 何ステップ学習しても損失が1桁も変わらない | A も B も0で初期化した。dA も dB も0のまま | A をランダム、B を0にする |
| LoRA を入れただけで損失が悪くなる | B もランダムに初期化した(手元で+0.57) | B を0にする。学習前の出力が元と一致することを確かめる |
| 数値微分と A の勾配だけ合わない(相対誤差0.333) | dA に α/r を掛け忘れた | dA・dB・dx のすべてに同じスケールを掛ける |
| numpy の学習が思ったより遅い | np.sqrt(整数) が返す float64 のスカラーで割り、float32 の配列が float64 に変わっていた | math.sqrt などの Python の float を使うか、dtype を確かめる |
| r を上げても良くならない | q・v だけでは変えられる部分が限られる | 対象の層を広げる。手元では r=32 より全線形層の r=8 が効いた |
| r を上げたら元の性能が大きく落ちた | α を r に比例させて大きくした(学習率を上げたのと同じ) | α を固定するか、学習率を下げる |
| 足し込んだ後に出力がずれる | 足し込むときに α/r を掛け忘れた、または量子化した土台に足した | W0 + (α/r)·BA で足し、足し込んだ後のモデルで評価し直す |
1つ目・4つ目は、手元でわざと起こして原文と数値を確かめたものです。5つ目は実際に起きたもので、numpy 2 の型の決まり(NumPy のスカラーは型を持ったまま計算に入る)によります。注意のスケール 1/√(ヘッド次元) を np.sqrt で作っていたため、注意の行列から先が float64 で計算されていました。これを直し、3次元の配列の行列積を2次元に並べ直す変更と合わせると、1ステップが0.33秒から0.11秒になりました(幅64・2層での測定)。
LoRAか全パラメータ学習か(判断表)
この節では、LoRA を選ぶか全パラメータ学習を選ぶか、LoRA を使うならどの設定から始めるかを判断する材料が分かります。
| 状況 | 向いている方法 | 理由(手元の実測・論文) |
|---|---|---|
| GPU のメモリが足りず、全パラメータの勾配と Adam の状態を置けない | LoRA。さらに足りなければ QLoRA | 勾配と最適化の状態が学習するパラメータの分だけになる |
| 用途ごとに別のモデルを持ちたい | LoRA(土台1つ+アダプタ複数) | 手元のアダプタはモデル全体の1.7%。原論文は350GBが35MBになったと報告 |
| 推論の速さを変えたくない | LoRA を足し込んで配る | 足し込んだ後は元のモデルとの差が誤差の範囲 |
| 分野が大きく違い、できるだけ合わせたい | 全パラメータ学習、または全線形層の LoRA | 手元では同じ忘却で全パラメータ学習が2倍覚えた |
| 元の分野の性能を落としたくない | 学習率を下げる・ステップを減らす・元の分野の評価を見ながら止める | LoRA でも忘却は起きた(+0.16〜+0.28) |
| 出す文字・語彙の頻度そのものを変えたい | 出力層も学習する方法を検討する | q・v の LoRA では出力層を変えられない |
LoRA で始めるときに確かめることを並べます。
よくある質問
この節では、LoRA についてよく聞かれることへの答えが分かります。
Q. LoRA は何の略ですか。
Low-Rank Adaptation の略で、日本語では「低ランク適応」と訳されます。原論文の題名は「LoRA: Low-Rank Adaptation of Large Language Models」(Hu et al., 2021)です。
Q. LoRA とファインチューニングは別のものですか。
LoRA はファインチューニングの方法の1つです。全部の重みを学習するのが全パラメータ学習(フルファインチューニング)で、LoRA は重みを凍結して差分だけを学習します。
Q. ランク r はいくつから始めればよいですか。
PEFT の既定値は r=8 です。原論文は q・v なら r=1 で足りた作業があったと報告し、手元でも r=1〜32 の差は小さいものでした。r を上げるより、対象の層と学習率を先に比べる方が効果が見えやすいです。
Q. lora_alpha はどう決めますか。
原論文は α を最初に試した r と同じにして調整していません。α は学習率に近い働きをするので、α を固定して学習率で調整すると、r を変えたときの見直しが少なくなります。
Q. LoRA なら元の性能は落ちませんか。
落ちます。手元では分野Aの損失が+0.16〜+0.28悪くなりました。全パラメータ学習も学習率を下げれば同じくらいの忘却に抑えられ、そのときは新しい分野をより多く覚えました。元の分野の評価も一緒に見ることが要ります。
Q. LoRA で学習は何倍速くなりますか。
手元では1ステップが16%速くなっただけでした。凍結した重みの勾配は省けますが、順伝播と入力側へ流す勾配は省けないからです。原論文の GPT-3 175B の報告も、学習の処理量が25%上がったという数字です。大きく減るのはメモリと保存の容量です。
Q. 推論は遅くなりますか。
足し込めば遅くなりません。足し込まずに LoRA の経路を通すと、手元では全線形層で24%遅くなりました。用途ごとに切り替えたい場合は、足し込まずに使うか、足し込みと取り外しを繰り返します。
Q. QLoRA と LoRA の違いは何ですか。
学習するもの(A・B)は同じで、土台の重みの持ち方が違います。QLoRA は土台を4bit(NF4)で凍結して持ち、計算のときに bf16 に戻します。原論文は65B を48GBの GPU 1枚で学習できたと報告しています。
Q. A をランダムにするのはなぜですか。B をランダムにしてはいけないのですか。
どちらか一方が0なら ΔW=0 で始められます。原論文は B を0、A をランダムにしています。A まで0にすると勾配が0で学習が進まず、両方ランダムにすると始める前から元の性能を壊します(手元で+0.57)。
Q. numpy だけで LoRA を試す意味はありますか。
大きなモデルの性能は分かりませんが、式・初期化・スケール・マージの性質は規模によらず同じなので、ライブラリの設定の意味を数字で確かめられます。手で書いた逆伝播は、数値微分と照合してから使います。
まとめ
この節では、記事全体の要点が分かります。
- LoRA は h = W0x + (α/r)·BAx の B と A だけを学習する。学習するパラメータは1行列あたり r×(d_in+d_out)。手元の q・v・r=8 で全体の1.6%、アダプタの容量は1.7%だった。
- B=0・A ランダムで始めると、学習前の出力が元のモデルと完全に一致する。両方0だと勾配が0で動かず、両方ランダムだと元の性能を壊した(+0.57)。
- 手元の実験では、LoRA は覚える量も忘れる量も小さかった。全パラメータ学習を学習率1×10⁻⁴にすると、LoRA と同じ忘却で分野Bが2倍下がった。元の分野の評価も一緒に見る。
- r は1〜32で差が小さく、対象の層を広げる方が効いた。全パラメータ学習の ΔW も8割前後が上位8方向に集まっていた。α を r に比例させると学習率を上げたのと同じになる。
- 足し込めば推論の速さは元と同じで、外せば元に戻る。減るのは勾配・最適化の状態・保存の容量で、1ステップの時間は16%しか減らない。土台まで小さくするのが QLoRA(NF4・Double Quantization・Paged Optimizers)。
参考・出典
この節では、この記事で根拠にした論文と公式ドキュメントが分かります(確認日 2026年11月4日)。
- Edward J. Hu ほか「LoRA: Low-Rank Adaptation of Large Language Models」2021:https://arxiv.org/abs/2106.09685
- Tim Dettmers ほか「QLoRA: Efficient Finetuning of Quantized LLMs」2023:https://arxiv.org/abs/2305.14314
- Hugging Face PEFT 公式ドキュメント「LoRA」(LoraConfig・merge_and_unload):https://huggingface.co/docs/peft/package_reference/lora
- NumPy 公式ドキュメント:https://numpy.org/doc/stable/
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「LoRAの仕組み|numpyで自作して全パラメータ学習と忘却を実測」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。