ITE4053 · HANYANG UNIV · 2019 SPRING

딥러닝 방법론과 응용

손으로 미분한 역전파는 정확했고 README의 정확도 표는 7년 뒤에도 소수점까지 재현된다. 그런데 그 표에는 코드상 존재할 수 없는 실험이 한 칸 섞여 있고, 나머지 두 과제에는 아예 숫자가 없다.

파일73
커밋42
기간2019.03–2020.06
파이썬2,441 LOC
C+종합
소견5 치명적6 중대6 경미합계 17

총평

세 개의 과제가 들어 있다. NumpyNeuralNetwork는 numpy만으로 Keras 흉내를 낸 미니 프레임워크로 2층 시그모이드 분류기를 학습시키고 TensorFlow 구현과 정확도를 비교한다. ImageDenoising은 CIFAR-10에 가우시안 노이즈를 얹어 5층 CNN으로 제거하는 과제이고, SuperResolution은 2배 확대 SR을 CNN과 직접 만든 vanilla RNN 두 가지로 푼다.

가장 잘한 것은 첫 번째 과제다. 역전파를 라이브러리 없이 직접 유도해 구현했는데, 수치 미분과 대조해보니 상대오차 최대 1.86e-07로 정확하다. 더 인상적인 것은 재현성이다. README의 NNN 행 일곱 개 수치(97.27 / 96.88 / 69.92 / 97.27 / 95.94 / 95.86 / 96.45)를 현재 numpy 2.4에서 그대로 돌려보니 일곱 개 모두 소수점 둘째 자리까지 일치했다. 학부 과제 리포트에서 이 정도로 시드·설정이 정직하게 기록된 경우는 드물다.

가장 치명적인 것도 같은 표에 있다. --loss mean_squared_error 열은 존재할 수 없는 실험이다. Sequential.backward가 BCE 미분을 상수처럼 하드코딩하고 있어서(nnn/modules.py:30) 어떤 손실 함수를 컴파일하든 그래디언트가 동일하다. 실제로 두 설정의 dW가 비트 단위로 같고, 그래서 표의 Base 칸과 Loss[MSE] 칸이 둘 다 97.27로 똑같이 찍혀 있다. 손실 함수를 바꾼 실험이 아니라 같은 실험을 두 번 적은 것이다.

나머지 두 과제는 성격이 다르다. 여기서는 틀린 숫자를 적은 것이 아니라 숫자를 아예 적지 않았다. ImageDenoising에는 README도, PSNR 값도, 베이스라인도 없고 결과 PNG 세 장만 있다. 그리고 검증 제너레이터가 학습 데이터를 그대로 읽는다. SuperResolution은 train/test 분리는 제대로 했지만, SR 과제에서 반드시 있어야 할 bicubic 베이스라인 PSNR — 심지어 입력 텐서가 곧 bicubic 결과라서 공짜로 계산되는 값 — 을 끝내 계산하지 않았다. 프레임워크는 과하게 짓고 측정은 비워둔 레포다.

과제주제핵심 판정등급
A1NumpyNeuralNetwork역전파는 검증 통과, 수치는 완전 재현. 다만 보고한 실험 한 칸이 코드상 불가능B-
A2SuperResolution (CNN)데이터 분리는 정상. 베이스라인·보고 수치·시드 고정이 전부 없음C
A3SuperResolution (RNN)입력 전처리가 이미지를 섞어버리고, 셀 내부에서 매번 Conv2D를 새로 만든다D
A4ImageDenoising검증셋이 학습셋. skip 모드는 정답에 도달할 수 없는 구조D

Assignment 1 — NumpyNeuralNetwork

과제는 X² > Y를 판별하는 2차원 이진 분류를 2층 신경망으로 풀되, numpy로 직접 구현한 것과 TensorFlow 구현의 성능을 비교하는 것이다. 저자는 여기서 멈추지 않고 nnn이라는 Keras 클론 패키지(Layer / Activation / Optimizer / Loss / Metric, compile·fit·evaluate)를 만들어 --mode np|tf 한 플래그로 두 백엔드를 갈아끼울 수 있게 했다. 610줄.

치명적

손실 함수를 바꿔도 그래디언트가 바뀌지 않는다. README의 Loss[MSE] 열은 실험이 아니다

Sequential.backwardself.loss를 전혀 참조하지 않고 BCE의 미분식 -(Y/A - (1-Y)/(1-A))를 직접 써 넣는다. Loss 클래스 계층에는 backward가 아예 정의되어 있지도 않다. 즉 --loss mean_squared_error로 컴파일해도 순전파 후 찍히는 손실값만 MSE로 바뀔 뿐, 파라미터 갱신에 쓰이는 그래디언트는 BCE 그대로다.

