주간 리뷰 (Week 8): LoRALinear와 loss mask를 백지에서 다시 쓰면 내가 모르는 곳이 보인다
이번 주에 배운 SFT와 LoRA를 눈으로 읽는 대신 손으로 다시 구현하며 점검하는 날이다. LoRALinear와 loss mask 함수를 아무것도 보지 않고 다시 쓰고, tensor shape 추론·Linear Layer·Loss 구현 같은 딥러닝 코딩테스트 유형을 시간 제한 없이 연습한다. 막히는 곳이 바로 내가 아직 이해하지 못한 곳이므로 그 지점을
By 송민성
오늘의 목표
이번 주에 배운 SFT(지도 미세조정)와 LoRA를 "읽어서 아는 것"에서 "빈 화면에서 직접 쓸 수 있는 것"으로 바꾼다. 동시에 딥러닝 코딩테스트 형식(shape 추론, Linear, Loss 구현)에 익숙해지기 시작한다. 오늘은 새 개념을 배우지 않는다. 이번 주 내용을 꺼내 쓰는 연습이다.
개념과 직관
오늘은 새로 배우는 개념이 없으므로, 이번 주 핵심 두 가지를 짧게 복기한다. 복기하면서 "이걸 설명 없이 코드로 쓸 수 있나?"를 스스로 물어보자.
1. LoRA (Low-Rank Adaptation, 큰 가중치 행렬은 얼려 두고 작은 보조 행렬 두 개만 학습하는 방법)
원래 Linear 층은 y = W x 를 계산한다. W가 (out, in) 크기의 큰 행렬이라고 하자. 전체를 학습하면 파라미터가 너무 많다. LoRA는 W를 고정(freeze, 학습에서 제외)하고, 작은 행렬 두 개 A, B를 더한다.
y = W x + (alpha / r) * B (A x)
A: (r, in) B: (out, r) r은 작은 수 (예: 8)비유하자면 W는 인쇄된 두꺼운 교과서이고, A·B는 그 위에 붙이는 얇은 포스트잇이다. 교과서는 건드리지 않고 포스트잇만 바꿔 쓴다. r이 작을수록 포스트잇이 얇다.
초기화 규칙도 복기하자. 보통 B는 0으로, A는 작은 랜덤 값으로 시작한다. 그러면 학습 시작 시점에 B(Ax) = 0이라서 모델 출력이 원래 모델과 정확히 같다. 출발점이 망가지지 않는 것이다.
2. Loss mask (손실 마스크, 정답으로 채점할 토큰만 골라 손실에 반영하는 장치)
SFT 데이터는 "질문(prompt) + 답변(response)"을 이어 붙인 토큰열이다. 우리는 모델이 답변을 잘 만들도록 가르치고 싶지, 질문을 따라 말하도록 가르치고 싶지는 않다. 그래서 질문 부분의 손실은 계산에서 뺀다.
PyTorch의 cross_entropy는 label이 -100인 위치를 기본적으로 무시한다(ignore_index=-100). 그래서 질문 구간의 label을 -100으로 바꿔 두는 방식이 흔하다.
그리고 언어 모델은 "다음 토큰 맞히기"를 하므로, 위치 t의 출력은 위치 t+1의 토큰과 비교한다. 이 한 칸 밀기(shift)를 빼먹기 쉽다.
코드로 직접 해보기
규칙: 이번 주 노트, 이전 코드, 검색을 모두 닫고 시작한다. 막히면 10분은 혼자 버틴 뒤에만 확인한다. 확인했다면 어디서 막혔는지 적어 둔다.
1단계. LoRALinear 백지 작성
요구사항: (1) 기존 nn.Linear를 받아 W는 얼린다. (2) A, B를 만든다. (3) forward에서 W x + scale * B(A x)를 계산한다.
import math
import torch
import torch.nn as nn
class LoRALinear(nn.Module):
def __init__(self, base: nn.Linear, r: int = 8, alpha: int = 16):
super().__init__()
self.base = base
for p in self.base.parameters():
p.requires_grad = False # 원래 가중치는 얼린다
self.r = r
self.scale = alpha / r
self.A = nn.Parameter(torch.empty(r, base.in_features))
self.B = nn.Parameter(torch.zeros(base.out_features, r))
nn.init.kaiming_uniform_(self.A, a=math.sqrt(5)) # A는 랜덤, B는 0
def forward(self, x):
# x: (..., in) -> (..., out)
lora = (x @ self.A.T) @ self.B.T # (..., r) -> (..., out)
return self.base(x) + self.scale * lora작성 후 반드시 아래 세 가지를 직접 확인한다.
base = nn.Linear(16, 32)
m = LoRALinear(base, r=4, alpha=8)
x = torch.randn(2, 5, 16)
# 확인 1: 초기에는 원래 모델과 출력이 같아야 한다
print(torch.allclose(m(x), base(x))) # True
# 확인 2: 학습되는 파라미터는 A, B뿐이어야 한다
print([n for n, p in m.named_parameters() if p.requires_grad]) # ['A', 'B']
# 확인 3: 파라미터 개수 비교
trainable = sum(p.numel() for p in m.parameters() if p.requires_grad)
total = sum(p.numel() for p in m.parameters())
print(trainable, total) # 4*16 + 32*4 = 192, 전체는 192 + 16*32 + 32 = 7362단계. loss mask 함수 백지 작성
요구사항: prompt 길이만큼의 label을 -100으로 가리고, 한 칸 밀어서 cross entropy를 계산한다.
import torch
import torch.nn.functional as F
def build_labels(input_ids, prompt_len):
# input_ids: (B, T), prompt_len: 정수 (모든 샘플이 같다고 가정)
labels = input_ids.clone()
labels[:, :prompt_len] = -100 # 질문 구간은 채점 제외
return labels
def sft_loss(logits, labels):
# logits: (B, T, V), labels: (B, T)
shift_logits = logits[:, :-1, :] # 위치 t의 출력 ...
shift_labels = labels[:, 1:] # ... 은 t+1 토큰과 비교
return F.cross_entropy(
shift_logits.reshape(-1, shift_logits.size(-1)),
shift_labels.reshape(-1),
ignore_index=-100,
)간단한 검증:
B, T, V = 2, 6, 10
ids = torch.randint(0, V, (B, T))
logits = torch.randn(B, T, V)
labels = build_labels(ids, prompt_len=3)
print(sft_loss(logits, labels))
# 전부 가리면 어떻게 되나? (nan이 나올 수 있다)
all_masked = torch.full((B, T), -100)
print(sft_loss(logits, all_masked))마지막 줄은 일부러 해 본다. 채점할 토큰이 하나도 없으면 평균을 낼 대상이 0개가 되어 nan이 나온다. 실제 학습 데이터에서 너무 긴 질문이 답변을 잘라 먹으면 이런 샘플이 생길 수 있다.
3단계. 비교와 기록
작성한 코드를 이번 주 노트의 코드와 비교한다. 줄 단위로 다른 곳을 표로 적는다.
| 항목 | 내가 쓴 것 | 정답/노트 | 왜 달랐나 |
|---|---|---|---|
| 예: B 초기화 | 랜덤 | 0 | 초기 출력이 원본과 같아야 하는 이유를 놓쳤다 |
오늘의 실험
제한시간 없이, 딥러닝 코딩테스트 유형 세 가지를 연습한다. 속도가 아니라 "정확히 설명하며 푸는 것"이 목표다. 각 유형마다 직접 코드를 돌려서 예측과 실제를 비교한다.
유형 1. tensor shape 추론
코드를 실행하기 전에 종이에 shape을 먼저 적고, 실행해서 맞는지 확인한다.
import torch
x = torch.randn(4, 10, 8) # (B, T, D)
w = torch.randn(8, 3)
print((x @ w).shape) # 예측: ?
print(x.transpose(1, 2).shape) # 예측: ?
print(x.reshape(-1, 8).shape) # 예측: ?
print(x.sum(dim=1).shape) # 예측: ?
print(x.mean(dim=-1, keepdim=True).shape) # 예측: ?바꿔 볼 것: sum(dim=1)을 dim=0, dim=2로 바꾸고, keepdim=True를 켜고 끄면서 shape이 어떻게 변하는지 본다. 기록은 "예측 / 실제 / 틀렸다면 이유" 세 칸으로 한다.
유형 2. Linear Layer 직접 구현
nn.Linear를 쓰지 않고 y = x W^T + b를 구현한다.
class MyLinear(nn.Module):
def __init__(self, in_f, out_f):
super().__init__()
self.W = nn.Parameter(torch.randn(out_f, in_f) * 0.02)
self.b = nn.Parameter(torch.zeros(out_f))
def forward(self, x):
return x @ self.W.T + self.b
# 검증: nn.Linear에 같은 가중치를 복사해서 출력을 비교
ref = nn.Linear(16, 32)
mine = MyLinear(16, 32)
with torch.no_grad():
mine.W.copy_(ref.weight)
mine.b.copy_(ref.bias)
x = torch.randn(3, 16)
print(torch.allclose(mine(x), ref(x), atol=1e-6))바꿔 볼 것: 입력을 (3, 16)에서 (2, 5, 16)으로 바꿔도 동작하는지 확인한다. 또 bias를 빼면 무엇이 달라지는지 본다.
유형 3. Loss 구현
F.cross_entropy를 쓰지 않고 직접 만든다. 공식은 정답 클래스의 확률에 -log를 취한 값의 평균이다.
loss = - mean( log_softmax(logits)[정답 위치] )def my_cross_entropy(logits, targets):
# logits: (N, V), targets: (N,)
log_probs = torch.log_softmax(logits, dim=-1)
picked = log_probs[torch.arange(len(targets)), targets]
return -picked.mean()
logits = torch.randn(5, 10)
targets = torch.randint(0, 10, (5,))
print(my_cross_entropy(logits, targets), F.cross_entropy(logits, targets))바꿔 볼 것: ignore_index=-100을 직접 지원하도록 확장해 본다. 힌트는 "무시할 위치를 걸러 낸 뒤 남은 개수로 평균을 낸다"이다. 그다음 PyTorch 결과와 비교한다.
기록 방법 오늘 푼 문제마다 한 줄씩 남긴다: 문제 / 처음 막힌 지점 / 원인 / 다음에 같은 실수를 안 하는 방법. 이 기록이 내일부터 쓸 "나만의 오답 노트"가 된다.
왜 이걸 배우나 (LLM / Post-training 연결)
LoRA와 loss mask는 실제 LLM 후처리 학습(post-training)에서 거의 매번 마주치는 부품이다. 어디서 shape이 어긋났는지, 어떤 토큰이 손실에 들어갔는지를 코드 수준에서 바로 짚을 수 있어야 LLM Research Engineer로서 학습 실험을 디버깅할 수 있다. 코딩테스트 형식의 연습은 그 기본기를 짧은 시간에 점검하는 도구이기도 하다.
초보자가 막히는 지점
- A, B의 shape과 곱하는 순서가 헷갈린다.
A: (r, in),B: (out, r)이다. 입력은 A를 먼저 지나 r차원으로 줄었다가 B에서 out으로 돌아온다. 종이에(..., in) -> (..., r) -> (..., out)을 먼저 적고 코드를 쓰자. - `.T`를 빠뜨리거나 두 번 붙인다.
nn.Linear의 weight는 (out, in)이라x @ W.T가 맞다. shape 에러가 나면print(x.shape, W.shape)부터 찍는다. - B를 랜덤으로 초기화한다. 에러는 나지 않지만 초기 출력이 원본과 달라진다.
allclose검증이 이런 조용한 버그를 잡아 준다. - freeze를 잊는다.
requires_grad = False를 빼면 W까지 학습되어 LoRA의 의미가 없어진다. 학습 가능 파라미터 목록을 꼭 출력해서 확인한다. - shift를 빼먹거나 두 번 한다. logits는
[:, :-1], labels는[:, 1:]로 한 번만 민다. 이미 밀어서 만든 labels를 또 밀지 않았는지 확인한다. - 전부 마스킹되어 loss가 nan이 된다. 채점 대상 토큰이 0개인 샘플이 섞이면 생긴다. 먼저
(labels != -100).sum()을 찍어서 확인한다. - "보고 베끼면 쓴 것 같은" 착각. 노트를 열어 두고 따라 치면 이해한 느낌만 남는다. 오늘은 반드시 닫고 쓰고, 막힌 곳을 오답으로 기록하는 쪽이 훨씬 남는 게 많다.
알고리즘 (병행 1시간)
이번 주에 틀린 문제를 다시 푼다. 새 문제를 풀기보다 틀렸던 문제를 풀이 기억 없이 처음부터 다시 푸는 것이 핵심이다.
풀 때마다 다음을 기록한다.
- 접근: 어떤 아이디어로 풀었는가. 처음 접근과 달라졌다면 무엇이 달라졌는가.
- 시간복잡도: 입력 크기를 N이라 할 때 대략 어느 정도 걸리는지, 그리고 왜 그런지 한 줄로.
- 틀린 이유: 개념 부족, 경계 조건(빈 입력·크기 1 등) 누락, 구현 실수, 시간 초과 중 무엇인가. 같은 이유가 반복되면 따로 모아 둔다.
다시 풀어도 막히면 풀이를 보되, 덮고 다시 처음부터 직접 써 본다. 그리고 다음 주에 한 번 더 풀 문제 목록에 올린다.