안녕하세요.
오늘은 저번 포스팅에서 paper review를 진행했던 Patch SVDD 방법론에 대한 code review를 진행해보려고 합니다.
기존 모델 architecture 위주로 리뷰했던 이전 글들과는 달리, 해당 방법론은 설명할 것들이 많고 복잡하여 조금은 다른 형태로 진행하게 될 것 같습니다.
먼저 큰 분류로는 1. Train / 2. Test로 나눠서 설명하고, Train과 Test에서도 전체적인 흐름을 먼저 제시하려고 합니다.
그러고 나서, 전체적인 흐름 중 한 단계씩을 세부적으로 설명하는 형식으로 진행해보려고 합니다.
따라서, 개괄적인 내용만 확인해보고 싶으시다면 1.1과 2.1만을 확인하셔도 되고 세부적으로 코드를 라인 바이 라인으로 확인하고 싶으시다면 전체 내용을 보시면 되겠습니다.
해당 논문의 paper review는 이전 포스팅에서 확인하실 수 있습니다.
https://cumulu-s.tistory.com/42
그럼 코드 리뷰를 진행해보겠습니다.
1. Training
1.1에서는 학습이 전체적으로 어떻게 이루어지는지 글로 설명드리고, 1.2.x에서는 해당 내용을 코드 기준으로 설명해드립니다.
1.1 Training의 전체적인 흐름
가장 먼저 학습을 진행해야 하는 모델들을 정의해줍니다.
우리가 사용할 모델은 Encoder와 classifier가 있습니다.
다음으로는 학습을 하는 데 사용하는 데이터셋을 구축하게 됩니다.
데이터셋은 총 4개를 구축하게 되며, (1) 64x64 patch dataset (+pos), (2) 32x32 patch dataset (+pos), (3) 64x64 patch dataset (no pos), (4) 32x32 patch dataset (no pos)로 구성됩니다.
그리고 각 데이터셋은 항상 patch pair로 구성됩니다.
기본적으로 학습이 이루어질 때 main patch와 이와 인접한 neighborhood patch로 1쌍이 구성되기 때문입니다.
pos는 position 정보를 약자로 표현한 것인데 논문에서 제시한 self-supervised learning을 진행할 때 classifier가 맞춰야 하는 ground-truth가 됩니다.
예를 들어서, 논문의 Fig. 4의 그림을 기준으로 생각해보자면 해당 케이스에서는 pos가 0이 될 것입니다.
데이터셋이 구성되었다면, 일반적인 딥러닝 학습과 동일하게 batch size를 기준으로 dataloader를 통해 데이터셋을 불러오게 됩니다.
4개의 데이터셋에 대해서 각각 학습해야 하는 것이 아니라, 4개의 데이터셋을 하나의 Concat Dataset으로 구성하여 사용해서 batch 단위로 4개의 데이터셋에 포함된 데이터들이 나오도록 구성합니다.
position 정보를 포함한 데이터셋을 이용해서 $\mathcal{L}_{SSL}$을 계산하고, position 정보가 포함되지 않은 데이터셋을 이용해서 $\mathcal{L}_{SVDD'}$를 계산합니다.
정확히 얘기하자면, position 정보를 포함한 데이터셋은 64x64 patch dataset과 32x32 patch dataset이 있으므로 $\mathcal{L}_{SSL}$은 $\mathcal{L}_{SSL64}$와 $\mathcal{L}_{SSL32}$의 합이 되는 것이죠.
그리고 position 정보를 포함하지 않은 데이터셋도 마찬가지로 64x64 patch dataset과 32x32 patch dataset이 있으므로 $\mathcal{L}_{SVDD'}$은 $\mathcal{L}_{SVDD'64}$와 $\mathcal{L}_{SVDD'32}$의 합이 되는 것입니다.
이를 이용해서 논문에서 나온 대로 $\mathcal{L}_{Patch SVDD} = \lambda\mathcal{L}_{SVDD'} + \mathcal{L}_{SSL}$로 계산해 최종 loss를 계산해줍니다.
그리고 이를 이용하여 Backpropagation을 진행해 classifier와 encoder를 학습시키게 됩니다.
Training 과정이 끝나게 되면, 최종적으로는 학습이 완료된 Encoder를 얻게 됩니다.
코드 상으로는 논문에 나와있는 것처럼 normal patch를 별도로 저장하지는 않습니다.
1.2.1 Encoder 및 classifier 정의
1.2.x 에서는 세부적인 코드를 포함해 설명을 진행합니다.
먼저 Encoder 및 classifier 정의를 수행하는 부분을 살펴봅니다.
# main_train.py (line32 - line41)
with task('Networks'):
enc = EncoderHier(64, D).cuda()
cls_64 = PositionClassifier(64, D).cuda()
cls_32 = PositionClassifier(32, D).cuda()
modules = [enc, cls_64, cls_32]
params = [list(module.parameters()) for module in modules]
params = reduce(lambda x, y: x + y, params)
opt = torch.optim.Adam(params=params, lr=lr)
코드가 여러 가지. py 파일로 저장되어 있는 구조이므로, 찾기 쉽도록 주석을 이용하여 해당 코드가 포함된 파일의 위치와 line의 위치를 적어두겠습니다.
먼저 enc라는 변수로 Encoder를 정의하고, cls_64와 cls_32로 classifier를 정의합니다.
그리고 이를 modules로 합친 뒤, 각 모델에 있는 parameter를 params로 전부 합치고, reduce 함수를 통해서 모두 합쳐줍니다.
optimizer는 논문에 나온 대로 Adam optimizer를 사용하고 있는 모습입니다.
그렇다면 이제 Encoder와 classifier가 어떤 구조로 되어있는지 확인해봐야겠죠?
먼저 Encoder부터 살펴봅니다.
# networks.py line 132 - line 160
class EncoderHier(nn.Module):
def __init__(self, K, D=64, bias=True):
super().__init__()
if K > 64:
self.enc = EncoderHier(K // 2, D, bias=bias)
elif K == 64:
self.enc = EncoderDeep(K // 2, D, bias=bias)
else:
raise ValueError()
self.conv1 = nn.Conv2d(D, 128, 2, 1, 0, bias=bias)
self.conv2 = nn.Conv2d(128, D, 1, 1, 0, bias=bias)
self.K = K
self.D = D
def forward(self, x):
h = forward_hier(x, self.enc, K=self.K)
h = self.conv1(h)
h = F.leaky_relu(h, 0.1)
h = self.conv2(h)
h = torch.tanh(h)
return h
모델은 크게 두 부분으로 되어 있습니다.
self.enc와 self.conv1 + self.conv2입니다.
self.con1과 self.conv2는 너무 단순하니까 별도로 설명은 안 드려도 될 것 같고요.
self.enc를 보면 EncoderDeep으로 되어 있는 것을 알 수 있죠? 이걸 또 살펴봐야겠네요.
# networks.py line 75 - line 116
class EncoderDeep(nn.Module):
def __init__(self, K, D=64, bias=True):
super().__init__()
self.conv1 = nn.Conv2d(3, 32, 3, 2, 0, bias=bias)
self.conv2 = nn.Conv2d(32, 64, 3, 1, 0, bias=bias)
self.conv3 = nn.Conv2d(64, 128, 3, 1, 0, bias=bias)
self.conv4 = nn.Conv2d(128, 128, 3, 1, 0, bias=bias)
self.conv5 = nn.Conv2d(128, 64, 3, 1, 0, bias=bias)
self.conv6 = nn.Conv2d(64, 32, 3, 1, 0, bias=bias)
self.conv7 = nn.Conv2d(32, 32, 3, 1, 0, bias=bias)
self.conv8 = nn.Conv2d(32, D, 3, 1, 0, bias=bias)
self.K = K
self.D = D
def forward(self, x):
h = self.conv1(x)
h = F.leaky_relu(h, 0.1)
h = self.conv2(h)
h = F.leaky_relu(h, 0.1)
h = self.conv3(h)
h = F.leaky_relu(h, 0.1)
h = self.conv4(h)
h = F.leaky_relu(h, 0.1)
h = self.conv5(h)
h = F.leaky_relu(h, 0.1)
h = self.conv6(h)
h = F.leaky_relu(h, 0.1)
h = self.conv7(h)
h = F.leaky_relu(h, 0.1)
h = self.conv8(h)
h = torch.tanh(h)
return h
nn.Conv2d를 8개 쌓고, 중간에 activation function은 leaky_relu를 사용하였으며 마지막은 tanh를 사용한 구조가 되겠네요.
그렇다면, 사실상 Encoder는 EncoderDeep(conv 8개) + self.conv1 + self.conv2로 총 10개의 conv layer가 쌓인 형태라는 사실을 확인할 수 있겠습니다.
다음으로는 Classifier를 한번 살펴보겠습니다.
# networks.py line 214 - line 265
class PositionClassifier(nn.Module):
def __init__(self, K, D, class_num=8):
super().__init__()
self.D = D
self.fc1 = nn.Linear(D, 128)
self.act1 = nn.LeakyReLU(0.1)
self.fc2 = nn.Linear(128, 128)
self.act2 = nn.LeakyReLU(0.1)
self.fc3 = NormalizedLinear(128, class_num)
self.K = K
def save(self, name):
fpath = self.fpath_from_name(name)
makedirpath(fpath)
torch.save(self.state_dict(), fpath)
def load(self, name):
fpath = self.fpath_from_name(name)
self.load_state_dict(torch.load(fpath))
def fpath_from_name(self, name):
return f'ckpts/{name}/position_classifier_K{self.K}.pkl'
@staticmethod
def infer(c, enc, batch):
x1s, x2s, ys = batch
h1 = enc(x1s)
h2 = enc(x2s)
logits = c(h1, h2)
loss = xent(logits, ys)
return loss
def forward(self, h1, h2):
h1 = h1.view(-1, self.D)
h2 = h2.view(-1, self.D)
h = h1 - h2
h = self.fc1(h)
h = self.act1(h)
h = self.fc2(h)
h = self.act2(h)
h = self.fc3(h)
return h
함수들이 여러 개 있기는 하지만, 결국은 fully connected layer 3개로 이루어진 구조네요.
1.2.2 데이터 셋 불러오기
다음 단계는 데이터셋을 불러오게 됩니다.
코드상에서는 다음과 같습니다.
# main_train.py line 57 - line 59
with task('Datasets'):
train_x = mvtecad.get_x_standardized(obj, mode='train')
train_x = NHWC2NCHW(train_x)
mvtecad의 get_x_standardized를 확인하면 될 것 같습니다.
# mvtecad.py line 70 - line 73
def get_x_standardized(obj, mode='train'):
x = get_x(obj, mode=mode)
mean = get_mean(obj)
return (x.astype(np.float32) - mean) / 255
보니까 get_x라는 함수를 또 살펴봐야 할 것 같네요.
get_mean도 살펴봐야 합니다.
# mvtecad.py line 48 - line 67
def get_x(obj, mode='train'):
fpattern = os.path.join(DATASET_PATH, f'{obj}/{mode}/*/*.png')
fpaths = sorted(glob(fpattern))
if mode == 'test':
fpaths1 = list(filter(lambda fpath: os.path.basename(os.path.dirname(fpath)) != 'good', fpaths))
fpaths2 = list(filter(lambda fpath: os.path.basename(os.path.dirname(fpath)) == 'good', fpaths))
images1 = np.asarray(list(map(imread, fpaths1)))
images2 = np.asarray(list(map(imread, fpaths2)))
images = np.concatenate([images1, images2])
else:
images = np.asarray(list(map(imread, fpaths)))
if images.shape[-1] != 3:
images = gray2rgb(images)
images = list(map(resize, images))
images = np.asarray(images)
return images
# mvtecad.py line 104 - line 107
def get_mean(obj):
images = get_x(obj, mode='train')
mean = images.astype(np.float32).mean(axis=0)
return mean
get_x는 데이터셋을 불러오는 함수입니다.
fpattern으로 obj의 mode에 해당하는 모든 데이터의 경로를 가져오고, 이를 sorted 해서 fpaths에 저장합니다.
그리고 imread 함수를 이용해서 fpaths에 있는 이미지 데이터들을 다 가져와 list로 만든 다음 이를 numpy array로 변경합니다.
imread는 imageio라는 패키지에 있는 함수로, 이미지를 불러오는 함수입니다.
그러고 나서 resize라는 함수를 적용해서 이미지를 resizing 시켜줍니다.
# mvtecad.py line 20 - line 21
def resize(image, shape=(256, 256)):
return np.array(Image.fromarray(image).resize(shape[::-1]))
resize는 이미지를 256 x 256의 형태로 바꿔주는 역할을 합니다.
따라서, get_x를 통해서 training image를 256 x 256으로 resize 시켜서 가져오고, get_mean을 통해서 평균을 구하고, 이를 get_x_standardized 함수에서 사용하여 이미지에서 평균을 빼고 255로 나누어 기본적인 전처리를 해줍니다.
255로 나눈다는 것은 0 ~ 255의 value space를 가지도록 만드는 게 아니라, 0 ~ 1의 value space를 가지도록 만들어준다는 것을 의미한다고 보시면 되겠습니다.
그리고 NHWC2NCHW 함수는 이름만 봐도 알겠지만 데이터의 차원을 변경해주는 함수가 되겠습니다.
즉 batch / channel / height / width 순으로 값을 변경해줍니다.
1.2.3 Dataset 구성하기
다음 단계는 데이터셋을 구성하게 됩니다.
# main_train.py line 62 - line 70
datasets = dict()
datasets[f'pos_64'] = PositionDataset(train_x, K=64, repeat=rep)
datasets[f'pos_32'] = PositionDataset(train_x, K=32, repeat=rep)
datasets[f'svdd_64'] = SVDD_Dataset(train_x, K=64, repeat=rep)
datasets[f'svdd_32'] = SVDD_Dataset(train_x, K=32, repeat=rep)
dataset = DictionaryConcatDataset(datasets)
loader = DataLoader(dataset, batch_size=256, shuffle=True, num_workers=2, pin_memory=True)
데이터셋은 총 4개가 있는데요.
PositionDataset은 position 정보가 포함된 dataset입니다.
PositionDataset부터 보도록 하겠습니다.
# dataset.py line 117 - line 153
class PositionDataset(Dataset):
def __init__(self, x, K=64, repeat=1):
super(PositionDataset, self).__init__()
self.x = np.asarray(x)
self.K = K
self.repeat = repeat
def __len__(self):
N = self.x.shape[0]
return N * self.repeat
def __getitem__(self, idx):
N = self.x.shape[0]
K = self.K
n = idx % N
image = self.x[n]
p1, p2, pos = generate_coords_position(256, 256, K)
patch1 = crop_image_CHW(image, p1, K).copy()
patch2 = crop_image_CHW(image, p2, K).copy()
# perturb RGB
rgbshift1 = np.random.normal(scale=0.02, size=(3, 1, 1))
rgbshift2 = np.random.normal(scale=0.02, size=(3, 1, 1))
patch1 += rgbshift1
patch2 += rgbshift2
# additive noise
noise1 = np.random.normal(scale=0.02, size=(3, K, K))
noise2 = np.random.normal(scale=0.02, size=(3, K, K))
patch1 += noise1
patch2 += noise2
return patch1, patch2, pos
Dataset 객체는 __init__과 __len__, __getitem__으로 이루어지게 되는데요.
__init__은 데이터셋을 구성할 때 필요한 요소들이라고 생각해주시면 좋고요.
__len__는 말 그대로 데이터셋의 크기를 나타냅니다.
Patch SVDD에서는 repeat이라는 parameter를 입력받게 되는데, 이는 1장당 패치를 몇 개 뽑을지를 결정한다고 보시면 되겠습니다.
따라서 __len__의 return이 N * self.repeat이 되는 것이죠.
__getitem__쪽도 보게 되면, N = self.x.shape[0] 이므로 train_x의 데이터셋 크기가 되고 n = idx % N 이므로 data의 index를 N으로 나눈 결과라고 보시면 되겠습니다.
다음으로 p1, p2, pos = generate_coords_position(256, 256, K)로 만들어지는 것을 보실 수 있는데요.
이는 다음 코드를 통해서 얻게 됩니다.
# datasets.py line 9 - line 40
def generate_coords(H, W, K):
h = np.random.randint(0, H - K + 1)
w = np.random.randint(0, W - K + 1)
return h, w
pos_to_diff = {
0: (-1, -1),
1: (-1, 0),
2: (-1, 1),
3: (0, -1),
4: (0, 1),
5: (1, -1),
6: (1, 0),
7: (1, 1)
}
def generate_coords_position(H, W, K):
with task('P1'):
p1 = generate_coords(H, W, K)
h1, w1 = p1
pos = np.random.randint(8)
with task('P2'):
J = K // 4
K3_4 = 3 * K // 4
h_dir, w_dir = pos_to_diff[pos]
h_del, w_del = np.random.randint(J, size=2)
h_diff = h_dir * (h_del + K3_4)
w_diff = w_dir * (w_del + K3_4)
h2 = h1 + h_diff
w2 = w1 + w_diff
h2 = np.clip(h2, 0, H - K)
w2 = np.clip(w2, 0, W - K)
p2 = (h2, w2)
return p1, p2, pos
generate_coords의 경우는 H와 W, K를 입력으로 받고 랜덤으로 좌표를 만들어주게 됩니다.
예를 들어서, 이미지가 256 x 256이고 우리가 생각하는 패치의 사이즈가 64x64라면
가로와 세로의 좌표가 0부터 192 사이에서 뽑혀야 가로 세로로 64만큼 뽑을 수 있겠죠?
그 좌표를 뽑아준다고 보시면 되겠습니다.
다음으로는 generate_coords_position인데요.
위 함수와 동일하게 H, W, K를 받게 되고요.
generate_coords를 통해서 뽑은 h와 w를 각각 h1, w1로 저장합니다.
그리고 랜덤으로 0부터 7까지의 숫자를 뽑아서 이를 pos로 저장하게 됩니다.
다음으로는 J = K // 4로 지정하고, K3_4 = 3 * K // 4로 지정한 다음
h_dir, w_dir로 곱해줄 상수를 결정합니다. 기준이 되는 patch를 기준으로 3x3 grid를 생각했을 때 하나를 결정하는 것이죠.
그리고 h_del, w_del로 2차원으로 0부터 J-1까지의 숫자 중에 랜덤 하게 뽑아주게 됩니다.
h_diff는 h_dir(3x3 grid 기준 위치)와 h_del + K3_4를 곱해줘서 결정하고, 이를 아까 앞에서 뽑아둔 h1과 w1에 더해줍니다.
그리고 np.clip을 통해서 만약 H-K나 W-K를 넘어가는 경우 clipping 해줍니다.
이는 뽑히는 위치에 따라서 patch를 실제로 뽑을 수 있는 좌표를 넘어가는 경우들이 있기 때문이죠.
마지막으로 결정된 p1(main patch의 시작점)와 p2(neighbor patch의 시작점), 그리고 neighbor patch의 위치인 pos를 결괏값으로 냅니다.
p1, p2가 결정되었으니, 이를 시작점으로 해서 실제 patch를 잘라내야겠죠?
그 작업이 crop_image_CHW 함수를 이용해서 이루어지게 됩니다.
# utils.py line 83 - line 85
def crop_image_CHW(image, coord, K):
h, w = coord
return image[:, h: h + K, w: w + K]
매우 간단한 함수로 짜져 있는데요.
channel은 그대로 사용하고, 시작점인 h와 w를 기준으로 K만큼 이동한 부분만 뽑아내 줍니다.
main patch와 neighbor patch를 각각 뽑아서 patch1과 patch2로 저장합니다.
그리고 논문에서 언급했던 self-supervised learning 과정에서 shortcuts을 학습하는 것을 방지하고자 RGB에 랜덤 하게 값을 추가해주는 작업이 이루어지고, 추가적으로 noise를 추가해줍니다.
이 작업을 통해서 main patch와 neighbor patch, position의 쌍을 구성하게 됩니다.
뽑히는 patch는 다음과 같은 느낌으로 뽑히게 됩니다.
위의 이미지는 제가 opencv를 이용하여 실제 뽑히는 patch를 MVTec AD의 한 데이터에 직접 적용해서 시각화를 한 자료입니다.
빨간색이 main patch이고, 검은색이 neighbor patch라고 보시면 될 것 같습니다.
다음으로는 SVDD_Dataset인데, 이 부분은 거의 사실상 동일하니까 넘어가겠습니다.
DictionaryConcatDataset이라는 함수로 4개의 데이터셋을 합쳐주게 되는데요.
해당 코드를 보게 되면 데이터가 4개의 key와 이에 해당하는 데이터로 data가 나오는 것을 확인할 수 있습니다.
# utils.py line 44 - line 59
class DictionaryConcatDataset(Dataset):
def __init__(self, d_of_datasets):
self.d_of_datasets = d_of_datasets
lengths = [len(d) for d in d_of_datasets.values()]
self._length = min(lengths)
self.keys = self.d_of_datasets.keys()
assert min(lengths) == max(lengths), 'Length of the datasets should be the same'
def __getitem__(self, idx):
return {
key: self.d_of_datasets[key][idx]
for key in self.keys
}
def __len__(self):
return self._length
다음으로는 DataLoader인데 이는 많이 사용되는 코드니 별도로 설명은 생략하겠습니다.
1.2.4 Loss 계산 및 학습 진행
DataLoader까지 만들었으니, 이제는 DataLoader를 이용해서 데이터를 batch 단위로 뽑아낼 수 있게 됩니다.
이를 통해서 각 batch 단위로 loss를 계산하고, 학습을 진행하게 됩니다.
# main_train.py line 55 - line 72
for i_epoch in range(args.epochs):
if i_epoch != 0:
for module in modules:
module.train()
for d in loader:
d = to_device(d, 'cuda', non_blocking=True)
opt.zero_grad()
loss_pos_64 = PositionClassifier.infer(cls_64, enc, d['pos_64'])
loss_pos_32 = PositionClassifier.infer(cls_32, enc.enc, d['pos_32'])
loss_svdd_64 = SVDD_Dataset.infer(enc, d['svdd_64'])
loss_svdd_32 = SVDD_Dataset.infer(enc.enc, d['svdd_32'])
loss = loss_pos_64 + loss_pos_32 + args.lambda_value * (loss_svdd_64 + loss_svdd_32)
loss.backward()
opt.step()
d가 바로 데이터가 되는 것이고요.
먼저 loss_pos_64부터 계산하는 과정을 살펴보겠습니다.
위에서 PositionClassifier를 소개해드렸는데, 이번에는 infer 부분만 보면 되겠습니다.
# networks.py line 242 - line 250
def infer(c, enc, batch):
x1s, x2s, ys = batch
h1 = enc(x1s)
h2 = enc(x2s)
logits = c(h1, h2)
loss = xent(logits, ys)
return loss
xent = nn.CrossEntropyLoss()
infer에서는 classifier와 encoder, batch를 input으로 받게 됩니다.
loss_pos_64 = PositionClassifier.infer(cls_64, enc, d['pos_64'])이므로 64차원 patch를 받는 classifier인 cls_64와 encoder인 enc, 그리고 데이터셋 중에 64x64 짜리 position 정보를 포함하는 batch를 받게 됩니다.
그리고 batch를 x1s, x2s, ys로 분리하고(x1s이 main patch, x2s가 neighbor patch, ys가 position 정보라고 생각하시면 됩니다.) x1s와 x2s를 각각 encoder에 통과시킨 뒤, 이 둘의 결과를 classifier에 통과시켜서 logit을 얻습니다.
logit과 ys, 즉 position 정보를 기준으로 cross-entropy loss를 계산해서 return 합니다.
loss_pos_32도 사실상 동일한 과정을 통해 얻게 되므로, 생략합니다.
다음으로는 loss_svdd_64를 보겠습니다.
이는 SVDD_Dataset.infer를 이용하게 되는데요.
# datasets.py line 106 - line 114
@staticmethod
def infer(enc, batch):
x1s, x2s, = batch
h1s = enc(x1s)
h2s = enc(x2s)
diff = h1s - h2s
l2 = diff.norm(dim=1)
loss = l2.mean()
return loss
사실상 앞에 구한 loss랑 비슷하게 흘러가는 것을 보실 수 있겠죠?
마지막으로 loss는 loss_pos_64 + loss_pos_32 + lambda * (loss_svdd_64 + loss_svdd_32)로 계산됩니다.
이 부분은 논문에 나와있는 내용 그대로 구현이 된 부분이고요.
이를 통해 backward()를 진행하고, optimizer가 step을 하게 됩니다.
마지막으로 매 epoch마다 enc.save(obj)를 통해서 encoder를 저장해주게 됩니다.(main_train.py line 76)
여기까지 train과 관련된 내용을 살펴봤습니다.
어째 좀 자세하게 다룬다 싶으면 글 분량이 엄청 많아지는 기분인데요.
다음으로는 evaluate 부분을 다뤄보겠습니다.
2. Test
해당 Section에서는 학습이 완료된 encoder를 가지고 test를 진행하는 과정을 설명합니다.
이 과정을 통해서 anomaly detection & anomaly segmentation의 AUROC 및 anomaly map을 얻게 됩니다.
2.1 Test의 전체적인 흐름
학습이 완료된 Encoder를 이용해서, training data와 test data를 64차원, 32차원으로 각각 embedding 시켜줍니다.
이를 통해서 64 dim training embedding, 32 dim training embedding, 64 dim test embedding, 32 dim test embedding을 얻게 됩니다.
그리고 64 dim training embedding과 64 dim test embedding을 이용해서 embedding space 기준으로 test embedding과 train embedding 간 L2 distance가 가장 가까운 데이터들을 모으게 됩니다.
동일한 과정을 32 dim에 대해서도 시행하고요.
논문에서 nearest normal patch라고 쓰여있던 부분이 바로 이 부분이라고 보시면 되겠습니다.
이 과정을 거치게 되면 64 dim test embedding과 가장 가까운 training embedding들을 모두 가지게 되고, 이를 이용해서 anomaly map을 만들어줍니다.
마찬가지로 32 dim의 경우도 동일한 과정을 거칩니다.
64 anomaly map과 32 anomaly map을 구했다면, 이를 element-wise addition과 element-wise multiplication을 거쳐 최종적으로 64, 32, mult, sum anomaly map을 얻게 됩니다.
2.2.1 embedding 만들기
첫 번째로 다뤄볼 부분은 training data와 test data를 encoder를 활용하여 embedding space로 mapping 하는 것입니다.
# insepction.py line 38 - line 43
def eval_encoder_NN_multiK(enc, obj):
x_tr = mvtecad.get_x_standardized(obj, mode='train')
x_te = mvtecad.get_x_standardized(obj, mode='test')
embs64_tr = infer(x_tr, enc, K=64, S=16)
print(embs64_tr.shape)
embs64_te = infer(x_te, enc, K=64, S=16)
print(embs64_te.shape)
x_tr = mvtecad.get_x_standardized(obj, mode='train')
x_te = mvtecad.get_x_standardized(obj, mode='test')
embs32_tr = infer(x_tr, enc.enc, K=32, S=4)
embs32_te = infer(x_te, enc.enc, K=32, S=4)
embs64 = embs64_tr, embs64_te
embs32 = embs32_tr, embs32_te
return eval_embeddings_NN_multiK(obj, embs64, embs32)
먼저 데이터를 x_tr와 x_te로 가져오고, 학습된 encoder를 통해서 inference를 진행합니다.
infer는 다음과 같습니다.
# inspection.py line 11 - line 25
def infer(x, enc, K, S):
x = NHWC2NCHW(x)
dataset = PatchDataset_NCHW(x, K=K, S=S)
loader = DataLoader(dataset, batch_size=64, shuffle=False, pin_memory=True)
embs = np.empty((dataset.N, dataset.row_num, dataset.col_num, enc.D), dtype=np.float32) # [-1, I, J, D]
enc = enc.eval()
with torch.no_grad():
for xs, ns, iis, js in loader:
xs = xs.cuda()
embedding = enc(xs)
embedding = embedding.detach().cpu().numpy()
for embed, n, i, j in zip(embedding, ns, iis, js):
embs[n, i, j] = np.squeeze(embed)
return embs
먼저 데이터를 batch / channel / height / width 형태로 바꿔줍니다.
그리고 이를 이용해서 PatchDataset을 만들어줍니다.
# utils.py line 88 - line 127
class PatchDataset_NCHW(Dataset):
def __init__(self, memmap, tfs=None, K=32, S=1):
super().__init__()
self.arr = memmap
self.tfs = tfs
self.S = S
self.K = K
self.N = self.arr.shape[0]
def __len__(self):
return self.N * self.row_num * self.col_num
@property
def row_num(self):
N, C, H, W = self.arr.shape
K = self.K
S = self.S
I = cnn_output_size(H, K=K, S=S)
return I
@property
def col_num(self):
N, C, H, W = self.arr.shape
K = self.K
S = self.S
J = cnn_output_size(W, K=K, S=S)
return J
def __getitem__(self, idx):
N = self.N
n, i, j = np.unravel_index(idx, (N, self.row_num, self.col_num))
K = self.K
S = self.S
image = self.arr[n]
patch = crop_CHW(image, i, j, K, S)
if self.tfs:
patch = self.tfs(patch)
return patch, n, i, j
PatchDataset의 경우는 K(patch의 사이즈)와 S(Stride)를 input으로 받아서 만들어지게 됩니다.
아까 말씀드렸지만, Dataset 객체의 경우는 __len__ 와 __getitem__을 봐야만 어떤 작업이 이루어지는지 파악할 수 있습니다.
먼저 __len__은 self.N * self.row_num * self.col_num이므로 각각이 뭔지 파악해야 합니다.
self.N은 self.arr.shape[0]이며 self.arr는 memmap이므로 이는 데이터셋의 크기를 의미한다는 것을 파악할 수 있습니다.
예를 들어서, bottle class의 training set은 (209, 3, 256, 256)인데, 여기서 self.N은 209가 됩니다.
다음으로 self.row_num을 봐야 하는데, 이는 Stride와 K를 기준으로 row가 몇 개 나올지를 나타내게 됩니다.
예를 들어서, 256 x 256 짜리의 이미지에서 64 x 64 짜리 patch를 stride 16을 적용해서 뽑으면 row와 col이 얼마나 나올지를 나타낸다는 것이죠.
(256 - 64) / 16을 하면 12이므로 총 row와 col은 13개가 나옵니다.
따라서 이를 기반으로 training set이 (209, 3, 256, 256)인 경우 K=64, S=16를 적용 시 Dataset의 크기는 209 * 13 * 13개가 됩니다.
다음으로 __getitem__을 살펴보면 np.unravel_index를 통해서 n, i, j를 만드는 모습을 볼 수 있는데요.
np.unravel_index는 처음 보시는 분들도 계시겠지만, 어떤 크기의 데이터에서 원하는 번째의 좌표를 뽑을 수 있게 됩니다.
예시로, (209, 13, 13)에서 12번째 좌표를 뽑는다면, np.unravel_index(12, (209, 13, 13))이 되고 (0, 0, 12)가 output으로 나오게 됩니다.
이를 통해서 데이터셋 전체 중에서 i번째의 좌표를 계속해서 뽑아낼 수 있으며, 이를 이용해서 patch를 뽑아낼 수 있게 되는 것이죠.
image = self.arr[n]를 이용해서 n번째 데이터를 들고 오고, crop_CHW 함수를 이용해서 i, j 좌표에서 K=64 S=16를 적용해서 원하는 위치의 image patch를 뽑게 됩니다.
예를 들어서, (i, j) = (1, 2)이고 K=64, S=16라고 한다면 crop의 시작점은 (16, 32)가 될 것이고, 이를 기준으로 가로 세로로 64씩 만큼 image patch를 뽑을 수 있게 됩니다.
그리고 patch, n, i, j를 데이터셋의 output으로 만들어주게 되죠.
다음으로는 다시 infer 함수로 돌아가 보겠습니다.
embs는 저희가 결과로 저장할 embedding을 나타내고, embs의 shape는 (-1, row, col, D) 형태가 됩니다.
만약 데이터셋이 (209, 3, 256, 256)이고 K=64, S=16, encoder Dim = 64을 적용한다면 결과로 나오게 되는 embedding은 (209, 13, 13, 64)가 됩니다.
다음으로는 for문을 살펴보겠습니다.
for xs, ns, iis, js in loader: 부분인데요.
아까 PatchDataset에서 patch, n, i, j를 output으로 낸다는 얘기를 했었습니다.
즉, xs는 patch가 되며, 이 xs를 encoder에 투입하여 embedding을 얻게 됩니다. 64차원 embedding을 얻는 것이니, 이 embedding은 (Batch size, 64, 1, 1)의 shape를 가집니다. 두 번째 64는 차원을 나타내는 것이라고 보시면 되겠습니다.
그리고 이를 다시 for embed, n, i, j를 이용해서 각 위치에 64차원 embedding을 투입하게 됩니다.
위의 예시를 이용해서 생각해보자면 n, i, j는 (0, 0, 0)부터 (209, 13, 13)까지 총 209 * 13 * 13개가 나오게 될 것이고, 각각에 64차원짜리 embedding을 채워 넣어서 최종적으로는 (209, 13, 13, 64) 짜리 embedding을 완성하게 됩니다.
동일한 방법으로 test set에 대해서도 진행하여 test embedding을 얻게 됩니다.
32차원의 train embedding과 test embedding도 과정은 동일하니 설명은 넘어가도록 하겠습니다.
2.2.2 Nearest normal patch 찾기
2.2.1에서 64차원의 training embedding, 64차원의 test embedding, 32차원의 training embedding, 32차원의 test embedding을 만들었습니다.
논문에서 언급되었듯이, 이제는 embedding space에서 test embedding을 기준으로 가장 가까운 normal patch를 찾는 작업을 진행하게 됩니다.
# insepction.py line 63 - line 97
def eval_embeddings_NN_multiK(obj, embs64, embs32, NN=1):
emb_tr, emb_te = embs64
maps_64 = measure_emb_NN(emb_te, emb_tr, method='kdt', NN=NN)
maps_64 = distribute_scores(maps_64, (256, 256), K=64, S=16)
det_64, seg_64 = assess_anomaly_maps(obj, maps_64)
emb_tr, emb_te = embs32
maps_32 = measure_emb_NN(emb_te, emb_tr, method='ngt', NN=NN)
maps_32 = distribute_scores(maps_32, (256, 256), K=32, S=4)
det_32, seg_32 = assess_anomaly_maps(obj, maps_32)
maps_sum = maps_64 + maps_32
det_sum, seg_sum = assess_anomaly_maps(obj, maps_sum)
maps_mult = maps_64 * maps_32
det_mult, seg_mult = assess_anomaly_maps(obj, maps_mult)
return {
'det_64': det_64,
'seg_64': seg_64,
'det_32': det_32,
'seg_32': seg_32,
'det_sum': det_sum,
'seg_sum': seg_sum,
'det_mult': det_mult,
'seg_mult': seg_mult,
'maps_64': maps_64,
'maps_32': maps_32,
'maps_sum': maps_sum,
'maps_mult': maps_mult,
}
작업은 모두 동일하게 이루어지니, 64차원 embedding을 기준으로 과정을 설명해보겠습니다.
먼저, embs64에서 emb_tr와 emb_te를 분리합니다.
각각은 64차원의 training embedding과 test embedding을 나타냅니다.
이를 가지고 measure_emb_NN라는 함수를 적용하여 nearest normal patch를 찾아주게 됩니다.
# inspection.py line 102 - line 110
def measure_emb_NN(emb_te, emb_tr, method='kdt', NN=1):
from .nearest_neighbor import search_NN
D = emb_tr.shape[-1]
train_emb_all = emb_tr.reshape(-1, D)
l2_maps, _ = search_NN(emb_te, train_emb_all, method=method, NN=NN)
anomaly_maps = np.mean(l2_maps, axis=-1)
return anomaly_maps
measure_emb_NN는 training embedding과 test embedding을 input으로 받고 nearest normal patch를 연산해주는 함수입니다.
먼저 train_emb_all이라는 변수로 training embedding을 2차원으로 reshape 해주게 됩니다.
예를 들어서 bottle class의 경우, 64차원 training embedding은 (209, 13, 13, 64)이므로, 이를 (209 * 13 * 13, 64)로 바꿔주는 것입니다.
그러고 나서 search_NN라는 함수에 test embedding과 2차원으로 reshape 된 training embedding을 input으로 투입합니다.
# nearest_neighbor.py line 9 - line 28
def search_NN(test_emb, train_emb_flat, NN=1, method='kdt'):
if method == 'ngt':
return search_NN_ngt(test_emb, train_emb_flat, NN=NN)
from sklearn.neighbors import KDTree
kdt = KDTree(train_emb_flat)
Ntest, I, J, D = test_emb.shape
closest_inds = np.empty((Ntest, I, J, NN), dtype=np.int32)
l2_maps = np.empty((Ntest, I, J, NN), dtype=np.float32)
for n in range(Ntest):
for i in range(I):
dists, inds = kdt.query(test_emb[n, i, :, :], return_distance=True, k=NN)
closest_inds[n, i, :, :] = inds[:, :]
l2_maps[n, i, :, :] = dists[:, :]
return l2_maps, closest_inds
아까 만들어놓은 2차원 training embedding을 가지고 KDTree를 만들어주고요.
가장 가까운 train embedding의 index를 저장할 closest_inds와 l2 distance를 저장할 l2_maps를 변수로 저장하고 Ntest, I, J, NN의 shape로 만들어줍니다.
NN은 몇 개의 Nearest normal patch를 찾을지 인데, 우리의 경우에서는 가장 가까운 patch 한 개만 찾으므로, 이는 1로 설정됩니다.
Nest, I, J, D는 test_emb의 shape가 되는데, bottle class의 경우 (83, 13, 13, 64)이므로 각각이 83, 13, 13, 64로 설정됩니다.
다음으로는 for문을 살펴보아야 하는데, 먼저 Ntest에 대해서 for문을 적용합니다. 즉, 이는 test embedding 각각에 대해서 for문을 돈다고 보시면 됩니다.
그리고 for i in range(I)인데, I는 두 번째 차원의 값입니다.
다음으로는 kdt.query를 test_emb[n, i, :, :]를 이용해서 작동시킵니다.
kdt.query는 KDTree를 이용해서 가장 가까운 요소를 찾아주는 코드라고 생각하시면 됩니다.
그리고 test_emb[n, i, :, :]라면 앞의 for문의 n과 i를 이용해서 첫 번째 차원과 두 번째 차원은 골라주고, 나머지 세 번째 차원과 네 번째 차원은 그대로 가져온다는 것을 의미합니다.
예를 들어서, bottle class의 경우 (83, 13, 13, 64)인데, 만약 n =0, i =0이라면 [13, 64] 차원의 데이터가 되겠죠.
이를 이용해서 kdt.query를 적용하게 되면, 가장 가까운 요소가 찾아지고, 이는 [13, 1]의 데이터가 됩니다.
(64차원짜리가 13개가 있는데, 13개 각각이 training embedding 중에서 가장 가까운 데이터를 찾는다 정도로 생각하시면 됩니다. 13개는 딥러닝에서 배치 단위로 데이터를 연산하는 것처럼 한 번에 연산하게 되는 단위? 정도로 보면 되겠네요.)
즉 이는 아까 위에서 언급했던 training embedding을 2차원으로 변경한 (209 * 13 * 13, 64)에서 가장 가까운 요소를 찾은 것이다 라고 보시면 되겠습니다.
이를 closest_inds와 l2_maps에 계속해서 저장해줍니다.
최종적으로 for문 2개를 모두 연산하게 되면, l2_maps와 closest_inds가 만들어지게 되고, 이를 return 해줍니다.
다시 measure_emb_NN로 돌아가면, l2_maps와 closest_inds 중에서 l2_maps만 저장을 하고, 이를 np.mean 해줍니다.
axis = -1인 것을 확인할 수 있는데, 왜냐하면 search_NN을 통해서 얻게 되는 결과물이 (Ntest, I, J, 1)의 shape를 가지기 때문입니다.
이 4차원 데이터를 3차원으로 바꿔주는 역할 정도로 볼 수 있겠네요.
이걸 통해서 anomaly_map을 최종적으로 얻게 됩니다.
bottle class를 기준으로 생각해보면, (209, 13, 13, 64) 짜리 64차원 training embedding과 (83, 13, 13, 64) 짜리 64차원 test embedding을 input으로 집어넣어서 test embedding 기준으로 embedding space에서 가장 가까운 training embedding과의 l2 distance를 다 찾은 뒤, (83, 13, 13) 짜리의 anomaly map을 만들었다! 정도로 이해하면 딱 깔끔한 것 같습니다.
다음으로 살펴볼 내용은 이 anomaly map을 이용해서 실제 논문에서 구현된 anomaly map을 만드는 과정입니다.
논문에서 나온 anomaly map은 이미지의 사이즈와 동일한 사이즈였죠?
그래서 이 (83, 13, 13) 짜리를 (83, 256, 256) 짜리로 만드는 작업이 필요합니다.
이 작업은 distribute_scores라는 함수를 통해서 구현됩니다.
# utils.py line 155 - line 158
def distribute_scores(score_masks, output_shape, K: int, S: int) -> np.ndarray:
N = score_masks.shape[0]
results = [distribute_score(score_masks[n], output_shape, K, S) for n in range(N)]
return np.asarray(results)
score_masks는 구해놓은 (83, 13, 13)라고 생각하시면 되고, output_shape는 (256, 256)로 지정하게 됩니다.
K는 64이며, S는 16입니다. 이는 기존에 학습을 진행할 때 정해둔 값을 그대로 전달하면 됩니다.
코드를 보니, distribute_score라는 함수를 적용해서 list로 만든 다음 이를 numpy array로 return 하는 코드네요.
그럼 distribute_score를 봐야겠습니다.
# utils.py line 161 - line 176
def distribute_score(score_mask, output_shape, K: int, S: int) -> np.ndarray:
H, W = output_shape
mask = np.zeros([H, W], dtype=np.float32)
cnt = np.zeros([H, W], dtype=np.int32)
I, J = score_mask.shape[:2]
for i in range(I):
for j in range(J):
h, w = i * S, j * S
mask[h: h + K, w: w + K] += score_mask[i, j]
cnt[h: h + K, w: w + K] += 1
cnt[cnt == 0] = 1
return mask / cnt
위의 distribute_scores 함수에서, distribute_score(score_masks[n])을 하는 것을 확인할 수 있는데요.
즉 test embedding 한 개에 대해서 distribute_score 함수를 적용하는 것을 확인할 수 있습니다.
bottle class의 경우 (83, 13, 13)이었는데, 여기서 (13, 13)만 빼서 distribute_score에 투입시킨다는 의미가 되겠죠?
먼저 mask와 cnt라는 변수를 지정해서 (256, 256) shape로 만들어줍니다.
그리고 I, J를 score_mask의 shape 중에서 첫 번째와 두 번째 요소로 저장해줍니다.
bottle class에서 I = 13, J = 13가 됩니다.
for i in range(I)와 for j in range(J)를 통해서 I = 0, J = 0부터 I = 0, J = 12, I = 12, J = 12까지 반복문을 도는 것을 확인할 수 있습니다.
h, w를 i * S와 J * S로 저장하고요.
예를 들어서 I = 0, J = 0이면 h = 0, w = 0이 됩니다.
그리고 mask[h: h+K, w: w+K] += score_mask[i, j]의 작업을 수행하게 되는데, 이는 score_mask의 [i, j]의 요소를 K만큼 분배해주는 것을 의미합니다.
이는 이전에 Encoding을 진행할 때, K 크기의 patch를 S 만큼의 stride를 적용해서 Encoding 했던 것을 다시 원상복구 해주는 것이라고 이해해볼 수 있습니다.
그리고 cnt는 작업이 진행될 때마다 +1 씩 해주는데, 이는 이미지의 영역 중에서 score_mask가 여러 번 더해지는 구간이 있기 때문입니다.
예를 들어서, mask의 [0:16, 0:16]은 score_mask[0, 0]에 의해서 단 한번 distribute 받게 되지만, 다른 구간은 두 번 이상씩 distribute를 받을 수 있습니다.
따라서 이를 counting 해주고, 마지막에 return 과정에서 distribute 받은 개수만큼 나눠주는 작업을 수행하게 됩니다.
만약 이미지 전체를 돌았는데도 cnt가 0인 경우가 있다면 이는 1로 지정해줍니다. return mask / cnt이기 때문에 만약 cnt가 0이 되어버리면 ZeroDivisionError가 발생하기 때문이겠죠?
최종적으로 정리해보면, distribute_scores 함수를 통해서 test embedding 각각에 대해 Encoding 당시 지정한 K와 S값을 가지고서 test embedding을 test anomaly map으로 만들어주는 과정을 진행하게 됩니다.
test embedding (83, 13, 13) => test anomaly map(83, 256, 256)으로 변환이 되게 됩니다.
이를 통해서 assess_anomaly_maps 함수를 거치게 됩니다.
# inspection.py line 32 - line 37
def assess_anomaly_maps(obj, anomaly_maps):
auroc_seg = mvtecad.segmentation_auroc(obj, anomaly_maps)
anomaly_scores = anomaly_maps.max(axis=-1).max(axis=-1)
auroc_det = mvtecad.detection_auroc(obj, anomaly_scores)
return auroc_det, auroc_seg
assess_anomaly_maps 함수는 우리가 방금 얻은 anomaly_map을 가지고 segmentation auroc와 detection auroc를 계산하는 함수가 되겠습니다.
detection auroc를 계산할 때는 우리가 구한 anomaly map 중에서 가장 큰 값을 anomaly score로 계산합니다.
(.max(axis=-1).max(axis=-1)를 참고해보시면 됩니다. 이렇게 하면 각 이미지 당 가장 높은 값이 나오게 됩니다.)
위 과정을 32차원 embedding에 대해서도 동일하게 적용하고, 64차원 anomaly map과 32차원 anomaly map을 element-wise addition과 element-wise multiplication을 해주면 최종적으로 모든 결과가 도출되게 됩니다.
여기까지 Training과 Inference에 대한 부분을 대략적으로 살펴보았습니다.
엄청 디테일하게 모든 코드를 다루기에는 코드의 양이 굉장히 많아 조금 어려울법한 내용들을 위주로 조금 디테일하게 설명하였고, 나머지는 대부분 이미 구현된 library를 쓰거나 했기 때문에 조금만 찾아봐도 내용이 나올 것이라고 생각합니다.
3. 실제 코드를 구동하면서 주의해야 할 점
마지막으로는 제가 Patch SVDD 모델을 사용하면서 주의해야 할 점이라고 생각하는 부분입니다.
(1) Dataset의 양이 많아지면 / 데이터의 크기가 크면 연산량이 기하급수적으로 늘어납니다.
저는 제가 연구하고 있는 Custom dataset을 가지고 있는데, training 데이터가 3000장 이상, train 데이터가 약 1000장 정도 가지고 있습니다.
이미지의 크기 또한 해당 모델에서 사용한 256x256보다 훨씬 큰 상황입니다.
따라서 inference를 한 번 시행하는데만 거의 10시간 가까이 소요되므로, 기존 main_train.py에 코드가 짜져 있는 대로 1 epoch 당 AUROC 성능 체크를 진행하게 되면 1 epoch 당 10시간씩 소요되게 됩니다.
그래서 제가 실제로 모델을 사용할 때는 1 epoch 당 AUROC 성능 체크하는 코드를 제외했습니다.
그리고 기존 코드에서 구현되어 있지 않았던 epoch 당 loss graph를 만들어주는 코드를 추가하여 loss가 정상적으로 우하향하는지를 점검하는 방식으로 변경하였습니다.
데이터셋의 양이 많아지면 연산량이 기하급수적으로 늘어나는 이유는, inference 시 Nearest normal patch를 찾아줘야 하는데 이미지의 사이즈가 커지면 당연히 embedding vector의 크기도 커지고 데이터의 양이 많아지면 전체적인 장수가 많아지므로 search 해줘야 하는 대상인 test embedding도 늘어나고 search 할 대상인 train embedding도 늘어나면서 전체적으로 연산량이 증가하게 됩니다.
(2) Dataset마다 최적의 lambda를 찾아줘야 합니다.
Dataset마다 최적의 lambda가 다릅니다.
논문에서도 언급되었지만, 데이터셋에 있는 이미지들의 특성에 따라서 lambda가 커야 좋을 수도 있고 작아야 좋을 수도 있기 때문이죠.
따라서 자신이 사용하는 Dataset에 적합한 lambda가 얼마일지는 실험을 통해서 찾아야만 합니다.
제 경우에서는 lambda를 올리면 성능이 떨어지게 되어, 0.001을 사용하고 있습니다.
저자의 Github에는 이와 관련된 Issue들이 올라온 상태이며, 저자가 추가적으로 설명해주고 있으니 이를 참고해보시면 좋을 듯합니다.
https://github.com/nuclearboy95/Anomaly-Detection-PatchSVDD-PyTorch/issues/10
그리고 얘기하는 내용을 보면 MVTec AD dataset에 대해서 각 class 별로 최적의 hyperparameter가 있어 보이는데, 별도로 저자가 공개하지는 않았습니다.
그 대신, 각 class에 대해서 학습이 완료된 weight를 공개하고 있어 이를 통해 논문에 나온 성능을 복원할 수 있는 상황입니다.
여기까지 Patch SVDD의 code review를 마무리 지어보려고 합니다.
워낙 내용이 많고 코드도 많다 보니까 글이 많이 길어진 감이 있네요.
그래도 저 또한 글을 작성하면서 다시 한번 내용을 조금 더 디테일하게 점검해볼 수 있는 계기가 되었네요.
다음 글에서는 또 새로운 논문을 가지고 찾아오겠습니다.
해당 논문에 대한 코드는 제 Github에서 확인하실 수 있습니다.
https://github.com/PeterKim1/paper_code_review/tree/master/10.%20Patch%20SVDD
감사합니다.