---
title: "Adam에서 L2가 weight decay가 아닌 이유 — AdamW"
slug: "decoupled-weight-decay-regularization-deep-dive"
category: "AI / LLM"
topic: "ai-llm"
subtopic: "ml-foundations"
tags: ["AdamW","Adam","Weight Decay","L2 Regularization","SGD","Optimizer","Deep Learning","Paper Review"]
status: "published"
created: "2026-07-24"
updated: "2026-07-24"
summary: "Loshchilov·Hutter의 ICLR 2019 Decoupled Weight Decay Regularization 원 논문을 12살 독자 기준으로 해부한다. SGD에서는 같은 L2 penalty와 weight decay가 Adam에서는 왜 달라지는지, Adam의 moment·adaptive denominator, AdamW의 분리된 shrinkage, 원 논문과 PyTorch weight_decay 파라미터화 차이, normalized weight decay의 실험적 범위, 저자 공식 Torch 코드의 실제 순서, Python 3.9.6으로 직접 검증한 SGD 등가성·Adam 불등가성·moment 오염·반복 shrinkage, 성능·운영·LLM fine-tuning 주의점을 사실·직접 실행·해석으로 분리한다."
kind: "Deep Dive"
evidence: "Loshchilov·Hutter ICLR 2019 원문과 appendix·algorithm 직접 확인, 저자 공식 AdamW-and-SGDW 저장소 commit 0ae5185의 Torch adam.lua·train.lua·README 직접 확인, PyTorch main commit 526b9ce의 AdamW source와 현재 공식 API 문서 직접 확인, Python 3.9.6 표준 라이브러리로 L2 미분·SGD 등가성·Adam L2와 AdamW의 좌표별 shrinkage·moment·반복 decay를 직접 대조"
series: "LLM 시스템 지도 — 3단계 Bottom-Up"
---