직접 확인했다. 같은 시드로 두 모델을 만들어 각각 binary_crossentropymean_squared_error로 컴파일한 뒤 1스텝 역전파를 돌리면 layer0.dW[[0.001737, 0.005505], [-0.000279, -0.00089]]로 완전히 동일하다. README 비교표의 NNN 행에서 Base와 Loss[MSE]가 나란히 97.27로 적혀 있는 것은 우연이 아니라 같은 학습을 두 번 기록한 결과다. 전체 7개 설정을 재현해 봤을 때도 두 값은 소수점까지 똑같이 97.27%로 나왔다.

    def backward(self, Y: np.ndarray) \
            -> np.ndarray:
        super(Sequential, self).backward(Y)

        grad = -(np.divide(Y, self._last_output) - np.divide(1 - Y, 1 - self._last_output))   # ← self.loss 는 어디에도 쓰이지 않음

        result = reduce(lambda g, layer: layer.backward(g), [grad, *reversed(self.layers)])

        return result
검증 출력scratchpad/verify_nnn.py
=== [4] MSE loss: does backward use MSE gradient? ===
  gradients identical for BCE vs MSE compile: True
  layer0.dW under 'binary_crossentropy' = [[ 0.001737  0.005505]
 [-0.000279 -0.00089 ]]
  layer0.dW under 'mean_squared_error'   = [[ 0.001737  0.005505]
 [-0.000279 -0.00089 ]]
중대

--optimizer Adam은 첫 스텝에서 죽는다

argparse의 choicesAdam이 등록되어 있고 README도 "SGDAdam이 구현되어 있다"고 적고 있지만, 실제로는 한 번도 돌아간 적이 없는 코드다. get_updateparams를 레이어 파라미터 행렬(형상 (out_dim, in_dim+1))로 받으면서 결과 버퍼를 1차원 np.zeros(np.size(params, 0))로 잡는다. 그리고 57행에서 길이 in_dim+1짜리 벡터를 그 1차원 배열의 스칼라 칸에 대입한다.

실행하면 ValueError: setting an array element with a sequence.가 즉시 난다. 같은 함수 45행에는 self.deacy 오타도 남아 있는데, decay > 0일 때만 평가되는 삼항식 안에 있어서 기본 설정에서는 터지지 않고 숨어 있다.

NumpyNeuralNetwork/nnn/optimizers.py:45, 48, 57
        lr = self.lr * ((1. / (1. + self.deacy * self.iteration)) if self.initial_decay > 0. else 1) * \
             (np.sqrt(1. - np.power(self.beta2, 1+self.iteration)) / (1. - np.power(self.beta1, 1+self.iteration)))

        results = np.zeros(np.size(params, 0))   # ← 1차원 버퍼
        ...
            results[i] = (p - lr * self.ms[i] / (np.sqrt(self.vs[i]) + self.epsilon)).reshape(params[i].shape)
중대

BCE에 클리핑이 없어서 README가 권하는 ReLU 설정은 20개 시드 중 19개에서 NaN이 된다

BCELoss.forwardnp.log(inputs)np.log(1 - inputs)를 아무 보호 없이 계산한다. float64에서 sigmoid(37) == 1.0이 성립하므로 출력이 포화하는 즉시 log(0) = -inf, 그리고 Sequential.backwardnp.divide(1 - Y, 1 - A)가 0으로 나눠 NaN이 전파된다.

과제 기본 설정(2-2-1 sigmoid, lr 0.1, 5000 epoch)에서는 가중치가 포화 영역까지 가지 않아 실제로 터지지는 않는다 — 재현 실행 7종 모두 non-finite loss 0회. 그러나 README 30–31행이 예시로 보여주는 activation='relu'를 쓰면 출력이 정확히 0이 될 수 있어 바로 무너진다. 시드 0–19에 대해 2-4-1 ReLU 망을 200 epoch 돌려본 결과 19개 시드에서 손실이 non-finite가 됐고 그중 10개는 0 epoch에서 즉사했다.

        size = np.size(inputs, -1)
        cost = -1 / size * (np.dot(targets, np.log(inputs).T) + np.dot(1 - targets, np.log(1 - inputs).T))

        return np.squeeze(cost)   # ← epsilon 클리핑 없음
