신경망의 뉴런 하나가 개념 하나에 대응하지 않는다는 것은 해석가능성 연구의 오래된 골칫거리다. Anthropic의 「Toy Models of Superposition」(Elhage 외, 2022, arXiv 2209.10652)은 그 이유를 아주 작은 모델에서 보였다. 특징이 드물게 등장하면, 모델은 차원 수보다 많은 특징을 겹쳐서 저장한다.
이 글은 그 주장 하나를 CPU에서 다시 돌린 기록이다. 개념 설명은 AI 설명가능성과 XAI가 맡으므로 여기서는 반복하지 않고, 직접 학습시킨 가중치에서 나온 숫자만 다룬다.
재현하려는 주장 하나
논문의 여러 주장 중 하나만 잡는다.
은닉 차원보다 특징이 많을 때, 특징의 희소성이 낮으면 모델은 가장 중요한 특징 몇 개만 직교 기저에 담고 나머지를 버린다. 희소성이 올라가면 버렸던 특징들이 서로 간섭을 감수하면서 같은 공간에 함께 들어온다.
확인할 것은 둘이다. 몇 개가 살아남는가와 서로 얼마나 간섭하는가.
실험 설계
논문의 가장 단순한 설정을 그대로 쓴다. 특징 20개를 은닉 5차원으로 눌렀다가 되살리는 오토인코더인데, 인코더와 디코더가 같은 행렬 $W$를 공유한다.
$W$는 $5 \times 20$이다. 입력 $x$의 각 성분은 확률 $1-S$로 활성되고 활성되면 $U(0,1)$ 값을 갖는다. $S$가 희소성이다. $S = 0$이면 20개가 항상 다 켜져 있고, $S = 0.99$면 한 번에 평균 0.2개만 켜진다.
손실은 중요도로 가중한 MSE다. $i$번 특징의 중요도는 $0.9^i$ — 앞쪽 특징일수록 중요하다. 이 가중치가 있어야 "모델이 무엇을 먼저 포기하는가"를 볼 수 있다.
측정하는 두 값은 이렇다.
- 표현된 특징 수 — $W$의 $i$번째 열 노름 $\lVert W_i \rVert$가 0.1을 넘는 특징의 개수. 열 노름이 0에 가까우면 그 특징은 버려진 것이다.
- 최대 비대각 — $W^\top W$에서 대각을 뺀 성분의 절댓값 최대치. 이것이 0이면 특징들이 서로 직교해 간섭이 없다는 뜻이고, 커질수록 서로를 침범한다.
두 번째 값이 더 중요하다. 첫 번째는 "0.1"이라는 임의의 문턱에 의존하지만 비대각 성분은 문턱 없이 그대로 읽히기 때문이다.
재현 블록
python3 -m venv venv && . venv/bin/activate
pip install torch
superposition.py:
import time, torch
N_FEAT, N_HIDDEN, STEPS, BATCH = 20, 5, 4000, 1024
SPARSITY = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.7, 0.9, 0.99]
SEEDS = [0, 1, 2, 3, 4]
def run(sparsity, seed):
torch.manual_seed(seed)
imp = torch.tensor([0.9 ** i for i in range(N_FEAT)])
W = torch.nn.Parameter(torch.randn(N_HIDDEN, N_FEAT) * 0.1)
b = torch.nn.Parameter(torch.zeros(N_FEAT))
opt = torch.optim.Adam([W, b], lr=1e-2)
for _ in range(STEPS):
x = torch.rand(BATCH, N_FEAT) * (torch.rand(BATCH, N_FEAT) > sparsity)
out = torch.relu(x @ (W.T @ W) + b)
loss = (imp * (x - out) ** 2).mean()
opt.zero_grad(); loss.backward(); opt.step()
Wd = W.detach()
G = Wd.T @ Wd
off = (G - torch.diag(torch.diag(G))).abs().max().item()
norms = Wd.norm(dim=0)
return int((norms > 0.1).sum()), off, loss.item(), norms
torch.set_num_threads(4)
print(f"torch {torch.__version__} / 특징 {N_FEAT}개 -> 은닉 {N_HIDDEN}차원 / "
f"중요도 0.9^i / Adam lr=1e-2 {STEPS}스텝 / 배치 {BATCH} / 시드 {SEEDS}")
print(f"\n{'희소성':>6} {'활성%':>6} {'표현된 특징수':>13} {'범위':>7} "
f"{'최대 비대각':>17} {'손실':>9} {'초':>5}")
for s in SPARSITY:
t0 = time.time()
r = [run(s, sd) for sd in SEEDS]
n = torch.tensor([x[0] for x in r], dtype=torch.float)
o = torch.tensor([x[1] for x in r])
l = torch.tensor([x[2] for x in r])
print(f"{s:6.2f} {100*(1-s):5.1f}% {n.mean():7.1f} +- {n.std():4.2f} "
f"{int(n.min()):3d}-{int(n.max()):<3d} {o.mean():9.4f} +- {o.std():.4f} "
f"{l.mean():9.5f} {time.time()-t0:5.1f}")
print("\n시드 0에서 특징별 열 노름 (중요도 내림차순, 0.1 미만이면 버려진 특징)")
for s in (0.0, 0.3, 0.9):
norms = run(s, 0)[3]
print(f" 희소성 {s:4.2f}: " + " ".join(f"{v:.2f}" for v in norms.tolist()))
python superposition.py
실제 출력
torch 2.13.0+cu130 / 특징 20개 -> 은닉 5차원 / 중요도 0.9^i / Adam lr=1e-2 4000스텝 / 배치 1024 / 시드 [0, 1, 2, 3, 4]
희소성 활성% 표현된 특징수 범위 최대 비대각 손실 초
0.00 100.0% 5.0 +- 0.00 5-5 0.0227 +- 0.0037 0.01963 20.8
0.10 90.0% 5.0 +- 0.00 5-5 0.0325 +- 0.0118 0.02295 19.4
0.20 80.0% 5.8 +- 0.45 5-6 0.1485 +- 0.0704 0.02514 19.4
0.30 70.0% 6.0 +- 0.00 6-6 0.1820 +- 0.0135 0.02602 20.7
0.40 60.0% 6.6 +- 0.55 6-7 0.5419 +- 0.0673 0.02568 20.0
0.50 50.0% 8.0 +- 0.00 8-8 0.9338 +- 0.0169 0.02336 19.3
0.70 30.0% 10.0 +- 0.00 10-10 1.0247 +- 0.0030 0.01415 19.6
0.90 10.0% 12.2 +- 0.45 12-13 1.0324 +- 0.0486 0.00392 20.3
0.99 1.0% 18.8 +- 1.10 17-20 1.0324 +- 0.0400 0.00015 18.8
시드 0에서 특징별 열 노름 (중요도 내림차순, 0.1 미만이면 버려진 특징)
희소성 0.00: 1.00 1.00 1.00 1.00 1.00 0.02 0.02 0.02 0.01 0.02 0.02 0.02 0.02 0.01 0.01 0.01 0.01 0.01 0.01 0.01
희소성 0.30: 1.01 1.00 1.01 1.01 1.00 0.23 0.06 0.04 0.03 0.02 0.03 0.01 0.01 0.01 0.01 0.03 0.02 0.01 0.01 0.01
희소성 0.90: 1.10 1.01 1.10 1.09 1.00 1.10 1.07 1.06 1.04 1.04 1.04 0.99 0.03 0.01 0.04 0.02 0.03 0.03 0.02 0.02
전체 3분 11초. 설정 하나가 시드 5개로 약 19~21초다.
발행 전에 이 스크립트를 새로 만든 빈 가상환경에서 한 번 더 돌려 위 출력과
대조했다. 맨 오른쪽 초 열을 뺀 모든 값이 글자 그대로 재현됐다 — 특징
개수와 표준편차, 최대 비대각의 소수점 넷째 자리, 손실의 다섯째 자리까지
같다. 시드를 고정했고 torch.set_num_threads(4)로 스레드 수까지 묶었기
때문이다.
초 열만 실행마다 다르다. 대부분 ±1초 안쪽이지만 다른 작업이 같은 CPU를
쓰는 동안 잰 행 하나는 20.7초가 30.0초로 나왔다. 같은 계산인데 1.4배가
된다는 것 자체가 절대 시간을 결론으로 쓰면 안 되는 이유다. 이 글이 결론에
쓴 것은 비율과 순서뿐이다.
읽는 법
희소성 0에서 정확히 5개다. 표준편차 0.00, 다섯 시드가 전부 5. 은닉 차원이 5니까 딱 그만큼만 담고 나머지 15개는 버렸다는 뜻이다. 최대 비대각이 0.0227이라는 것은 이 다섯이 거의 완벽하게 직교한다는 뜻이다 — 중첩이 없다.
희소성 0.99에서 18.8개다. 20개 중 거의 전부가 5차원 안에 들어와 있다. 최대 비대각은 1.0324로, 서로 다른 두 특징의 방향 벡터가 자기 자신만큼 겹친다.
그 사이가 이 실험의 본론이다.
꺾이는 지점
희소성 0.1까지는 공짜다. 0.2에서 손해가 시작된다.
숫자로 적으면 이렇다. 활성 비율 90%까지는 표현된 특징이 정확히 5개로 묶여 있고(표준편차 0.00) 간섭은 0.0325에 머문다. 활성 80%로 한 칸 내려가는 순간 여섯 번째 특징이 들어오고 최대 비대각이 0.0325에서 0.1485로 4.6배 뛴다. 모델이 "겹쳐도 이득"이라고 판단을 바꾸는 자리가 여기다.
두 번째 도약은 활성 60%와 50% 사이에 있다. 0.5419에서 0.9338로 오르면서 간섭이 1에 육박한다. 이 지점을 넘으면 더 이상 "약간 겹친다"가 아니라 특징끼리 방향을 공유하는 상태가 된다. 실제로 0.7 이후로는 비대각이 1.03 근처에서 포화하고, 늘어나는 것은 간섭의 세기가 아니라 겹쳐 넣은 특징의 개수뿐이다.
왜 하필 거기서 꺾이는가
전이가 일어나는 자리는 두 값의 크기 비교로 설명된다.
여섯 번째 특징을 5차원 안에 밀어 넣으면 얻는 것은 그 특징을 표현하게 된다는 것이고 그 가치는 중요도 $0.9^5 = 0.590$이다. 잃는 것은 이미 자리를 잡은 특징들과의 간섭인데, 간섭은 두 특징이 동시에 켜졌을 때만 손해가 된다. 특징 하나가 켜질 확률이 $1-S$이므로 둘이 함께 켜질 확률은 $(1-S)^2$이다.
$S$가 오르면 이 비용이 제곱으로 줄어든다. 이득은 그대로인데 비용만 줄어드니, 어느 지점에서 부등호가 뒤집히고 그때 여섯 번째가 들어온다. 이것이 사실이라면 중요도 감쇠 $r$을 바꿔 이득 쪽을 흔들면 꺾이는 자리가 움직여야 한다. $r$이 작으면(가파른 감쇠) 여섯 번째의 가치가 낮아 더 희소해질 때까지 안 들어오고, $r$이 1에 가까우면 모든 특징이 비슷하게 중요해 일찍 들어와야 한다.
decay.py:
import torch
N_FEAT, N_HIDDEN, STEPS, BATCH = 20, 5, 4000, 1024
SEEDS = [0, 1, 2]
def run(sparsity, seed, decay):
torch.manual_seed(seed)
imp = torch.tensor([decay ** i for i in range(N_FEAT)])
W = torch.nn.Parameter(torch.randn(N_HIDDEN, N_FEAT) * 0.1)
b = torch.nn.Parameter(torch.zeros(N_FEAT))
opt = torch.optim.Adam([W, b], lr=1e-2)
for _ in range(STEPS):
x = torch.rand(BATCH, N_FEAT) * (torch.rand(BATCH, N_FEAT) > sparsity)
out = torch.relu(x @ (W.T @ W) + b)
loss = (imp * (x - out) ** 2).mean()
opt.zero_grad(); loss.backward(); opt.step()
Wd = W.detach()
G = Wd.T @ Wd
return int((Wd.norm(dim=0) > 0.1).sum()), (G - torch.diag(torch.diag(G))).abs().max().item()
torch.set_num_threads(4)
print("중요도 감쇠 r을 바꾸면 중첩이 시작되는 희소성이 움직이는가")
print(f"시드 {SEEDS} 평균 / 표현된 특징수 (최대 비대각)\n")
print(f"{'희소성':>6} " + "".join(f"{'r=' + str(r):>22}" for r in (0.7, 0.9, 0.99)))
for s in (0.0, 0.1, 0.2, 0.3, 0.5):
cells = []
for r in (0.7, 0.9, 0.99):
res = [run(s, sd, r) for sd in SEEDS]
n = sum(x[0] for x in res) / len(res)
o = sum(x[1] for x in res) / len(res)
cells.append(f"{n:8.1f}개 ({o:6.4f})")
print(f"{s:6.2f} " + "".join(f"{c:>22}" for c in cells))
print("\n6번째 특징의 상대 중요도 r^5: " +
", ".join(f"r={r} -> {r**5:.3f}" for r in (0.7, 0.9, 0.99)))
출력:
중요도 감쇠 r을 바꾸면 중첩이 시작되는 희소성이 움직이는가
시드 [0, 1, 2] 평균 / 표현된 특징수 (최대 비대각)
희소성 r=0.7 r=0.9 r=0.99
0.00 5.0개 (0.0071) 5.0개 (0.0214) 8.0개 (0.2489)
0.10 5.0개 (0.0105) 5.0개 (0.0265) 8.0개 (0.2521)
0.20 5.0개 (0.0167) 6.0개 (0.1655) 9.7개 (0.2476)
0.30 5.0개 (0.0129) 6.0개 (0.1872) 9.7개 (0.3864)
0.50 6.0개 (0.9175) 8.0개 (0.9368) 10.0개 (0.9495)
6번째 특징의 상대 중요도 r^5: r=0.7 -> 0.168, r=0.9 -> 0.590, r=0.99 -> 0.951
예측대로 움직였다. 세 열을 가로로 읽으면 꺾이는 자리가 왼쪽으로 밀린다.
- $r = 0.7$ — 여섯 번째의 가치가 0.168뿐이라 희소성 0.3까지도 정확히 5개를 지킨다. 0.5에 가서야 여섯 번째가 들어온다. 꺾이는 자리가 오른쪽으로 밀렸다.
- $r = 0.9$ — 0.2에서 6개. 앞 실험과 같은 자리다.
- $r = 0.99$ — 희소성 0에서 이미 8개다. 특징이 하나도 드물지 않은데도 모델이 중첩을 택했다.
마지막 줄이 이 실험에서 가장 강한 결과다. 중첩은 "희소하면 생기는 현상"이 아니라 이득과 간섭 비용의 대소 관계가 뒤집히면 생기는 현상이고, 희소성은 비용 쪽을 낮추는 여러 방법 중 하나일 뿐이다. 모든 특징이 거의 똑같이 중요하면($r = 0.99$) 어느 것도 버릴 수 없어서, 밀도가 100%여도 겹쳐서라도 담는 쪽을 고른다. $r = 0.99$ 열의 비대각이 희소성 0에서 이미 0.2489로 $r = 0.9$ 열의 0.0214보다 열 배 이상 큰 것이 그 대가다.
이 두 번째 스크립트도 새 가상환경에서 다시 돌려 위 출력과 대조했고, 시간 열이 없으므로 모든 값이 글자 그대로 일치했다. 실행 2분 56초.
무엇을 먼저 포기하는가
열 노름 표가 중요도 가중치의 역할을 그대로 보여 준다.
희소성 0.00에서 살아남은 다섯은 앞에서부터 다섯 개다 — 노름 1.00이 다섯 개 연속으로 붙고 여섯 번째부터 0.02로 떨어진다. 중요도 $0.9^i$가 내림차순이니 모델은 가장 중요한 것부터 자리를 준다.
희소성 0.30의 여섯 번째 값 0.23이 재미있다. 1.00도 0.02도 아닌 어중간한 값이다. 여섯 번째 특징이 자리를 반쯤 얻은 상태이고, 이것이 전이가 진행 중이라는 증거다. 희소성 0.90에서는 앞에서부터 열두 개가 1.0 근처를 받고 열세 번째부터 버려진다.
손실 열도 같이 읽어야 한다. 희소성이 오를수록 손실이 0.01963에서 0.00015로 줄어드는데, 이건 모델이 좋아져서가 아니라 입력의 대부분이 0이라 맞히기가 쉬워져서다. 희소한 입력에서 낮은 손실은 성능 지표로 쓸 수 없다.
이 결과가 해석가능성에 뜻하는 것
두 실험을 합치면 이렇게 정리된다. 뉴런 하나가 개념 하나에 대응하지 않는 것은 모델이 이상해서가 아니라 그것이 손실을 낮추는 방향이기 때문이다. $r = 0.99$ 열에서 보았듯 특징이 드물지 않아도, 담아야 할 것이 담을 자리보다 많고 어느 것도 버리기 아까우면 모델은 겹쳐서라도 담는다.
그래서 "5번 뉴런이 무엇을 뜻하는가"를 묻는 방식의 해석은 잘 안 된다. 희소성 0.9에서 5차원 안에 12개가 들어가 있으니, 축 하나를 떼어 보면 12개 중 여럿이 섞여 나온다. 최근 해석가능성 연구가 뉴런을 직접 읽는 대신 원래 차원보다 훨씬 넓은 공간으로 다시 펼쳐서 특징을 분리하려는 것도 이 때문이다. 겹쳐 넣은 것을 되돌리려면 자리를 넉넉히 줘야 한다는, 이 장난감 모델이 보여 주는 산수 그대로다. 이 방향의 지형은 AI 설명가능성과 XAI에 정리돼 있다.
다만 여기서 조심할 것이 있다. 이 실험이 보인 것은 그런 압력이 존재한다는 것이지 실제 언어 모델이 정확히 이 방식으로 겹쳐 넣는다는 것이 아니다. 아래 한계 절이 그 선을 긋는다.
계획과 달랐던 것
이 실험을 설계할 때 적어 둔 예상치는 희소성 0.5에서 7개, 0.9에서 9개, 0.99에서 12개였다. 실제로는 8개, 12.2개, 18.8개가 나왔다. 방향과 상 전이의 모양은 그대로지만 개수는 더 크게 나왔다.
원인은 "표현된 특징"의 문턱값이다. 열 노름 0.1을 기준으로 세었는데, 이 값을 0.5로 올리면 애매하게 자리를 얻은 특징들이 빠지면서 개수가 줄어든다. 위의 희소성 0.30 행에서 여섯 번째 노름이 0.23인 것이 그 예다 — 0.1 기준으로는 세어지고 0.5 기준으로는 안 세어진다. 개수를 결론으로 쓸 때는 문턱을 반드시 함께 적어야 한다는 뜻이고, 그래서 이 글은 문턱이 필요 없는 최대 비대각을 주 지표로 두었다.
한계
장난감 모델 하나다. 특징 20개, 은닉 5차원, 합성 데이터, 균등분포 입력이다. 실제 언어 모델의 잔차 스트림에서 같은 현상이 같은 자리에서 일어난다는 근거는 이 실험에 없다. 논문도 이 점을 분명히 하고 있고, 여기서 나온 "활성 90%까지는 중첩 없음" 같은 수치를 실제 모델에 옮겨 적으면 안 된다.
입력 특징이 서로 독립이다. 실제 데이터의 특징은 상관을 갖는다. 상관이 있는 특징들이 어떻게 묶여 들어가는지는 논문의 뒷부분 주제이고 이 재현에는 없다.
ReLU 하나짜리 구조다. 층이 깊어지면 중첩이 층 사이에 분산될 수 있는데 그 역시 다루지 않았다.
절대 시간은 환경 종속이다. 아래 환경에서 잰 값이고, 코어 수가 다르면 초는 달라진다. 결론으로 쓴 것은 비율(4.6배)과 순서뿐이다.
측정 환경
| 항목 | 값 |
|---|---|
| OS | Ubuntu 24.04.4 LTS (컨테이너) |
| CPU | Intel Xeon @ 2.10GHz, 4코어 (torch.set_num_threads(4)) |
| Python | 3.11.15 |
| torch | 2.13.0+cu130 (CPU로만 실행) |
| 측정일 | 2026-08-05 |
| 총 실행 시간 | 3분 11초 |
pip install torch가 리눅스에서 기본으로 CUDA 빌드를 받아 온다. GPU 없이도
그대로 돌지만 설치 용량이 크므로, 받을 수 있는 환경이라면
--index-url https://download.pytorch.org/whl/cpu로 CPU 휠을 지정하는 편이
낫다. 위 출력의 +cu130은 그 이유로 붙은 것이고 계산은 전부 CPU에서 했다.