> **3단계 일곱 번째 논문.** 이 글은 Ilya Loshchilov, Frank Hutter의 [Decoupled Weight Decay Regularization 원문 PDF](https://arxiv.org/pdf/1711.05101)를 직접 읽고 썼다. arXiv 최초 제출은 2017-11-14이며, 이 글이 확인한 PDF는 ICLR 2019 게재 표기가 있는 판본이다.[arXiv 서지 기록](https://arxiv.org/abs/1711.05101) AdamW(Adam with decoupled Weight decay)는 **loss의 gradient에 (L2) 항을 섞어 Adam의 적응형 분모로 처리하지 않고, parameter(파라미터) 자체를 별도 비율로 줄이는 Adam 변형**이다.

## 이 글에서 얻을 답과 범위

`weight_decay=0.01`이라는 설정은 너무 흔해서, “작은 weight를 선호하게 만드는 regularization(정규화)” 정도로만 외우기 쉽다. 하지만 같은 숫자를 SGD(Stochastic Gradient Descent, 확률적 경사 하강법), Adam, AdamW에 넣어도 update(갱신) 식은 같지 않다. 특히 Adam의 일반적인 L2 regularization(엘투 정규화)은 loss gradient와 (\lambda\theta)를 합친 다음, Adam의 first moment(1차 모멘트)와 second moment(2차 모멘트)에 넣는다. 이때 “weight를 줄이는 힘”까지 parameter별 adaptive scaling(적응형 크기 조절)을 받는다.

이 글을 끝까지 읽으면 다음을 답할 수 있어야 한다.

1. L2 penalty와 weight decay는 정확히 무엇이며, SGD에서는 어떤 coefficient mapping(계수 대응) 아래 같은 update가 되는가.
2. Adam의 (m_t), (v_t), bias correction(편향 보정), (\epsilon)이 무엇이며, L2 gradient가 왜 여기 섞이는가.
3. Adam with L2와 AdamW가 같은 initial parameter와 data gradient에서 다른 parameter trajectory(파라미터 경로)를 만드는 이유는 무엇인가.
4. “decoupled”는 learning rate와 완전히 무관하다는 뜻이 아닌데, 무엇과 분리된다는 뜻인가.
5. 원 논문의 (\lambda)와 현재 PyTorch `AdamW(weight_decay=...)`의 숫자를 왜 그대로 복사하면 안 되는가.
6. 저자 공식 Torch code는 L2와 AdamW를 어느 줄에서 갈라 놓고, learning-rate multiplier(학습률 배수)를 weight decay에 어떻게 적용하는가.
7. LLM fine-tuning(미세 조정)에서 parameter group(파라미터 그룹), scheduler(스케줄러), checkpoint를 볼 때 무엇을 확인해야 하는가.

**범위**는 Loshchilov·Hutter의 AdamW·SGDW(SGD with decoupled weight decay) 논문, 저자 공식 Torch experiment code, 현재 PyTorch `AdamW` API다. 이 글은 Adam의 convergence theorem(수렴 정리), AMSGrad(Adam의 변형), weight norm control(가중치 노름 제어), 모든 optimizer benchmark를 대신 설명하지 않는다. 아래 Python 예제는 CIFAR-10·ImageNet32×32 논문 실험을 재현하지 않고, L2 derivative(미분), SGD의 등가성, Adam의 불등가성, moment buffer(모멘트 버퍼)를 작은 scalar(스칼라) 숫자로 확인한다.

### 먼저 잡을 한 문장

> **SGD에서는 L2 gradient를 더하는 일과 parameter를 일정 비율로 줄이는 일이 계수만 맞추면 같지만, Adam에서는 L2 gradient도 Adam의 역사 기반 분모를 통과한다. AdamW는 data gradient만 Adam에 맡기고 parameter shrinkage는 따로 적용한다.**

## 왜 필요한가 — 같은 weight decay라는 이름이 다른 update를 가린다

### 학습은 loss를 낮추는 쪽으로 parameter를 조금 움직이는 일이다

model(모델)의 parameter vector(파라미터 벡터)를 (\theta)라고 하자. training batch(학습 배치) 하나에서 계산한 loss를 (f_t(\theta))라고 적는다. gradient(기울기) (\nabla f_t(\theta))는 각 parameter를 아주 조금 올렸을 때 loss가 얼마나 변할지를 담은 vector다.

가장 단순한 SGD update는 다음이다.

\[
\theta_{t+1}=\theta_t-\alpha\nabla f_t(\theta_t)
\tag{1}
\]

| 기호 | 뜻 | 쉬운 설명 |
| --- | --- | --- |
| (t) | update step 번호 | 몇 번째 mini-batch까지 처리했는지 |
| (\theta_t) | t번째 update 전 parameter | model의 현재 다이얼 묶음 |
| (f_t) | t번째 batch loss | 이 batch에서 틀린 정도 |
| (\nabla f_t) | loss gradient | 어느 다이얼을 어느 방향으로 움직일지 |
| (\alpha) | learning rate(학습률) | 한 번에 움직일 보폭 |

weight가 무한히 커지는 것, training data의 우연한 noise(잡음)를 과하게 외우는 것, 너무 복잡한 함수를 만드는 것을 줄이기 위해 penalty를 더할 수 있다. 가장 흔한 L2 penalty는 다음이다.

\[
f_t^{\mathrm{reg}}(\theta)
=f_t(\theta)+\frac{\lambda_{L2}}{2}\lVert\theta\rVert_2^2
\tag{2}
\]

여기서 (\lVert\theta\rVert_2^2=\sum_i\theta_i^2)다. (\lambda_{L2})는 L2 penalty coefficient(엘투 패널티 계수)다. 미분하면

\[
\nabla f_t^{\mathrm{reg}}(\theta)
=\nabla f_t(\theta)+\lambda_{L2}\theta
\tag{3}
\]

가 된다. (\lambda_{L2}\theta_i)는 parameter가 0에서 멀수록 0 쪽으로 더 세게 당기는 gradient다.

### weight decay는 loss에 penalty를 쓰지 않고 parameter를 직접 줄인다

Hanson·Pratt 식을 따라 원 논문은 weight decay를 다음처럼 쓴다.

\[
\theta_{t+1}=(1-\lambda_{WD})\theta_t
-\alpha\nabla f_t(\theta_t)
\tag{4}
\]

(\lambda_{WD})는 **한 step에서 parameter에 곱할 shrinkage rate(축소율)** 이다. data gradient가 0이면 (\theta)만 매 step (1-\lambda_{WD})배가 된다. 따라서 여러 step 뒤에는 대략 exponential decay(지수 감쇠) 모양이 된다.

```text
L2 regularization
  loss에 0.5 * λ_L2 * ||θ||²를 더한다
  → gradient에 λ_L2 * θ가 추가된다

weight decay
  parameter 자체에 (1 - λ_WD)를 곱한다
  → data-loss gradient와 별도 shrinkage step이다
```

둘은 같은 목적을 향하지만, 계산 경로가 다르다. 왜 이름이 섞였는지는 다음 절의 SGD 등가성 때문이다.

## 무엇인가 — SGD에서는 같고 Adam에서는 갈라지는 regularization

### SGD에서만 성립하는 정확한 등가성

SGD가 식 (2)의 regularized loss를 최적화하면 식 (3)을 넣어

\[
\begin{aligned}
\theta_{t+1}
&=\theta_t-\alpha\left(\nabla f_t(\theta_t)+\lambda_{L2}\theta_t\right)\\
&=(1-\alpha\lambda_{L2})\theta_t-\alpha\nabla f_t(\theta_t)
\end{aligned}
\tag{5}
\]

를 얻는다. 식 (4)와 비교하면

\[
\lambda_{WD}=\alpha\lambda_{L2}
\tag{6}
\]

일 때 정확히 같다. 원 논문 Proposition 1도 standard SGD에서 이 coefficient reparameterization(계수 재매개변수화) 아래 L2와 weight decay가 동치라고 쓴다.[원 논문 2절·Appendix A Proposition 1](https://arxiv.org/pdf/1711.05101)

이 등가성에는 조건이 있다.

1. standard SGD update여야 한다.
2. L2 계수와 per-step decay 계수를 식 (6)처럼 맞춰야 한다.
3. learning rate가 바뀌면 같은 L2 계수에 대응하는 per-step decay도 바뀐다.

그래서 `L2 regularization = weight decay`는 **항상 참인 정의**가 아니라, 특정 SGD update와 특정 계수 대응에서 성립하는 계산 결과다.

### Adam은 gradient마다 서로 다른 분모를 쓴다

Adam(Adaptive Moment Estimation, 적응형 모멘트 추정)은 gradient의 moving average(이동 평균)와 squared gradient의 moving average를 보관한다. data loss gradient를 (g_t=\nabla f_t(\theta_{t-1}))라고 하면 일반적인 Adam은 다음 흐름이다.

\[
m_t=\beta_1m_{t-1}+(1-\beta_1)g_t
\tag{7}
\]

\[
v_t=\beta_2v_{t-1}+(1-\beta_2)g_t^2
\tag{8}

\]

\[
\hat m_t=\frac{m_t}{1-\beta_1^t},
\qquad
\hat v_t=\frac{v_t}{1-\beta_2^t}
\tag{9}

\]

\[
\theta_t=\theta_{t-1}
-\alpha\frac{\hat m_t}{\sqrt{\hat v_t}+\epsilon}
\tag{10}

\]

| 값 | 뜻 | 왜 필요한가 |
| --- | --- | --- |
| (m_t) | 1차 모멘트 | 최근 gradient 방향의 평균을 부드럽게 본다 |
| (v_t) | 2차 모멘트 | 최근 gradient 크기의 제곱 평균을 본다 |
| (\beta_1), (\beta_2) | decay coefficient(감쇠 계수) | 얼마나 과거를 기억할지 정한다 |
| (\hat m_t), (\hat v_t) | bias-corrected moment(편향 보정 모멘트) | 0에서 시작한 buffer의 초반 치우침을 보정한다 |
| (\epsilon) | numerical stability term(수치 안정 항) | 분모가 0 또는 너무 작아지는 것을 막는다 |

모든 원소 연산은 element-wise(원소별)다. 어떤 parameter coordinate(좌표)가 역사적으로 큰 gradient를 자주 받았다면 (\sqrt{\hat v_t})가 커져 그 좌표의 update 보폭은 작아질 수 있다. 바로 이 adaptive denominator(적응형 분모)가 L2와 weight decay를 갈라 놓는다.

### Adam에 L2를 넣으면 regularizer도 m과 v에 들어간다

Adam에 L2 loss를 더하는 흔한 구현은 Adam에 넣을 gradient를

\[
g_t^{\mathrm{L2}}
=\nabla f_t(\theta_{t-1})+\lambda_{L2}\theta_{t-1}
\tag{11}

로 만든다. 이 합친 gradient가 식 (7)과 (8)의 (m_t), (v_t)에 들어간다.

```text
Adam with L2
data gradient + λ_L2 * θ
        ↓
      m_t, v_t
        ↓
adaptive division by sqrt(v_hat) + eps
        ↓
parameter update
```

data gradient와 L2 gradient는 같은 buffer와 같은 adaptive scaling을 공유한다. 원 논문은 history상 큰 data gradient를 가진 weight는 L2 regularization gradient도 더 작은 상대량으로 처리될 수 있다고 설명한다.[원 논문 2절·Proposition 2](https://arxiv.org/pdf/1711.05101)

### AdamW는 shrinkage를 adaptive path 밖으로 꺼낸다

원 논문의 Algorithm 2는 schedule multiplier(스케줄 배수) (\eta_t)를 포함해 AdamW를 다음처럼 적는다.

\[
\theta_t
=\theta_{t-1}
-\eta_t\left(
\alpha\frac{\hat m_t}{\sqrt{\hat v_t}+\epsilon}
+\lambda_{WD}\theta_{t-1}
\right)
\tag{12}
\]

식 (12)에서 (m_t), (v_t)는 **data loss gradient만**으로 만든다. weight decay term은 분모 (\sqrt{\hat v_t}+\epsilon)를 통과하지 않는다. 같은 식을 두 단계로 쓰면 더 읽기 쉽다.

\[
\theta_t'=(1-\eta_t\lambda_{WD})\theta_{t-1}
\tag{13}
\]

\[
\theta_t=\theta_t'
-\eta_t\alpha\frac{\hat m_t}{\sqrt{\hat v_t}+\epsilon}
\tag{14}
\]

이것이 decoupled다. **regularizer gradient를 Adam의 moment·variance estimator와 preconditioner(사전조건자)에 섞지 않는다**는 뜻이다.

## 선행 개념 — norm, momentum, parameter별 보폭

### L2 norm은 parameter 크기를 재는 방법이다

parameter가 (\theta=[\theta_1,\theta_2,\ldots,\theta_d])일 때

\[
\lVert\theta\rVert_2
=\sqrt{\theta_1^2+\theta_2^2+\cdots+\theta_d^2}
\]

이다. L2 penalty (\frac{\lambda}{2}\lVert\theta\rVert_2^2)는 큰 parameter에 더 큰 0 방향 gradient를 준다. 여기서 “작은 weight가 항상 좋은 model”이라는 뜻은 아니다. data fit(데이터 적합), architecture, normalization, learning rate schedule과 함께 generalization(일반화)에 영향을 주는 한 제어 장치다.

### momentum buffer는 과거 gradient를 기억한다

Adam의 (m_t)는 velocity(속도) 같은 비유로 설명할 수 있다. 같은 방향 gradient가 여러 step 이어지면 그 방향 기억이 쌓인다. (v_t)는 gradient의 크기 제곱을 기억해, 자주 요동치거나 큰 gradient가 난 coordinate의 보폭을 줄이는 역할을 한다.

중요한 사실은 buffer가 **무엇을 입력으로 받았는가**다.

| optimizer 형태 | (m), (v)에 들어가는 값 |
| --- | --- |
| Adam with L2 | (\nabla f+\lambda_{L2}\theta) |
| AdamW | (\nabla f) |

따라서 AdamW의 `weight decay does not accumulate in the momentum nor variance`라는 PyTorch 공식 설명은 단순한 홍보 문구가 아니라 buffer 입력의 차이다.[PyTorch `AdamW` 공식 문서](https://docs.pytorch.org/docs/main/generated/torch.optim.AdamW.html)

### decoupled는 learning rate가 필요 없다는 말이 아니다

원 논문 식 (12)에는 decay에도 (\eta_t)가 곱해진다. 현재 PyTorch `AdamW` documentation은 한 step을 다음 순서로 적는다.

\[
\theta\leftarrow\theta-\gamma\lambda\theta
\]

그 뒤 data gradient로 (m), (v)를 갱신하고 Adam update를 적용한다. 즉 PyTorch에서 per-step shrinkage factor는

\[
1-\gamma\cdot\texttt{weight\_decay}
\tag{15}

이다.[PyTorch `AdamW` 알고리즘](https://docs.pytorch.org/docs/main/generated/torch.optim.AdamW.html)

learning-rate scheduler가 (\gamma)를 작게 만들면, 그 step의 direct decay도 작아진다. AdamW가 decouple한 것은 **learning rate schedule 전체와 완전히 분리**가 아니라, weight decay가 **adaptive gradient normalization과 moment state에서 분리**된 것이다.

## 밑바닥 원리 — 작은 숫자로 SGD의 등가성과 Adam의 불등가성 보기

### SGD에서는 계수를 맞추면 같은 2.99가 나온다

다음 값을 사용하자.

\[
\theta=3,
\quad \nabla f=0.7,
\quad \alpha=0.01,
\quad \lambda_{L2}=0.1
\]

식 (6)에 따라 (\lambda_{WD}=0.01\times0.1=0.001)이다.

L2 SGD는

\[
3-0.01(0.7+0.1\times3)=2.99
\]

weight decay SGD는

\[
(1-0.001)3-0.01\times0.7=2.99
\]

이다. 이것이 사람들이 두 용어를 바꿔 부르게 된 실제 이유다.

### Adam에서는 1과 100이 같은 비율로 줄지 않는다

data gradient가 0인 첫 Adam step만 보자. (\alpha=0.01), (\lambda_{L2}=0.1), (\epsilon=10^{-8})이고 initial parameter 두 개가 `[1, 100]`이라고 하자.

Adam with L2는 각 coordinate에 `[0.1, 10]`을 gradient로 넣는다. 첫 step의 bias-corrected Adam ratio는 대략 gradient의 sign(부호)만 남는다.

```text
Adam with L2
  1   → 0.990000001    상대 비율 약 0.990000001
  100 → 99.990000000   상대 비율 약 0.999900000
```

작은 1은 약 1% 줄고, 큰 100은 약 0.01% 줄었다. “큰 weight일수록 더 많이 0으로 당긴다”는 L2의 직관이 Adam adaptive denominator를 통과하며 이 예에서는 상대 shrinkage로 유지되지 않는다.

동일한 data gradient 0에서 PyTorch-style AdamW를 쓰면 direct decay가 먼저 적용된다.

```text
AdamW
  1   → 0.999000000    상대 비율 0.999
  100 → 99.900000000   상대 비율 0.999
```

두 coordinate 모두 같은 (1-0.01\times0.1)=0.999를 곱했다. 이것은 AdamW가 parameter별 data update 크기를 같게 만든다는 뜻이 아니다. data gradient update는 여전히 Adam의 adaptive rule을 따른다. **직접 weight shrinkage만 같은 비율**이다.

### L2 gradient가 moment history를 바꾸는 것도 차이다

(\theta=10), data gradient (0.2), L2 계수 (0.1)인 첫 step을 보자.

\[
g^{\mathrm{L2}}=0.2+0.1\times10=1.2
\]

Adam with L2의 first moment는 (\beta_1=0.9)일 때 (m_1=0.1\times1.2=0.12)이고, second moment는 (v_1=0.001\times1.2^2=0.00144)다. AdamW는 10을 9.99로 먼저 줄이되, moment에는 data gradient `0.2`만 넣는다. 그래서 (m_1=0.02), (v_1=0.00004)다.

```text
Adam with L2
  moment input = 1.2
  m = 0.12, v = 0.00144

AdamW
  direct decay 10 → 9.99
  moment input = 0.2
  m = 0.02, v = 0.00004
```

이 buffer 차이는 다음 step에도 남는다. 그래서 AdamW를 “Adam step 뒤에 L2 한 번 더”라고 느슨하게 구현하면 update order, state, scheduler 적용이 reference implementation과 달라질 수 있다.

## 내부 구조와 실제 실행 흐름 — 논문 code와 현재 PyTorch를 대조하기

### 저자 공식 Torch code는 L2와 AdamW를 분기한다

저자 공식 [`AdamW-and-SGDW` 저장소](https://github.com/loshchil/AdamW-and-SGDW/tree/0ae5185b3c655e45cc249adaca4457cd881874cc)는 Torch Lua implementation을 공개했다. 이 글이 확인한 commit은 `0ae5185b3c655e45cc249adaca4457cd881874cc`이다.

[`UPDATETORCHFILES/adam.lua` 39–44행](https://github.com/loshchil/AdamW-and-SGDW/blob/0ae5185b3c655e45cc249adaca4457cd881874cc/UPDATETORCHFILES/adam.lua#L39-L44)은 `adam_type == 0`일 때만 `dfdx:add(wd, x)`로 gradient에 L2 term을 더한다. 뒤의 60–71행은 `dfdx`로 moment와 denominator를 만들고 parameter를 Adam update한다. 반대로 72–75행은 `adam_type > 0`일 때 Adam update 뒤 `x:add(-wdcur, xold)`로 **갱신 전 parameter 복사본**을 직접 줄인다.

```lua
-- L2 path
if (config.adam_type == 0) then
   dfdx:add(wd, x)
end

-- Adam moment and adaptive update use dfdx
state.m:mul(beta1):add(1-beta1, dfdx)
state.v:mul(beta2):addcmul(1-beta2, dfdx, dfdx)
x:addcdiv(-stepSize, state.m, state.denom)

-- decoupled path
if (config.adam_type > 0) then
   x:add(-wdcur, xold)
end
```

이것은 최신 PyTorch의 내부 순서와 완전히 같은 code byte(바이트)는 아니다. 현재 PyTorch `AdamW`는 direct decay를 Adam moment update **전에** 적용한다. 그러나 두 구현 모두 핵심은 같다. decay term이 data gradient로 (m), (v)에 들어가지 않는다. source revision과 framework를 바꾸면 update order까지 확인해야 한다.

### 현재 PyTorch AdamW의 호출 경로

이 글이 확인한 PyTorch main commit은 `526b9cee8c9ad8da312bf33ea2c3f308cbfe8876`이다. [`torch/optim/adamw.py`](https://github.com/pytorch/pytorch/blob/526b9cee8c9ad8da312bf33ea2c3f308cbfe8876/torch/optim/adamw.py)는 `AdamW`가 공통 Adam functional path를 `decoupled_weight_decay=True`로 호출함을 보여 준다. current documentation은 다음을 명시한다.

* `AdamW`의 weight decay는 momentum과 variance에 accumulate되지 않는다.
* default `lr`은 `1e-3`, `betas`는 `(0.9, 0.999)`, `eps`는 `1e-8`, `weight_decay`는 `1e-2`다.
* `foreach` implementation은 CUDA에서 보통 빠를 수 있지만 tensorlist intermediate 때문에 peak memory를 더 쓸 수 있다.

[PyTorch `AdamW` 공식 문서](https://docs.pytorch.org/docs/main/generated/torch.optim.AdamW.html)는 현재 API의 근거다. 논문의 2019 Torch experiment hyperparameter와 현재 PyTorch default를 같다고 가정하면 안 된다.

### 원 논문의 normalized weight decay는 별도 제안이다

논문 Appendix B.1은 training budget(학습 예산)에 따라 좋은 decay 값이 달라지는 관찰을 줄이기 위해 normalized weight decay를 제안한다.

\[
\lambda_{WD}
=\lambda_{\mathrm{norm}}\sqrt{\frac{b}{B\cdot T}}
\tag{16}

\]

| 기호 | 뜻 |
| --- | --- |
| (\lambda_{\mathrm{norm}}) | 논문이 제안한 normalized decay hyperparameter |
| (b) | batch size |
| (B) | training data point 총수 |
| (T) | total training epochs |

저자 code도 cosine schedule에서 batch 수와 restart period를 이용해 normalized decay를 계산한다.[`train.lua` 64–99행](https://github.com/loshchil/AdamW-and-SGDW/blob/0ae5185b3c655e45cc249adaca4457cd881874cc/train.lua#L64-L99) 하지만 논문은 이것이 “few experiments로부터 정보 얻은 한 가지 normalization”이라고 명시한다.[원 논문 Appendix B.1](https://arxiv.org/pdf/1711.05101) 따라서 논문의 `weightDecay=0.025` example을 현재 PyTorch `weight_decay=0.025`에 그대로 복사하면 parameterization·batch budget·scheduler가 달라 같은 실험이 아니다.

## 직접 검증과 재현 — 작은 scalar로 AdamW를 실행하기

### 실행 환경과 검증 범위

아래 코드는 macOS의 Python `3.9.6`, 표준 라이브러리만 사용해 직접 실행했다. GPU, PyTorch, CIFAR-10을 쓰지 않았다. 검증 대상은 다음이다.

1. (\frac{\lambda}{2}\theta^2)의 analytic derivative(해석적 미분)가 finite difference(유한 차분)와 일치하는가.
2. SGD에서 (\lambda_{WD}=\alpha\lambda_{L2})일 때 L2·weight decay update가 같은가.
3. data gradient가 0일 때 Adam with L2와 AdamW의 좌표별 relative shrinkage가 다른가.
4. L2 regularizer가 Adam moment에 들어가고 AdamW에서는 data gradient만 들어가는가.
5. data gradient가 0인 AdamW가 반복해서 (1-\mathrm{lr}\cdot\mathrm{wd})를 곱하는가.

```python
#!/usr/bin/env python3
"""Small, dependency-free checks for Loshchilov & Hutter AdamW."""

from __future__ import annotations

import math


def l2_penalty(theta, coefficient):
    return 0.5 * coefficient * theta * theta


def finite_difference_l2_gradient(theta, coefficient, delta=1e-6):
    return (l2_penalty(theta + delta, coefficient) - l2_penalty(theta - delta, coefficient)) / (2 * delta)


def sgd_l2_step(theta, data_gradient, learning_rate, l2_coefficient):
    return theta - learning_rate * (data_gradient + l2_coefficient * theta)


def sgd_weight_decay_step(theta, data_gradient, learning_rate, decay_rate):
    return (1.0 - decay_rate) * theta - learning_rate * data_gradient


def adam_l2_step(theta, data_gradient, state, learning_rate, l2_coefficient,
                 beta1=0.9, beta2=0.999, epsilon=1e-8):
    gradient = data_gradient + l2_coefficient * theta
    step = state['step'] + 1
    m = beta1 * state['m'] + (1.0 - beta1) * gradient
    v = beta2 * state['v'] + (1.0 - beta2) * gradient * gradient
    m_hat = m / (1.0 - beta1 ** step)
    v_hat = v / (1.0 - beta2 ** step)
    next_theta = theta - learning_rate * m_hat / (math.sqrt(v_hat) + epsilon)
    return next_theta, {'step': step, 'm': m, 'v': v}, gradient


def adamw_step(theta, data_gradient, state, learning_rate, weight_decay,
               beta1=0.9, beta2=0.999, epsilon=1e-8):
    decayed_theta = theta * (1.0 - learning_rate * weight_decay)
    step = state['step'] + 1
    m = beta1 * state['m'] + (1.0 - beta1) * data_gradient
    v = beta2 * state['v'] + (1.0 - beta2) * data_gradient * data_gradient
    m_hat = m / (1.0 - beta1 ** step)
    v_hat = v / (1.0 - beta2 ** step)
    next_theta = decayed_theta - learning_rate * m_hat / (math.sqrt(v_hat) + epsilon)
    return next_theta, {'step': step, 'm': m, 'v': v}, decayed_theta


def fresh_state():
    return {'step': 0, 'm': 0.0, 'v': 0.0}


def show(label, values):
    if isinstance(values, list):
        print(label + ': [' + ', '.join(f'{value:.9f}' for value in values) + ']')
    else:
        print(f'{label}: {values:.9f}')


def main():
    learning_rate = 0.01
    l2_coefficient = 0.1

    print('== L2 gradient ==')
    theta = 3.0
    analytical = l2_coefficient * theta
    numerical = finite_difference_l2_gradient(theta, l2_coefficient)
    show('analytical d(0.5*lambda*theta^2)/dtheta', analytical)
    show('finite-difference gradient', numerical)
    assert math.isclose(analytical, numerical, rel_tol=1e-8, abs_tol=1e-8)

    print('\n== SGD: L2 and decoupled weight decay are equal after coefficient mapping ==')
    data_gradient = 0.7
    decay_rate = learning_rate * l2_coefficient
    sgd_l2 = sgd_l2_step(theta, data_gradient, learning_rate, l2_coefficient)
    sgd_wd = sgd_weight_decay_step(theta, data_gradient, learning_rate, decay_rate)
    show('SGD with L2 penalty', sgd_l2)
    show('SGD with decoupled decay', sgd_wd)
    assert math.isclose(sgd_l2, sgd_wd, rel_tol=1e-12, abs_tol=1e-12)

    print('\n== first Adam step with zero data gradient ==')
    coordinates = [1.0, 100.0]
    l2_results, adamw_results = [], []
    for value in coordinates:
        l2_next, _, _ = adam_l2_step(value, 0.0, fresh_state(), learning_rate, l2_coefficient)
        adamw_next, _, _ = adamw_step(value, 0.0, fresh_state(), learning_rate, l2_coefficient)
        l2_results.append(l2_next)
        adamw_results.append(adamw_next)
    show('initial coordinates', coordinates)
    show('Adam with L2 penalty', l2_results)
    show('AdamW', adamw_results)
    show('Adam L2 relative factors', [new / old for old, new in zip(coordinates, l2_results)])
    show('AdamW relative factors', [new / old for old, new in zip(coordinates, adamw_results)])
    assert not math.isclose(l2_results[0] / coordinates[0], l2_results[1] / coordinates[1])
    assert math.isclose(adamw_results[0] / coordinates[0], adamw_results[1] / coordinates[1])

    print('\n== L2 regularizer enters Adam moments, AdamW keeps them data-only ==')
    theta = 10.0
    data_gradient = 0.2
    _, l2_state, l2_gradient = adam_l2_step(theta, data_gradient, fresh_state(), learning_rate, l2_coefficient)
    _, adamw_state, decayed_theta = adamw_step(theta, data_gradient, fresh_state(), learning_rate, l2_coefficient)
    show('data gradient', data_gradient)
    show('Adam L2 gradient stored in moments', l2_gradient)
    show('AdamW theta after direct decay', decayed_theta)
    show('Adam L2 first moment m', l2_state['m'])
    show('AdamW first moment m', adamw_state['m'])
    show('Adam L2 second moment v', l2_state['v'])
    show('AdamW second moment v', adamw_state['v'])
    assert math.isclose(l2_gradient, 1.2)
    assert math.isclose(l2_state['m'], 0.12)
    assert math.isclose(adamw_state['m'], 0.02)

    print('\n== repeated AdamW decay with no data gradient ==')
    theta = 1.0
    state = fresh_state()
    for _ in range(5):
        theta, state, _ = adamw_step(theta, 0.0, state, learning_rate, l2_coefficient)
    expected = (1.0 - learning_rate * l2_coefficient) ** 5
    show('five-step AdamW parameter', theta)
    show('closed-form (1 - lr*wd)^5', expected)
    assert math.isclose(theta, expected, rel_tol=1e-12, abs_tol=1e-12)
    print('checks passed')


if __name__ == '__main__':
    main()
```

실제 출력은 다음이다.

```text
== L2 gradient ==
analytical d(0.5*lambda*theta^2)/dtheta: 0.300000000
finite-difference gradient: 0.300000000

== SGD: L2 and decoupled weight decay are equal after coefficient mapping ==
SGD with L2 penalty: 2.990000000
SGD with decoupled decay: 2.990000000

== first Adam step with zero data gradient ==
initial coordinates: [1.000000000, 100.000000000]
Adam with L2 penalty: [0.990000001, 99.990000000]
AdamW: [0.999000000, 99.900000000]
Adam L2 relative factors: [0.990000001, 0.999900000]
AdamW relative factors: [0.999000000, 0.999000000]

== L2 regularizer enters Adam moments, AdamW keeps them data-only ==
data gradient: 0.200000000
Adam L2 gradient stored in moments: 1.200000000
AdamW theta after direct decay: 9.990000000
Adam L2 first moment m: 0.120000000
AdamW first moment m: 0.020000000
Adam L2 second moment v: 0.001440000
AdamW second moment v: 0.000040000

== repeated AdamW decay with no data gradient ==
five-step AdamW parameter: 0.995009990
closed-form (1 - lr*wd)^5: 0.995009990
checks passed
```

이 출력은 “AdamW가 언제나 더 좋은 validation score를 낸다”는 증명이 아니다. 수치상 확인한 사실은 더 좁다.

* SGD에서는 계수를 맞추면 두 식이 같은 parameter를 낸다.
* 첫 Adam step에서 L2 penalty는 coordinate마다 다른 **상대** shrinkage를 냈다.
* AdamW direct decay는 두 coordinate에 같은 비율을 곱했다.
* L2 term이 Adam moment buffer를 바꾸지만 AdamW buffer는 data gradient만 받았다.

## 성능과 트레이드오프 — AdamW가 없애지 않는 문제

### 원 논문의 실험 조건과 결과 범위

원 논문은 CIFAR-10·CIFAR-100과 downsampled ImageNet32×32에서 Shake-Shake 기반 26-layer ResNet을 사용했다. batch size는 128, regular image augmentation을 썼고, fixed·step-drop·cosine annealing learning-rate schedule을 비교했다. Figure 1·2는 AdamW가 Adam with L2보다 더 분리된 learning rate·weight decay search space를 보였고 cosine schedule이 더 좋은 결과를 냈다고 보고한다.[원 논문 4.1절·그림 1·2](https://arxiv.org/pdf/1711.05101)

논문은 default base learning rate 0.001 조건에서 AdamW가 CIFAR-10과 ImageNet32×32에서 Adam보다 test error 15% relative improvement를 얻었다고 적었다. 이 수치는 2019 논문의 architecture·training budget·augmentation·normalized decay·schedule 조건의 결과다.[원 논문 4.2절·그림 4](https://arxiv.org/pdf/1711.05101) 오늘날의 LLM, 다른 dataset, 다른 parameter group에서 같은 개선을 보장하지 않는다.

| 주장 | 원문이 뒷받침하는 범위 | 일반화하면 안 되는 말 |
| --- | --- | --- |
| AdamW는 Adam L2와 다른 update다 | algorithm·Proposition 2 | 모든 optimizer의 L2와 decay가 다르다 |
| AdamW search space가 더 separable했다 | 해당 CIFAR·ImageNet32×32 experiments | tuning이 필요 없어졌다 |
| cosine schedule이 해당 실험에서 좋았다 | 논문의 model·budget | 모든 task의 최적 scheduler다 |
| AdamW가 generalization을 개선했다 | 보고된 benchmark 설정 | 모든 model에서 AdamW가 SGD보다 낫다 |

### weight decay는 parameter group에 걸린다

optimizer는 `params`로 전달받은 tensor group에만 update를 적용한다. PyTorch `AdamW`의 `weight_decay`도 group option이다. 그래서 fine-tuning에서 “weight decay를 썼다”는 말만으로는 부족하다.

```python
# 정책 예시다. 어떤 tensor를 제외할지는 model·실험 근거가 필요하다.
optimizer = torch.optim.AdamW(
    [
        {"params": decay_parameters, "weight_decay": 0.01},
        {"params": no_decay_parameters, "weight_decay": 0.0},
    ],
    lr=3e-4,
)
```

이 예는 `bias`나 normalization parameter를 무조건 제외하라는 원 논문 규칙이 아니다. 저자 논문은 전체 parameter group policy를 LLM fine-tuning 관점에서 규정하지 않았다. production config에는 어떤 parameter names·shapes가 어느 group에 들어갔는지, group별 lr·weight decay가 무엇인지 남겨야 한다.

### scheduler와 total update 수를 같이 기록하자

식 (15)처럼 PyTorch AdamW의 shrinkage는 lr에 곱해진다. 같은 `weight_decay=0.01`도 다음에 따라 누적 효과가 다르다.

* batch size와 dataset 크기가 만들어 내는 steps per epoch
* epoch 수와 gradient accumulation(기울기 누적)
* warmup(워밍업)과 cosine·linear scheduler
* resume(재개) 시 optimizer step count와 scheduler state
* parameter group별 learning rate

paper Appendix B.1의 normalized decay는 이 의존성을 다루려 한 한 제안이다. 현재 framework의 `weight_decay` 하나에 자동으로 그 정규화가 들어 있다고 가정하지 않는다.

### 메모리·처리량 비용

AdamW는 parameter마다 보통 first moment와 second moment buffer를 둔다. mixed precision training에서는 master weight, gradient, optimizer state dtype까지 더해 memory가 parameter size보다 몇 배 커질 수 있다. `foreach`·`fused`는 throughput을 높일 수 있지만 PyTorch 문서는 foreach가 tensorlist intermediate로 peak memory를 더 사용할 수 있다고 경고한다.[PyTorch `AdamW` 공식 문서](https://docs.pytorch.org/docs/main/generated/torch.optim.AdamW.html)

따라서 optimizer 선택은 validation metric만이 아니라 VRAM peak, batch size, checkpoint size, resume time, distributed communication까지 함께 본다. AdamW의 decoupled decay가 optimizer state memory를 없애지는 않는다.

## 실패와 운영 기준 — config 숫자 하나만 옮기면 생기는 문제

### 논문 lambda와 framework weight_decay를 혼동하는 실패

| 이름 | 식에서의 의미 | 그대로 복사하면 위험한 이유 |
| --- | --- | --- |
| 논문 식 (4)의 (\lambda_{WD}) | step마다 직접 곱하는 rate | learning rate와 별도 단위의 per-step shrinkage |
| 논문 Algorithm 2의 (\lambda_{WD}) | (\eta_t\lambda\theta)에 들어가는 decay | schedule multiplier와 normalized decay 문맥이 있다 |
| PyTorch `weight_decay` | (\gamma\cdot\texttt{weight\_decay})에 곱해지는 coefficient | per-step rate가 lr에 따라 달라진다 |
| L2 coefficient (\lambda_{L2}) | loss의 (\frac{\lambda}{2}\lVert\theta\rVert^2) 계수 | Adam에서는 direct decay와 동치가 아니다 |

논문 README의 `-weightDecay 0.025`는 Torch code의 normalized schedule과 연결된 example이다. PyTorch experiment에서 같은 `0.025`를 복사하려면 먼저 update 식·scheduler·step budget이 같은지 확인해야 한다.

### checkpoint resume에서 optimizer state는 model weight와 별개다

model state dict(모델 상태 사전)만 저장하고 AdamW state를 잃으면 (m), (v), step count, scheduler state가 초기화될 수 있다. 그 다음 update는 같은 (\theta)에서 시작해도 이전 training trajectory와 다르다. 재현 로그에는 최소한 다음을 남긴다.

1. framework와 optimizer implementation version
2. AdamW인지 Adam with L2인지, optimizer parameter group 전체
3. lr·betas·eps·weight_decay·amsgrad·fused·foreach 설정
4. scheduler 종류, current step, warmup·total steps
5. gradient clipping, loss scaling, accumulation, batch size
6. model·optimizer·scheduler checkpoint를 저장·복원한 순서

PyTorch documentation은 `LRScheduler`를 초기화한 뒤 optimizer `load_state_dict`를 호출하지 않으면 loaded learning rate가 덮어써질 수 있다고 경고한다.[PyTorch `AdamW` API 경고](https://docs.pytorch.org/docs/main/generated/torch.optim.AdamW.html) 이 문제는 AdamW 논문 알고리즘이 아니라 현재 framework checkpoint contract다.

### direct weight decay는 data quality 문제를 고치지 않는다

AdamW가 해결하지 않는 것들도 분명하다.

| 문제 | AdamW만으로 해결되지 않는 이유 |
| --- | --- |
| label 오류·data leakage | optimizer가 학습 data의 잘못된 정답을 판별하지 못한다 |
| hallucination(환각) | LLM generation·retrieval·alignment 문제다 |
| exploding loss | learning rate, initialization, numerical overflow, data 문제가 함께 원인일 수 있다 |
| validation regression | model capacity·data split·schedule·parameter groups를 측정해야 한다 |
| GPU OOM(Out Of Memory, 메모리 부족) | AdamW state가 오히려 optimizer memory를 요구한다 |

## 대안과 선택 기준 — Adam, AdamW, SGD를 이름으로 고르지 말자

| 선택지 | regularization 경로 | 장점 | 주의점 |
| --- | --- | --- |
| SGD + L2 | gradient에 (\lambda\theta)를 더함 | 계수 대응 시 weight decay와 같은 update | lr가 바뀌면 mapping도 바뀐다 |
| SGDW | parameter를 직접 shrink | decay와 momentum gradient를 분리 | scheduler·step budget은 여전히 영향 |
| Adam + L2 | data·L2 gradient를 m·v에 함께 저장 | 일부 기존 code와 checkpoint의 의미를 유지 | adaptive preconditioner가 regularizer도 변형 |
| AdamW | data gradient만 m·v에 저장, direct shrink | Adam의 adaptive path와 decay를 분리 | lr·group·scheduler tuning은 필요 |
| RMSprop·AdaGrad 계열 | optimizer마다 adaptive rule이 다름 | task별 장점 | L2와 direct decay 동치 여부를 식으로 확인해야 함 |

원 논문 Proposition 2는 general adaptive optimizer가 (M_t\ne kI)라는 non-scalar preconditioner를 쓰면 L2와 direct decay가 일반적으로 동치가 아니라고 설명한다. 따라서 optimizer 이름만 `Adam`에서 다른 적응형 optimizer로 바꿔도 자동으로 동일하다고 가정하지 않는다.[원 논문 Appendix A Proposition 2](https://arxiv.org/pdf/1711.05101)

## 흔한 오해와 최초 질문에 대한 답

### AdamW는 Adam보다 weight를 더 많이 줄이나

항상 그렇지 않다. data gradient, parameter 값, m·v history, lr schedule, group 설정에 따라 달라진다. AdamW의 정의상 차이는 “얼마나 많이”가 아니라 **direct shrinkage가 m·v adaptive path를 통과하지 않는다**는 것이다.

### AdamW의 weight decay는 learning rate와 독립인가

adaptive normalization과는 분리됐지만 per-step shrinkage는 PyTorch에서 (\mathrm{lr}\times\mathrm{weight\_decay})다. learning rate schedule이 바뀌면 decay trajectory도 바뀐다.

### L2 penalty를 loss에 더하면 AdamW와 같은가

아니다. Adam에서는 L2 gradient가 moment와 variance에 들어간다. 위 직접 실행처럼 parameter 값이 다른 coordinate가 같은 상대 비율로 줄지 않을 수 있다.

### 논문의 0.025를 내 LLM PyTorch config에 넣으면 되나

아니다. 논문의 normalized decay, batch size, total epochs, schedule, Torch code와 PyTorch AdamW의 parameterization이 다를 수 있다. 일단 update 식과 total steps를 확인하고 validation·weight norm·loss·throughput을 측정하며 tune해야 한다.

### AdamW를 쓰면 generalization이 보장되나

아니다. 논문은 특정 image classification experiments에서 개선을 보였다. optimizer는 data, architecture, schedule, regularization, evaluation design의 일부다.

### 한 문장 답

> **AdamW는 L2 penalty를 Adam gradient에 섞는 대신, parameter를 별도 비율로 직접 줄여 weight decay가 Adam의 moment·variance history와 adaptive denominator에 왜곡되지 않게 만든 optimizer다. 하지만 decay의 실제 누적량은 lr·scheduler·step 수·parameter group에 여전히 의존한다.**

## 출처 및 검증 경로

### 1차 자료

1. Ilya Loshchilov, Frank Hutter. [Decoupled Weight Decay Regularization](https://arxiv.org/abs/1711.05101), ICLR 2019. 이 글은 본문 1절부터 5절, Algorithm 1·2, Proposition 1·2·3, Figure 1부터 4, Appendix A·B.1을 직접 확인했다.
2. loshchil. [AdamW-and-SGDW 공식 저장소](https://github.com/loshchil/AdamW-and-SGDW/tree/0ae5185b3c655e45cc249adaca4457cd881874cc), commit `0ae5185b3c655e45cc249adaca4457cd881874cc`. [`UPDATETORCHFILES/adam.lua`](https://github.com/loshchil/AdamW-and-SGDW/blob/0ae5185b3c655e45cc249adaca4457cd881874cc/UPDATETORCHFILES/adam.lua)와 [`train.lua`](https://github.com/loshchil/AdamW-and-SGDW/blob/0ae5185b3c655e45cc249adaca4457cd881874cc/train.lua)의 L2·direct decay 분기, normalized schedule 처리를 직접 확인했다.
3. PyTorch. [AdamW source](https://github.com/pytorch/pytorch/blob/526b9cee8c9ad8da312bf33ea2c3f308cbfe8876/torch/optim/adamw.py), commit `526b9cee8c9ad8da312bf33ea2c3f308cbfe8876`. `decoupled_weight_decay=True` 전달 경로를 확인했다.
4. PyTorch. [torch.optim.AdamW current API](https://docs.pytorch.org/docs/main/generated/torch.optim.AdamW.html). 현재 default·algorithm·fused/foreach·scheduler load 경계의 공식 문서다. 이 API는 2019 논문의 Torch implementation과 version·parameterization이 다를 수 있다.

### 직접 실행

* Python `3.9.6`, 표준 라이브러리만 사용했다. L2 derivative의 finite difference, SGD L2와 direct decay의 계수 대응, zero data gradient에서 Adam L2·AdamW 상대 shrinkage, moment buffer 차이, AdamW 5-step closed form을 위 코드로 실행했고 본문 출력은 실제 결과다.
* 이 검증은 PyTorch kernel, CUDA fused optimizer, mixed precision, distributed optimizer, CIFAR-10·ImageNet32×32 training, 논문 Figure의 test error 재현이 아니다. 그런 성능 수치는 원 논문의 명시된 experiment 조건에서 나온 외부 결과로 인용했다.

### 이 글의 해석

* “moment 오염”은 L2 regularizer gradient가 data gradient와 합쳐져 m·v state에 저장된다는 계산적 의미로 썼다. data 자체가 오염됐다는 뜻이 아니다.
* `bias`·normalization parameter의 no-decay group 예시는 production policy가 명시적으로 필요함을 보이는 예시다. 원 논문이 모든 LLM에 적용할 고정 exclusion rule을 제시한 것은 아니다.