검증 출력scratchpad/relu2.py — README 예시 설정(relu) 재현
  seed=0: loss became non-finite at epoch 0, output range=[0.0,0.0]
  seed=2: loss became non-finite at epoch 64, output range=[0.0,0.5001742458909859]
  seed=7: loss became non-finite at epoch 1, output range=[1.0119984048752273,1.0192380548504565]
  ... 20개 시드 중 19개 실패
경미

"only numpy" 프레임워크가 TensorFlow 없이는 import되지 않는다

README 20행은 NNN을 "neural network framework based on only numpy"라고 소개한다. 그런데 main.py:4from nnn import utils를 하고, nnn/utils/__init__.py:4가 무조건 import tensorflow as tf를 한다. --mode np로만 쓰려는 사용자도 TensorFlow를 깔아야 한다. 실제로 numpy·tqdm만 설치한 환경에서 import nnn은 통과하지만 from nnn import utilsModuleNotFoundError: No module named 'tensorflow'로 실패한다.

덧붙여 같은 파일 10행의 tf.random.set_random_seed는 TF 1.x 전용 API다. requirements.txttensorflow>=1.0.0이라 TF 2.x가 설치되면 시드 고정 자체가 예외로 죽는다.

경미

README가 데이터 분포를 반대로 설명하고, 이미지 링크 두 개가 404다

README 85행은 "Dataset은 np.random.randn으로 standard normal에서 샘플을 만든다. --normal 플래그로 normalized dataset을 설정한다"고 적는다. 코드는 정반대다. Dataset.__init__generator = np.random.randn if normal else np.random.rand이고 --normalstore_true라 기본값이 False — 즉 기본 실행은 균등분포 rand이고 --normal을 줘야 정규분포가 된다. 비교표의 Base 열과 Data[Normal] 열의 관계가 README 설명과 뒤집혀 있다.

그리고 2019년 6월 커밋 73b23d0에서 프로젝트를 NumpyNeuralNetwork/ 하위로 옮기면서 README의 이미지 경로를 고치지 않았다. 79행과 132행이 가리키는 master/assets/network.png, master/assets/results.png는 존재하지 않는다(실제 위치는 master/NumpyNeuralNetwork/assets/). functional.py:7,22__int__ 오타(__init__이어야 함)도 "Update MSE" 커밋(448d881)에서 그 줄을 직접 건드리면서도 고쳐지지 않고 남았다.

잘한 것

역전파를 수치 미분으로 검증했다. 2-2-1 시그모이드 망에 대해 Dense.dW·db를 중앙차분과 대조한 결과 전 파라미터 최대 상대오차가 1.86e-07로, 체인룰이 layer→activation 경계에서 정확히 연결되어 있다. 특히 Dense.backward1/m을 손실 미분이 아니라 dW·db 단계에서 곱하는데도 결과가 맞는다.

그리고 README 비교표의 NNN 행 7개 수치를 2026년의 numpy 2.4.6에서 그대로 재현했다 — 97.27 / 96.88 / 69.92 / 97.27 / 95.94 / 95.86 / 96.45, 전부 소수점 둘째 자리까지 일치. 시드(2), 크기(128), epoch(5000), lr(0.1)이 README에 정확히 적혀 있고 코드 기본값과도 맞아서 가능한 일이다. parameters.npz에 저장된 가중치 역시 README 표의 -5.4167 / 4.1429 / 1.1458과 정확히 일치한다. 이 레포 전체에서 가장 값어치 있는 부분이다.

Assignment 2/3 — SuperResolution

2배 확대 SR 과제. 학습은 T91·291 데이터셋에서 32×32 크롭을 뜨고, 16×16으로 줄였다가 bicubic으로 되키운 것을 입력으로 쓴다. 평가는 Set5. 두 가지 백본을 --mode cnn|rnn으로 고르는데, CNN은 3층 3×3 conv(64-64-1)이고 RNN은 Keras Layer를 상속해 직접 만든 vanilla RNN 셀이다. 470줄.

치명적

RNN 전처리가 이미지를 3번 복제하는 대신 픽셀을 섞어버린다

_preprocess의 의도는 명확하다 — 같은 입력 이미지를 repeat=3 타임스텝짜리 시퀀스로 만들어 RNN에 먹이는 것. 그런데 tf.tile(..., [1, repeat, repeat])공간축을 3×3으로 타일링한 뒤 reshape(-1, repeat, *shape)로 자르기 때문에, 타일 경계와 reshape 경계가 전혀 맞지 않는다.

