실전에서 키를 어떻게 전달하느냐가 학습 코드의 가독성을 좌우해. 흔한 패턴 네 가지를 살펴보자.
패턴 1: 키를 전달하고 값과 함께 반환하기
def sample_noise(key, shape):
return random.normal(key, shape)
def model_with_dropout(params, x, key, p=0.5):
key, subkey = random.split(key)
mask = random.bernoulli(subkey, 1 - p, x.shape)
return x * mask / (1 - p), key # 새 key 도 반환
# 호출
key = random.PRNGKey(0)
y, key = model_with_dropout(params, x, key)
y2, key = model_with_dropout(params, y, key)
장점은 흐름이 명시적이라는 점이고, 단점은 모든 함수가 키를 입력과 출력으로 다뤄야 해서 장황해진다는 점이야.
패턴 2: 미리 split, list로 전달
def init_model(key, n_layers):
keys = random.split(key, n_layers)
return [init_layer(k) for k in keys]
장점은 코드가 깔끔하다는 점이고, 단점은 필요한 split 횟수를 미리 알아야 한다는 점이야.
패턴 3: 함수 안에서만 split
def sample_things(key):
k1, k2, k3 = random.split(key, 3)
a = random.normal(k1, ...)
b = random.uniform(k2, ...)
c = random.bernoulli(k3, ...)
return a, b, c
# 호출 측은 한 key 만 주면 됨
key, sub = random.split(key)
a, b, c = sample_things(sub)
장점은 함수 시그니처가 깔끔하다는 점이고, 단점은 함수 내부의 난수 흐름이 호출자에게 보이지 않는다는 점이야.
패턴 4: fold_in으로 스텝 별 키
@jax.jit
def train_step(params, x, y, base_key, step):
'''매 step 마다 다른 random — fold_in 으로 deterministic'''
step_key = random.fold_in(base_key, step)
k_dropout, k_noise = random.split(step_key)
# ... random 사용
장점: 학습 루프에서 추가 상태 불필요해. 스텝 번호만 있으면 OK. JIT 친화. 단점: fold_in의 통계적 독립성이 split만큼 강하진 않다는 (이론적) 우려가 있어, 실제론 문제 안 돼.
JAX 표준, train 상태 안에 키 보존
@dataclass
class TrainState:
params: Any
opt_state: Any
step: int
key: Any # ← key 도 state 의 일부
@jax.jit
def train_step(state, batch):
key, subkey = random.split(state.key)
# subkey 로 random
...
return TrainState(
params=new_params,
opt_state=new_opt_state,
step=state.step + 1,
key=key, # 다음 step 위해
)
이 패턴이 Flax / Equinox / 표준 학습기 코드 어디서나. 키는 상태를 이루는 리프 가운데 하나야.
📐 어떤 패턴 쓸지
(1) 단순 함수, 패턴 3 (함수 안에서 split). (2) 학습 루프, 패턴 4 (fold_in) 또는 상태 안에 키 보존. (3) 큰 모델 초기화, 패턴 2 (미리 list). 가장 중요한 원칙은 같은 코드 안에서 한 패턴을 일관되게 쓰는 거야. 패턴을 뒤섞으면 어느 코드가 난수를 소비했는지 추적할 수 없어.
흔한 함정: 키 재사용해. 같은 키를 두 번 쓰면 같은 난수가 나와. 일부러 그러는 거 (test)이면 OK, 아니면 split 빠뜨린 버그.