--- 데이터 로드 완료 ---
총 데이터 개수: 564개
클래스별 개수: {1: 361, 0: 203}
최종 AUROC Score: 0.9124
이 문서는 mimii_patchcore 프로젝트가 MIMII(산업 기계 소음) 데이터셋을 어떻게 분석하는지, 그리고 PatchCore 논문(Roth et al., Towards Total Recall in Industrial Anomaly Detection, CVPR 2022)의 핵심 아이디어가 코드의 어느 부분에 어떻게 구현되어 있는지를 처음부터 끝까지 정리한 것입니다.
MIMII는 원래 소리(1차원 파형) 데이터입니다. 그런데 이 프로젝트가 쓰는 PatchCore는 원래 이미지 이상탐지를 위한 알고리즘입니다. 이 둘을 잇는 핵심 아이디어는 다음과 같습니다.
소음의 파형을 Mel-스펙트로그램(2D 이미지)으로 변환하면, "이미지 안에 이상 부위가 있는가"를 찾는 PatchCore를 그대로 재사용할 수 있다.
그래서 전체 파이프라인은 크게 두 단계로 나뉩니다.
audio.py) : 소리를 PatchCore가 이해할 수 있는 224×224 이미지로 바꿈feature_extractor.py, patchcore.py) : 그 이미지에서 "정상 소음과 얼마나 다른가"를 점수로 계산그리고 이 위에 학습(train.py), 평가(test.py, evaluate.py), 시각화(visualize_anomaly.py)가 얹혀 있는 구조입니다.
data/MIMII/normal/*.wav ─┐
data/MIMII/abnormal/*.wav─┤
▼
[dataset.py] 정상 8:2 분할 (train/test)
비정상은 test에만 사용
│
┌───────────┴───────────┐
▼ ▼
정상 train(80%) 정상+비정상 test(전체)
│ │
┌────────┴────────┐ │
▼ ▼ ▼
[audio.py] [feature_extractor.py] ...(동일 파이프라인)
wav → 224×224 ResNet18 layer2,3
로그멜 스펙트로그램 특징맵 추출
│ │
└────────┬────────┘
▼
[patchcore.py]
locally aware patch feature
(p=3 이웃 average pooling)
│
▼
┌───────┴───────┐
▼ ▼
train.py: test.py:
greedy coreset memory bank에 대해
subsampling k-NN 검색 +
→ memory bank 재가중치(reweighting)
저장(.npy) → anomaly score
│
▼
evaluate.py: ROC-AUC, F1
visualize_anomaly.py: 스펙트로그램 위에
이상 부위 히트맵 오버레이
audio.py)PatchCore는 ImageNet으로 미리 학습된 CNN을 그대로 쓰기 때문에, 입력이 3채널 224×224 이미지여야 합니다. wav_to_tensor()가 이 변환을 담당합니다.
| 단계 | 함수 | 내용 |
|---|---|---|
| 1 | load_audio() | wav 파일을 16kHz로 로드하고, 10초 길이로 자르거나 0으로 패딩 (파일마다 길이가 달라도 항상 같은 크기가 되도록) |
| 2 | make_logmel() | librosa로 Log-Mel 스펙트로그램 생성 (n_fft=1024, hop_length=512, n_mels=128) → [128, 313] 크기의 2D 배열 |
| 3 | spectrogram_to_tensor() | 0~1로 정규화 → 224×224로 bilinear 리사이즈 → 흑백 1채널을 3채널로 복제(R=G=B) → ImageNet 평균/표준편차로 정규화 |
왜 3채널로 복제하는가?: ResNet은 원래 RGB 3채널 이미지로 사전학습되어 있어서, 흑백 스펙트로그램을 그대로 넣으면 채널 수가 안 맞습니다. 3채널에 같은 값을 복제해 넣으면 사전학습된 필터를 그대로 재사용할 수 있습니다 (실제로는 "회색조 이미지"를 넣는 것과 동일한 효과).
feature_extractor.py)ResNetFeatureExtractor는 ImageNet으로 사전학습된 ResNet18을 쓰고, forward hook을 이용해 layer2, layer3의 중간 출력을 가로챕니다.
weights = models.ResNet18_Weights.DEFAULT
self.model = models.resnet18(weights=weights)
self.model.layer2.register_forward_hook(...)
self.model.layer3.register_forward_hook(...)
논문은 이미지 분류를 위한 마지막 레이어 대신, 중간 레이어(mid-level feature) 를 쓰라고 말합니다. 마지막 레이어는 "이게 고양이냐 강아지냐"를 구분하는 데 특화된, 너무 추상적이고 ImageNet 편향적인 정보이기 때문입니다. 산업 이상탐지에서는 오히려 "질감/패턴이 국소적으로 얼마나 다른가"가 중요하므로, 논문은 layer2 + layer3 (WideResNet50 기준) 조합을 씁니다.
이 코드는 논문의 기본 백본인 WideResNet50 대신 ResNet18을 사용합니다. config.py에 그 이유가 명시되어 있습니다.
'''
논문의 PatchCore 구현에서 WideResNet50과 layer2, layer3 조합을 사용하는 설정이 대표적이지만,
MIMII를 Mac에서 연구용으로 재현할 때는 ResNet18로 먼저 pipeline을 검증하는 것이 훨씬 편하다.
'''
BACKBONE = "resnet18"
LAYERS = ["layer2", "layer3"]
즉 "layer2 + layer3를 쓴다"는 논문의 설계 원칙은 그대로 따르되, 계산량이 적은 ResNet18로 먼저 파이프라인을 검증하는 실용적 선택입니다. 논문의 backbone ablation(Table S6)에서도 backbone 종류에 따른 성능 차이는 ±1%p 이내로 크지 않았기 때문에, 결과의 "경향"을 보는 데는 무리가 없지만, 논문이 보고한 절대 수치(AUROC 99%대)를 그대로 재현하긴 어려울 수 있다는 점은 감안해야 합니다.
patchcore.py::aggregate_features)여기서부터가 PatchCore의 핵심입니다. layer2, layer3에서 나온 특징맵([B, C, H, W])을, "각 위치가 하나의 패치를 대표하는" 벡터 집합으로 바꿉니다.
특징맵의 한 위치 값만 그대로 쓰면, 아주 작은 국소적 흔들림(spatial variation)에도 민감하게 반응합니다. 논문은 이를 완화하기 위해, 각 위치를 주변 이웃의 평균으로 다시 정의합니다.
def local_neighborhood_pooling(feature, patch_size=3):
padding = patch_size // 2
return F.avg_pool2d(feature, kernel_size=patch_size, stride=1, padding=padding)
config.py의 PATCH_NEIGHBORHOOD_SIZE = 3이 논문이 실험적으로 찾은 최적값 과 동일합니다(논문 Figure 4 상단 — 일 때 가장 성능이 낮고, 에서 최고점을 찍음). MIMII 스펙트로그램에서는 이 pooling 덕분에, 짧은 튐(spike)이나 국소적인 주파수 변화 하나에 과민 반응하지 않고 좀 더 "면적을 가진 패턴"으로 이상 여부를 판단하게 됩니다.
layer2(공간 해상도가 더 큼, 얕은 특징)와 layer3(공간 해상도가 더 작음, 더 추상적인 특징)를 합치기 위해, layer3를 layer2의 해상도로 bilinear 업샘플링한 뒤 채널 방향으로 concat합니다.
layer3 = F.interpolate(layer3, size=(h, w), mode="bilinear", align_corners=False)
features = torch.cat([layer2_patches, layer3_patches], dim=-1)
최종적으로 하나의 패치는 layer2의 채널(128) + layer3의 채널(256) = 384차원 벡터가 되고, 이미지 한 장(224×224)에서 총 28×28 = 784개의 패치 벡터가 나옵니다.
정리: 이미지 한 장 = 784개의 384차원 "지역 특징 벡터"의 집합. PatchCore는 이미지 전체를 하나로 보지 않고, 이 784개의 패치 각각을 독립적으로 정상/비정상 판단 대상으로 삼습니다. 이게 "이미지 하나가 이상하려면, 그 안의 패치 하나만 이상해도 충분하다"는 논문의 핵심 전제입니다.
train.py, patchcore.py::CoresetSampler)정상(nominal) 학습 데이터 전체 파일에서 뽑은 패치 벡터를 다 모으면(all_features), 수십만 개 이상이 됩니다. 이걸 memory bank로 그대로 쓰면 저장 공간도 크고, 테스트 시 매번 이 큰 집합 전체와 거리 비교를 해야 해서 느립니다. 그래서 논문은 가장 "대표성" 있는 일부만 남기는 coreset selection을 제안합니다.
"전체 데이터 중 대표점들로부터 가장 멀리 떨어진 점까지의 거리"를 최소화하는 부분집합을 찾는 문제입니다. 이 문제는 NP-hard라 정확히 풀 수 없어서, 논문은 greedy 근사 알고리즘(Algorithm 1)을 씁니다.
CoresetSampler._greedy_sample)1. 임의의 점 하나를 첫 center로 선택
2. 반복:
- 현재 center들로부터 가장 멀리 떨어진 점을 찾아 새 center로 추가
- 새 center를 기준으로 "각 점의 최근접 center까지 거리"를 갱신
3. 목표 개수(target)만큼 반복
계산 비용을 줄이기 위해 두 가지 장치를 추가로 씁니다.
CORESET_PROJECTION_DIM)으로 줄여서 거리 계산을 가볍게 함train.py가 계산한 device(MPS/CUDA/CPU)를 그대로 넘겨받아 GPU 가속 가능config.py의 관련 설정:
CORESET_RATIO = 0.01 # 전체 patch 중 몇 %를 memory bank로 남길지
CORESET_METHOD = "greedy" # greedy(논문 방식) / random(비교용) 중 선택
CORESET_PROJECTION_DIM = 128 # 거리 계산용 random projection 차원
train.py는 모든 정상 학습 파일에서 패치를 뽑아 all_features로 합친 뒤, 이 CoresetSampler로 줄여서 memory/MIMII_memory.npy에 저장하고, 학습/테스트에 쓰인 파일 목록(dataset_split.npz)도 함께 저장합니다.
patchcore.py::PatchCoreMemory, test.py)테스트 이미지의 각 패치 에 대해, memory bank 에서 가장 가까운 벡터 까지의 거리를 구합니다. 이 거리들 중 최댓값(=memory bank로 가장 설명이 안 되는 패치)이 그 이미지의 이상 점수 가 됩니다.
코드에서는 faiss.IndexFlatL2로 memory bank에 대한 brute-force 최근접 이웃 검색을 합니다 (PatchCoreMemory.fit()에서 인덱스를 만들고, predict()에서 검색).
논문은 단순 최대 거리 대신, memory bank 안에서 가 얼마나 "고립"되어 있는지까지 반영합니다.
PatchCoreMemory.predict())# 1) 각 test patch의 최근접 memory 벡터(m*)와 거리(s*)
sq_distances, nn_indices = self.index.search(features, self.k)
s_star = np.sqrt(sq_distances[:, 0]) # faiss가 squared L2를 반환하므로 sqrt
# 2) m*마다, memory bank 안에서 m* 자신의 b-nearest-neighbor 탐색
...
neighbor_idx = neighbor_idx[:, 1:] # 자기 자신 제외
# 3) test patch → 그 이웃들까지의 거리
dist_to_neighbors = np.linalg.norm(features[:, None, :] - neighbor_vectors_per_patch, axis=-1)
# 4) Eq(7) softmax형 재가중치
weight = 1.0 - ratio
patch_scores = weight * s_star
score = float(np.max(patch_scores)) # image-level anomaly score
config.py의 REWEIGHT_NUM_NEIGHBORS = 3이 이 식의 에 해당합니다(논문이 정확한 기본값을 명시하지 않아, 공식 구현을 참고해 설정).
test.py, evaluate.py)test.pytrain.py가 저장해 둔 dataset_split.npz에서 정상 테스트 파일과 비정상 파일을 불러옵니다 (비정상은 애초에 학습에 전혀 쓰이지 않았다는 점이 중요합니다 — PatchCore는 "cold-start"라고 해서, 비정상 예시를 한 번도 보지 않고 정상 데이터만으로 이상탐지 모델을 만듭니다).calculate_score()(오디오→특징 추출→patch화→PatchCoreMemory.predict())를 실행해 anomaly score를 계산합니다.label(0=정상, 1=비정상)과 함께 results/Test_results.csv에 저장합니다.evaluate.pyTest_results.csv를 읽어 ROC-AUC를 계산합니다 (임계값에 무관하게, 정상/비정상을 얼마나 잘 분리하는 점수인지를 나타내는 지표 — 논문의 핵심 성능 지표와 동일).visualize_anomaly.py)한 개의 오디오 파일을 골라, 다음 4단계를 하나의 그림으로 보여줍니다.
이 부분이 바로 논문이 말하는 anomaly localization(이상 부위 국소화) 을 MIMII(오디오)에 적용한 것입니다 — 이미지에서는 "어느 픽셀이 결함인가"였다면, 여기서는 "스펙트로그램의 어느 시간·주파수 영역이 비정상적인 소리 패턴인가"로 대응됩니다.
config.py)| 설정 | 값 | 의미 |
|---|---|---|
SAMPLE_RATE | 16000 | 오디오 샘플링 레이트 |
DURATION | 10.0초 | 모든 오디오를 이 길이로 통일 |
N_FFT, HOP_LENGTH, N_MELS | 1024, 512, 128 | Mel 스펙트로그램 파라미터 |
IMAGE_SIZE | 224 | ResNet 입력 이미지 크기 |
BACKBONE | resnet18 | 논문(WideResNet50)보다 가벼운 백본 (검증용) |
LAYERS | layer2, layer3 | 논문과 동일한 mid-level hierarchy 선택 |
PATCH_NEIGHBORHOOD_SIZE | 3 | locally aware patch feature의 이웃 크기 (논문 기본값과 동일) |
CORESET_RATIO | 0.01 | memory bank로 남길 patch 비율 |
CORESET_METHOD | greedy | greedy coreset(논문 방식) vs random(비교용) |
CORESET_PROJECTION_DIM | 128 | coreset 거리 계산용 random projection 차원 |
REWEIGHT_NUM_NEIGHBORS | 3 | anomaly score 재가중치의 이웃 개수 |
TRAIN_RATIO | 0.8 | 정상 데이터 중 학습에 쓸 비율 |
| PatchCore 논문 | 코드 위치 |
|---|---|
| §3.1 mid-level feature (layer , ) | feature_extractor.py (layer2, layer3 hook) |
| §3.1 Eq 1~2, locally aware patch (이웃 pooling, ) | patchcore.py::local_neighborhood_pooling |
| §3.1 hierarchy 결합 (bilinear 리사이즈 + concat) | patchcore.py::aggregate_features |
| §3.2 Eq 5, minimax facility location coreset | patchcore.py::CoresetSampler._greedy_sample |
| §3.2 Johnson-Lindenstrauss random projection | patchcore.py::CoresetSampler._random_projection |
| §3.2 Algorithm 1 | train.py의 coreset 구축 흐름 전체 |
| §3.3 Eq 6, 최근접 이웃 거리 기반 image score | patchcore.py::PatchCoreMemory (faiss.IndexFlatL2) |
| §3.3 Eq 7, anomaly score reweighting | patchcore.py::PatchCoreMemory.predict() |
| §3.3 anomaly segmentation(국소화) | visualize_anomaly.py의 anomaly map 생성/오버레이 |
| §4.2 image-level AUROC 평가 | evaluate.py |
# 1) 정상 데이터로 memory bank 구축 (greedy coreset 포함)
python train.py
# 2) 정상+비정상 테스트 데이터에 대해 anomaly score 계산
python test.py
# 3) ROC-AUC / F1 평가
python evaluate.py
# 4) (선택) 특정 파일 하나를 골라 이상 부위 시각화
python visualize_anomaly.py
train.py가 memory/MIMII_memory.npy와 memory/dataset_split.npz를 만들어야, test.py와 visualize_anomaly.py가 그걸 불러와서 동작합니다. patchcore.py의 patch feature 정의(예: PATCH_NEIGHBORHOOD_SIZE)를 바꾸면, memory bank가 그 정의로 다시 만들어져야 하므로 train.py부터 재실행해야 합니다.
test.py의 매 파일 nearest-neighbor 검색 비용이 거의 선형으로 늘어나, 체감상 실행 시간이 크게 늘어날 수 있습니다.faiss(OpenMP 기반)와 torch의 mps 백엔드를 한 프로세스에서 같이 쓰면 스레딩 충돌로 세그폴트가 날 수 있어, train.py/test.py/visualize_anomaly.py 모두 최상단에 KMP_DUPLICATE_LIB_OK, OMP_NUM_THREADS=1 환경변수와 faiss.omp_set_num_threads(1)을 설정해 두었습니다.visualize_anomaly.py에는 이 단계가 없습니다 (성능보다는 시각화 품질에 관한 요소로, 우선순위는 낮습니다).