numpy로 동일 연산을 재현해 확인했다. 4×4 입력 [[0,1,2,3],[4,5,6,7],[8,9,10,11],[12,13,14,15]]을 넣으면 타임스텝 0이 [[0,1,2,3],[0,1,2,3],[0,1,2,3],[4,5,6,7]], 타임스텝 1이 [[4,5,6,7],[4,5,6,7],[8,9,10,11],[8,9,10,11]]이 된다. 원본과 같은 타임스텝은 하나도 없고, 세 타임스텝이 서로 같지도 않다. 배치 크기도 3배로 부풀어((B,4,4,1) → (3B,3,4,4)) _postprocessoutput[repeat-1::repeat]로 다시 1/3을 골라내는데, 이때 살아남는 샘플이 원래 어느 이미지인지에 대한 보장도 없다. 이 상태에서 학습한 PSNR 곡선은 해석할 수 있는 값이 아니다.

SuperResolution/network/rnn.py:69–75
        def _preprocess(image):
            image = tf.reshape(tf.tile(tf.squeeze(inputs, -1), [1, repeat, repeat]), [-1, repeat, *shape])
            image = tf.expand_dims(image, -1)
            return image   # ← 인자 image 를 무시하고 바깥 변수 inputs 를 참조

        def _postprocess(output):
            return output[repeat-1::repeat]
검증 출력scratchpad/verify_misc.py — 동일 연산 numpy 재현
  input[0]:            out[0,0] (timestep0):
   [[ 0  1  2  3]       [[0 1 2 3]
    [ 4  5  6  7]        [0 1 2 3]
    [ 8  9 10 11]        [0 1 2 3]
    [12 13 14 15]]       [4 5 6 7]]

  timestep0 == original image?  False
  all 3 timesteps identical?    False
치명적

RNN 셀의 call 안에서 매번 새 Conv2D 레이어를 만든다

build()에서 add_weight로 정식 등록한 것은 세 개의 units×units 커널과 바이어스뿐이다. 정작 공간 정보를 다루는 두 개의 Conv2D는 call() 본문에서 KL.Conv2D(...)(inputs) 형태로 그 자리에서 생성된다. 레이어 객체가 지역 변수라 self.trainable_weights에 잡히지 않고, 타임스텝 간 가중치 공유라는 RNN의 전제도 성립하지 않는다. model.save()로 떨어지는 체크포인트에도 이 가중치는 담기지 않는다.

같은 셀에 self.latest라는 파이썬 속성이 텐서를 담고 다니는데(41–44, 56행), 배치나 에폭 경계에서 초기화되지 않는다. get_initial_state(batch, units, units, units) 4차원을 돌려주면서 self.state_size는 스칼라 units로 선언되어 있고, compute_output_shape는 conv 출력과 무관한 (batch, units)를 반환한다. 셀 계약이 세 군데에서 서로 어긋난다.

SuperResolution/network/rnn.py:41–56
        if self.latest is None:
            self.latest = inputs

        inputs = tf.concat([inputs, self.latest], axis=-1)

        inputs = KL.Conv2D(32, (3, 3), padding="SAME", name="layer1")(inputs)   # ← call() 안에서 레이어 생성

        h = K.dot(inputs, self.input_kernel) + K.dot(prev_hidden, self.hidden_kernel) + self.hidden_bias

        output = K.relu(K.dot(h, self.output_kernel) + self.output_bias)

        output = KL.Conv2D(1, (3, 3), padding="SAME", name="layer2")(output)

        self.latest = output
중대

bicubic 베이스라인 PSNR이 없다. 입력 텐서가 곧 그 베이스라인인데도

SR 결과를 판단하려면 "아무것도 안 한 bicubic 업샘플링" 대비 몇 dB를 벌었는지가 있어야 한다. 이 과제에서는 그 값이 공짜로 나온다 — source_transformsResize(16×16) → Resize(32×32)로 만든 sources가 정확히 bicubic 결과이므로, tf.image.psnr(targets, sources) 한 줄이면 끝이다. 그 한 줄이 어디에도 없다.

게다가 README 어디에도 실제 PSNR 숫자가 없다. 24–28행은 "2500 epoch만 돌렸지만 1000 epoch 이후 PSNR과 loss가 saturate했다"고만 적고 곡선 이미지 링크를 건다. 학습이 수렴했다는 진술만 있고 성능에 대한 진술이 없다. 평가지표 자체에도 흠이 있다 — cnn.py:23–26의 마지막 conv에는 활성화도 클리핑도 없어서 예측값이 [0,1]을 벗어날 수 있는데, tf.image.psnr(..., max_val=1.)을 클리핑 없이 그대로 적용한다. SR 표준 프로토콜의 경계 shave도 없다.

SuperResolution/README.md:24–28
## Results

In terms of time, it was only 2500 epoch. However, PSN and loss is saturated
after 1000 epoch. Model parameters is saved on `model.hdf5` using keras model
save method.   ← dB 수치, 베이스라인, Set5 평균 그 무엇도 없음
중대

배치 인덱싱이 np.roll이라 연속 배치가 127/128 겹치고, __len__은 배치 수가 아니라 이미지 수다

keras.utils.Sequence__len__은 에폭당 배치 수를 반환해야 한다. 여기서는 이미지 개수를 돌려주고, lib/model.py:31이 그걸 steps_per_epoch로 그대로 쓴다. T91+291이면 약 382장 × 배치 128 = 한 "에폭"에 48,896 샘플이 흐른다. README가 말하는 "2500 epoch"의 실제 의미가 무엇인지 아무도 알 수 없다.

더 나쁜 것은 __getitem__이 인덱스를 np.roll(self.indices, item)한 칸씩 회전시킨 뒤 앞에서 batch개를 뜯는다는 점이다. batch 0과 batch 1이 128개 중 127개를 공유한다. 배치가 데이터셋의 분할이 아니라 슬라이딩 윈도우라서, 같은 이미지가 한 에폭에 128번씩 등장한다.

같은 함수 58–60행에는 더 조용한 지뢰가 있다. 크롭 결과 형상이 self.size와 다르면 루프 안에서 sources·targetsnp.empty로 다시 할당한다. 그 시점까지 채운 b개 샘플은 전부 버려지고 초기화되지 않은 메모리로 대체된다. 현재 설정에서는 크롭 크기가 항상 32×32라 발동하지 않지만, 크롭 크기를 바꾸는 순간 학습 배치의 대부분이 쓰레기가 된다.

SuperResolution/utils/data.py:40–60
    def __len__(self) -> int:
        return len(self.images)        # ← 배치 수가 아니라 이미지 수
    ...
        indices = np.roll(self.indices, item)   # ← batch0=[0,1,2,3] batch1=[9,0,1,2]

        for b in range(self.batch):
            ...
            if self.size != target.shape:
                sources = np.empty((self.batch, *target.shape, self.channels), dtype=np.float32)
                targets = np.empty((self.batch, *target.shape, self.channels), dtype=np.float32)
중대

TensorFlow 시드를 한 번도 고정하지 않는다. 대신 쓰지도 않는 PyTorch 시드를 고정한다

이 프로젝트는 전부 TensorFlow/Keras다. 그런데 utils.initrandom, numpy, 그리고 torch를 시드한다. tf.random.set_seed는 없다. 가중치가 kernel_initializer='random_uniform'으로 초기화되므로, 같은 --seed 42로 두 번 돌려도 초기 가중치가 다르다. 첫 번째 과제에서는 시드를 제대로 잡아 7년 뒤 재현까지 됐는데, 여기서는 그 습관이 사라졌다.

동시에 workers=4, use_multiprocessing=TrueSequence를 돌린다. fork 방식에서 4개 워커가 동일한 numpy RNG 상태를 복사해 가므로 랜덤 크롭·랜덤 스케일 증강이 워커마다 똑같이 나온다. 증강 다양성이 의도의 1/4로 줄어든다.

def init(seed: int):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)        # ← torch 는 이 프로젝트에서 쓰이지 않음

    if torch.cuda.is_available():
        torch.cuda.manual_seed(seed)
        torch.cuda.manual_seed_all(seed)
                                    # ← tf.random.set_seed 가 없음
경미

README-RNN이 적은 손실 함수가 구현되어 있지 않다

README-RNN.md:11–13은 손실을 Σ(n=0..2) ||y_gt − y_n||², 즉 세 타임스텝 전부에 감독을 거는 multi-step loss로 명시한다. 구현(network/rnn.py:91–93)은 K.mean(K.square(y_pred - y_true)) 한 줄이고, KL.RNN(..., return_sequences=False) + _postprocess로 마지막 출력만 남긴다. 중간 타임스텝에는 손실이 걸리지 않는다.

같은 문서 25행과 README.md:5,20,22는 코드를 lib/network.py, utils/callbacks.py에 있다고 안내하는데 실제 경로는 network/cnn.py, network/rnn.py, utils/callback.py다. README.md:34는 "train samples" 캡션을 달고 loss_psnr_cnn.png를 링크한다 — train_samples_cnn.png가 바로 옆에 있는데도.

경미

커밋된 model.hdf5는 지금 코드가 만든 것이 아니다

486KB짜리 model.hdf5가 저장소에 들어 있다. 내부 config를 열어 보면 모델 이름이 "SR"인데 현재 network/cnn.py:28–29name='SRN'으로 만든다. 컴파일 정보도 "loss": "mean_squared_error" 문자열인데 lib/model.py:25는 커스텀 함수 self.klass.loss를 넘긴다. 즉 커밋된 체크포인트는 지금 소스와 다른 버전의 산출물이다. RNN 가중치는 들어 있지 않은데 README-RNN.md:31은 "Model parameters is saved on model.hdf5"라고 적고 있다.

network/__pycache__/*.cpython-36.pyc 세 개와, .gitignore로 무시되는 data/ 안의 빈 파일 data/pass도 함께 커밋되어 있다. main.py:51–53의 위치 인자 command(train/eval)는 파싱만 되고 main()에서 읽히지 않아 eval을 줘도 학습이 시작된다. argparse 설명 문자열은 'Evaluate Multi Object Tracking' — 다른 프로젝트에서 복사해 온 흔적이다.

잘한 것

train/test 분리는 제대로 했다. 학습은 data/91·data/291, 검증은 data/Set5로 디렉터리 수준에서 갈라 두어 누수가 없다(main.py:14–39). SR에서 Set5를 테스트 세트로 쓰는 것은 표준 관행이고, 두 번째·세 번째 과제 중 데이터 위생이 지켜진 유일한 곳이다.

CustomCallbackinterval마다 (저해상 입력, 예측, 정답)을 가로로 붙여 TensorBoard 이미지 서머리로 남기게 만든 것도 실용적이다(utils/callback.py:36–39). 숫자는 안 남겼지만 눈으로 볼 증거는 assets/cnn/, assets/rnn/에 실제로 남아 있다.

Assignment 4 — ImageDenoising

CIFAR-10 이미지에 N(0, 0.1) 가우시안 노이즈를 더해 5층 3×3 conv(64×4 + 3)로 복원하는 과제. --mode base|skip|bn 세 변형을 비교하는 것이 과제의 핵심으로 보인다. README가 없고, 결과는 512×512 PNG 세 장(scripts/Model1~3.png)이 전부다. 1,361줄 중 960줄이 scripts/의 3중 복사본이다.

치명적

검증 제너레이터가 학습 데이터를 읽는다. train 인자는 받아서 버린다

main.py:17–18utils.data.Dataset(train=False, batch=1, ...)로 검증 세트를 만든다. 호출부만 보면 완벽하다. 그런데 Dataset.__init__train 파라미터를 시그니처에 선언해 놓고 본문에서 한 번도 읽지 않는다. 35행은 인자와 무관하게 self.is_training = True로 못 박고, 38행은 무조건 self.images = self.x_train을 쓴다. x_test는 로드해서 즉시 버린다.

결과적으로 fit_generator에 넘어가는 validation_data가 학습 세트다. lib/model.py:33validation_steps=100이 뽑는 100개 샘플은 전부 학습에 쓰인 이미지이고, 로그에 찍히는 val_loss·val_psnr은 학습 성능일 뿐이다. train()·eval() 메서드(46–50행)도 정의만 되어 있고 저장소 어디에서도 호출되지 않는다. 이 과제에서 보고된 검증 수치가 없다는 점이 역설적으로 다행인데, 있었다면 전부 무효였을 것이다.

ImageDenoising/utils/data.py:27–44
    def __init__(self, train: bool = True,          # ← 선언만 되고 본문에서 사용되지 않음
                 source_transforms: List[Transform] = None,
                 target_transforms: List[Transform] = None,
                 batch: int = 32, shuffle: bool = True):

        self.batch = batch
        self.shuffle = shuffle
        self.channels = 3
        self.is_training = True                       # ← train 인자 무시

        (self.x_train, _), (self.x_test, _) = keras.datasets.cifar10.load_data()
        self.images = self.x_train                     # ← 검증 인스턴스도 학습셋을 본다
치명적

skip/bn 모드는 구조적으로 정답에 도달할 수 없다

네트워크의 마지막 연산이 KL.ReLU()다(50행). 회귀 문제의 출력 헤드에 ReLU를 붙였으니 예측은 항상 0 이상이고, 한 번 음수로 넘어간 픽셀은 그래디언트가 끊겨 영구히 죽는다. 이것만으로도 문제인데, skipbn 모드는 여기에 KL.average([x, inputs])를 얹는다.

average덧셈이 아니라 산술 평균이다. 최종 출력은 (f(x) + noisy) / 2이고 f(x) ≥ 0이므로 출력의 하한이 noisy/2로 고정된다. 노이즈가 위쪽으로 튄 픽셀 — 예컨대 깨끗한 값 0.30에 노이즈가 얹혀 0.90이 된 픽셀 — 의 정답은 0.30인데 모델이 낼 수 있는 최솟값이 0.45다. 구조적으로 도달 불가능하다. DnCNN 계열의 residual 연결은 input − noise_estimate 또는 Add이지 Average가 아니다. 세 모드의 비교 실험 자체가 공정한 비교가 아니게 된다.

        x = KL.Conv2D(3, (3, 3), padding="SAME", ... name="layer5")(x)
        if mode == 'bn':
            x = KL.BatchNormalization()(x)
        x = KL.ReLU()(x)                          # ← 회귀 출력 헤드에 ReLU

        if mode == 'skip' or mode == 'bn':
            x = KL.average([x, inputs])           # ← Add 가 아니라 Average: 출력 ≥ noisy/2
중대

320줄짜리 파일을 세 번 복사했다. 두 줄만 다르다

scripts/Model1.py, Model2.py, Model3.py는 각각 320줄이고 diff로 확인한 차이가 정확히 두 줄이다 — 97행의 출력 파일명('Model1.png' / 'Model2.png' / 'Model3.png')과 297행의 --mode 기본값('base' / 'skip' / 'bn'). 그런데 이 두 값은 이미 CLI 인자로 노출되어 있다. 같은 저장소의 main.py--mode를 받고 모듈 구조도 멀쩡한데, 제출을 위해 전부 한 파일로 인라인한 뒤 세 벌 복제했다.

같은 코드가 세 벌이면 버그도 세 벌이다. 위에서 지적한 검증셋 오염(Model*.py:111–112)과 출력 ReLU/average 문제(Model*.py:195,197–198)가 세 파일에 그대로 복제되어 있다. 그리고 이 과제에는 README가 없다 — 어떤 모드가 몇 dB였는지, 노이즈 σ가 얼마였는지, 무엇을 비교해 무슨 결론을 냈는지 저장소 어디에도 문장으로 남아 있지 않다. PNG 세 장이 전부다.

diff Model1.py Model2.pyImageDenoising/scripts/
97c97
<         filename = 'Model1.png'
---
>         filename = 'Model2.png'
297c297
<     parser.add_argument("--mode", default='base', choices=['base', 'skip', 'bn'],
---
>     parser.add_argument("--mode", default='skip', choices=['base', 'skip', 'bn'],
320줄 중 2줄. Model3 도 동일하게 'bn' 한 단어만 다름
경미

결과 파일명에서 점이 사라지고, 학습은 RGB인데 추론은 BGR이다

Eval.to_png는 확장자를 분리해 놓고 다시 붙일 때 점을 빠뜨린다. noisy.png를 넣으면 noisy-resultpng가 나온다 — 확장자 없는 파일이라 cv2.imwrite가 포맷을 결정하지 못한다. scripts/의 세 복사본에서는 이 줄이 filename = 'Model1.png' 하드코딩으로 "해결"되어 96행의 *path, ext = filename.split('.')이 결과를 쓰지 않는 죽은 줄로 남았다.

채널 순서도 어긋난다. 학습 데이터는 keras.datasets.cifar10.load_data()로 RGB인데, 추론 입력은 cv2.imread(filename)으로 BGR이다(utils/data.py:15). 거기에 CIFAR-10 32×32에서 σ=0.1로 학습한 모델을 노이즈 수준을 알 수 없는 512×512 사진에 그대로 적용한다. 정답 이미지가 없으니 PSNR도 낼 수 없다 — 제출물이 눈으로 보는 PNG 세 장에서 끝나는 구조적 이유다.

ImageDenoising/utils/data.py:21–24
    def to_png(self, filename: str):
        *path, ext = filename.split('.')
        filename = '.'.join(path) + '-result' + ext   # ← 'noisy.png' → 'noisy-resultpng'
        cv2.imwrite(filename, self.image)
경미

로깅 콜백은 주석 처리되어 있고, 주석을 풀면 실행되지 않는다

lib/model.py:38–39에서 CustomCallback이 통째로 주석 처리되어 콜백 리스트가 비어 있다. 학습 중 어떤 이미지 서머리도 남지 않는다. 그리고 주석을 풀어도 동작하지 않는다 — 2020년 6월 커밋에서 TF2로 기계적으로 치환하면서 utils/callback.py:42tf.compat.v1.summary(value=...)가 되었는데 tf.compat.v1.summary는 모듈이라 호출할 수 없고, 38행의 tf.summary.Image는 TF2에 존재하지 않는 이름이다. 고치는 대신 주석으로 덮었다.

Dataset.__len__이 50,000(CIFAR 학습셋 크기)을 반환하고 그것이 steps_per_epoch로 쓰이므로, 기본 --epoch 100은 배치 32 기준 1.6억 샘플을 의미한다. SuperResolution과 같은 np.roll 배치 중복 문제도 동일하게 있다(utils/data.py:67).

반복되는 패턴

  1. 인터페이스를 선언하고 본문에서 무시한다. Dataset(train=False)train, main.py의 위치 인자 command, Sequential.compile(loss=...)loss. 셋 다 호출부만 읽으면 정확해 보이고 셋 다 아무 효과가 없다. 이 레포의 가장 위험한 습관인데, 코드 리뷰가 아니라 실행 결과로만 검증할 경우 절대 드러나지 않기 때문이다.
  2. 스캐폴드는 과하게, 측정은 비워둔다. Keras 클론 프레임워크, Transform 파이프라인, 커스텀 TensorBoard 콜백을 다 만들었다. 반면 SR에는 bicubic 베이스라인이 없고, Denoising에는 README와 PSNR 수치 자체가 없고, 세 과제 어디에도 테스트나 그래디언트 체크 코드가 한 줄도 없다(assert 4개는 전부 mode in [...] 검사).
  3. 문서가 코드보다 앞서간다. "only numpy"인데 TensorFlow를 import하고, "Adam 구현"인데 첫 스텝에서 죽고, README-RNN이 명시한 3-step 손실은 구현되지 않았고, 데이터 분포 설명은 플래그 의미와 반대다. 예외적으로 NNN의 정확도 표만은 정직해서, 7년 뒤 재현이 된다.
  4. 배치 파이프라인을 np.roll로 만든다. SuperResolution과 ImageDenoising의 Dataset.__getitem__이 동일하게 인덱스를 한 칸씩 회전시킨다. 두 번째 과제의 설계 결함이 세 번째 과제로 복사되었고, 중간에 점검이 없었다.
  5. 제출 직전에 파일을 통째로 복제한다. ImageDenoising/scripts/의 320줄 × 3, 그리고 SuperResolutionREADME.md/README-RNN.md 분기. 옵션 하나를 위해 파일을 복사하므로, 이후의 수정은 반드시 세 곳에 따로 들어가야 하고 실제로 들어가지 않았다.

지금 손본다면

  1. Lossbackward를 추가하고 Sequential.backward가 그것을 호출하게 한다. 20줄 미만. 이것만으로 README 비교표의 Loss[MSE] 열이 실제 실험이 되고, 프레임워크가 광고한 확장성이 사실이 된다. 지금은 손실 함수를 추가해도 학습에 영향을 줄 수 없는 구조다.
  2. ImageDenoising/utils/data.py:38self.images = self.x_train if train else self.x_test로 고친다. 한 줄. 검증 수치가 비로소 의미를 갖는다. 동시에 __len__len(self.images) // self.batch로, np.rollself.indices[item*batch:(item+1)*batch]로 바꾸면 "1 에폭"이 실제로 1 에폭이 된다.
  3. bicubic 베이스라인 PSNR을 찍는다. sources가 이미 bicubic 업샘플 결과이므로 tf.image.psnr(targets, sources, max_val=1.) 한 줄이면 된다. SR 결과를 "몇 dB 벌었다"로 말할 수 있게 되는 유일한 조치이고, 비용은 사실상 0이다. 예측을 tf.clip_by_value(y_pred, 0., 1.)로 클리핑한 뒤 PSNR을 재는 것도 같이.
  4. BCE에 np.clip(inputs, eps, 1-eps)를 넣고 Adam의 get_update를 배열 단위 연산으로 다시 쓴다. 각각 한 줄, 다섯 줄. 지금은 README가 권하는 ReLU 설정에서 20개 시드 중 19개가 NaN이 되고 Adam은 선택 자체가 불가능하다.
  5. skip 모드의 KL.averageKL.add로 바꾸고 출력 ReLU를 제거한다. 두 줄. 세 모드 비교가 공정해진다. 지금 구조에서는 skip·bn 모드가 noisy/2 아래의 값을 낼 수 없어서, 비교 결과가 모델의 성능이 아니라 제약의 차이를 재고 있다.
  6. scripts/Model{1,2,3}.py 세 벌을 지우고 main.py --mode로 되돌린다. 960줄이 사라지고, 위의 수정들이 한 곳에만 들어가면 되는 상태가 된다. 필요하다면 실행 명령 세 줄을 README에 적는 것으로 충분하다.