Skip to content

Commit 2575a51

Browse files
authored
Revert "Updated coreset subsampling method to improve accuracy (#73)" (#79)
This reverts commit 3613a6e.
1 parent 3613a6e commit 2575a51

File tree

9 files changed

+435
-88
lines changed

9 files changed

+435
-88
lines changed

README.md

+35-35
Large diffs are not rendered by default.

anomalib/models/patchcore/README.md

+6-6
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,22 @@ All results gathered with seed `42`.
2828

2929
| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
3030
| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
31-
| Wide ResNet-50 | 0.980 | 0.984 | 0.959 | 1.000 | 1.000 | 0.989 | 1.000 | 0.990 | 0.982 | 1.000 | 0.994 | 0.924 | 0.960 | 0.933 | 1.000 | 0.982 |
32-
| ResNet-18 | 0.973 | 0.970 | 0.947 | 1.000 | 0.997 | 0.997 | 1.000 | 0.986 | 0.965 | 1.000 | 0.991 | 0.916 | 0.943 | 0.931 | 0.996 | 0.953 |
31+
| ResNet-18 | 0.819 | 0.947 | 0.722 | 0.997 | 0.982 | 0.988 | 0.972 | 0.810 | 0.586 | 0.981 | 0.631 | 0.780 | 0.482 | 0.827 | 0.733 | 0.844 |
32+
| Wide ResNet-50 | 0.877 | 0.981 | 0.842 | 1.0 | 0.991 | 0.991 | 0.985 | 0.868 | 0.763 | 0.988 | 0.914 | 0.769 | 0.427 | 0.806 | 0.878 | 0.958 |
3333

3434
### Pixel-Level AUC
3535

3636
| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
3737
| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
38-
| Wide ResNet-50 | 0.980 | 0.988 | 0.968 | 0.991 | 0.961 | 0.934 | 0.984 | 0.988 | 0.988 | 0.987 | 0.989 | 0.980 | 0.989 | 0.988 | 0.981 | 0.983 |
39-
| ResNet-18 | 0.976 | 0.986 | 0.955 | 0.990 | 0.943 | 0.933 | 0.981 | 0.984 | 0.986 | 0.986 | 0.986 | 0.974 | 0.991 | 0.988 | 0.974 | 0.983 |
38+
| ResNet-18 | 0.935 | 0.979 | 0.843 | 0.989 | 0.934 | 0.925 | 0.956 | 0.923 | 0.942 | 0.967 | 0.913 | 0.931 | 0.924 | 0.958 | 0.881 | 0.954 |
39+
| Wide ResNet-50 | 0.955 | 0.988 | 0.903 | 0.990 | 0.957 | 0.936 | 0.972 | 0.950 | 0.968 | 0.974 | 0.960 | 0.948 | 0.917 | 0.969 | 0.913 | 0.976 |
4040

4141
### Image F1 Score
4242

4343
| | Avg | Carpet | Grid | Leather | Tile | Wood | Bottle | Cable | Capsule | Hazelnut | Metal Nut | Pill | Screw | Toothbrush | Transistor | Zipper |
4444
| -------------- | :---: | :----: | :---: | :-----: | :---: | :---: | :----: | :---: | :-----: | :------: | :-------: | :---: | :---: | :--------: | :--------: | :----: |
45-
| Wide ResNet-50 | 0.976 | 0.971 | 0.974 | 1.000 | 1.000 | 0.967 | 1.000 | 0.968 | 0.982 | 1.000 | 0.984 | 0.940 | 0.943 | 0.938 | 1.000 | 0.979 |
46-
| ResNet-18 | 0.970 | 0.949 | 0.946 | 1.000 | 0.982 | 0.992 | 1.000 | 0.978 | 0.969 | 1.000 | 0.989 | 0.940 | 0.932 | 0.935 | 0.974 | 0.967 |
45+
| ResNet-18 | 0.896 | 0.933 | 0.857 | 0.995 | 0.964 | 0.983 | 0.959 | 0.790 | 0.908 | 0.964 | 0.903 | 0.916 | 0.853 | 0.866 | 0.653 | 0.898 |
46+
| Wide ResNet-50 | 0.923 | 0.961 | 0.875 | 1.0 | 0.989 | 0.975 | 0.984 | 0.832 | 0.908 | 0.972 | 0.920 | 0.922 | 0.853 | 0.862 | 0.842 | 0.953 |
4747

4848
### Sample Results
4949

anomalib/models/patchcore/config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ model:
2424
layers:
2525
- layer2
2626
- layer3
27-
coreset_sampling_ratio: 0.1
27+
coreset_sampling_ratio: 0.001
2828
num_neighbors: 9
2929
metric: auc
3030
weight_file: weights/model.ckpt

anomalib/models/patchcore/model.py

+28-46
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,17 @@
2424
import torchvision
2525
from kornia import gaussian_blur2d
2626
from omegaconf import ListConfig
27-
from sklearn import random_projection
2827
from torch import Tensor, nn
2928

3029
from anomalib.core.model import AnomalyModule
3130
from anomalib.core.model.dynamic_module import DynamicBufferModule
3231
from anomalib.core.model.feature_extractor import FeatureExtractor
3332
from anomalib.data.tiler import Tiler
33+
from anomalib.models.patchcore.utils.sampling import (
34+
KCenterGreedy,
35+
NearestNeighbors,
36+
SparseRandomProjection,
37+
)
3438

3539

3640
class AnomalyMapGenerator:
@@ -123,6 +127,7 @@ def __init__(
123127

124128
self.feature_extractor = FeatureExtractor(backbone=self.backbone(pretrained=True), layers=self.layers)
125129
self.feature_pooler = torch.nn.AvgPool2d(3, 1, 1)
130+
self.nn_search = NearestNeighbors(n_neighbors=9)
126131
self.anomaly_map_generator = AnomalyMapGenerator(input_size=input_size)
127132

128133
if apply_tiling:
@@ -165,8 +170,7 @@ def forward(self, input_tensor: Tensor) -> Union[torch.Tensor, Tuple[torch.Tenso
165170
if self.training:
166171
output = embedding
167172
else:
168-
distances = torch.cdist(embedding, self.memory_bank, p=2.0) # euclidean norm
169-
patch_scores, _ = distances.topk(k=9, largest=False, dim=1)
173+
patch_scores, _ = self.nn_search.kneighbors(embedding)
170174

171175
anomaly_map, anomaly_score = self.anomaly_map_generator(patch_scores=patch_scores)
172176
output = (anomaly_map, anomaly_score)
@@ -209,48 +213,25 @@ def reshape_embedding(embedding: Tensor) -> Tensor:
209213
embedding = embedding.permute(0, 2, 3, 1).reshape(-1, embedding_size)
210214
return embedding
211215

212-
def create_coreset(
213-
self,
214-
embedding: Tensor,
215-
sample_count: int = 500,
216-
eps: float = 0.90,
217-
):
218-
"""Creates n subsampled coreset for given sample_set.
216+
@staticmethod
217+
def subsample_embedding(embedding: torch.Tensor, sampling_ratio: float) -> torch.Tensor:
218+
"""Subsample embedding based on coreset sampling.
219219
220220
Args:
221-
embedding (Tensor): (sample_count, d) tensor of patches.
222-
sample_count (int): Number of patches to select.
223-
eps (float): Parameter for spare projection aggression.
221+
embedding (np.ndarray): Embedding tensor from the CNN
222+
sampling_ratio (float): Coreset sampling ratio
223+
224+
Returns:
225+
np.ndarray: Subsampled embedding whose dimensionality is reduced.
224226
"""
225-
# TODO: https://github.com/openvinotoolkit/anomalib/issues/54
226-
# Replace print statement with logger.
227-
print("Fitting random projections...")
228-
try:
229-
transformer = random_projection.SparseRandomProjection(eps=eps)
230-
sample_set = torch.tensor(transformer.fit_transform(embedding.cpu())).to( # pylint: disable=not-callable
231-
embedding.device
232-
)
233-
except ValueError:
234-
# TODO: https://github.com/openvinotoolkit/anomalib/issues/54
235-
# Replace print statement with logger.
236-
print(" Error: could not project vectors. Please increase `eps` value.")
237-
238-
select_idx = 0
239-
last_item = sample_set[select_idx : select_idx + 1]
240-
coreset_idx = [torch.tensor(select_idx).to(embedding.device)] # pylint: disable=not-callable
241-
min_distances = torch.linalg.norm(sample_set - last_item, dim=1, keepdims=True)
242-
243-
for _ in range(sample_count - 1):
244-
distances = torch.linalg.norm(sample_set - last_item, dim=1, keepdims=True) # broadcast
245-
min_distances = torch.minimum(distances, min_distances) # iterate
246-
select_idx = torch.argmax(min_distances) # select
247-
248-
last_item = sample_set[select_idx : select_idx + 1]
249-
min_distances[select_idx] = 0
250-
coreset_idx.append(select_idx)
251-
252-
coreset_idx = torch.stack(coreset_idx)
253-
self.memory_bank = embedding[coreset_idx]
227+
# Random projection
228+
random_projector = SparseRandomProjection(eps=0.9)
229+
random_projector.fit(embedding)
230+
231+
# Coreset Subsampling
232+
sampler = KCenterGreedy(model=random_projector, embedding=embedding, sampling_ratio=sampling_ratio)
233+
coreset = sampler.sample_coreset()
234+
return coreset
254235

255236

256237
class PatchcoreLightning(AnomalyModule):
@@ -311,10 +292,12 @@ def training_epoch_end(self, outputs):
311292
outputs (List[Dict[str, np.ndarray]]): List of embedding vectors
312293
"""
313294
embedding = torch.vstack([output["embedding"] for output in outputs])
314-
315295
sampling_ratio = self.hparams.model.coreset_sampling_ratio
316296

317-
self.model.create_coreset(embedding=embedding, sample_count=int(sampling_ratio * embedding.shape[0]), eps=0.9)
297+
embedding = self.model.subsample_embedding(embedding, sampling_ratio)
298+
299+
self.model.nn_search.fit(embedding)
300+
self.model.memory_bank = embedding
318301

319302
def validation_step(self, batch, _): # pylint: disable=arguments-differ
320303
"""Get batch of anomaly maps from input image batch.
@@ -328,8 +311,7 @@ def validation_step(self, batch, _): # pylint: disable=arguments-differ
328311
Dict[str, Any]: Image filenames, test images, GT and predicted label/masks
329312
"""
330313

331-
anomaly_maps, anomaly_score = self.model(batch["image"])
314+
anomaly_maps, _ = self.model(batch["image"])
332315
batch["anomaly_maps"] = anomaly_maps
333-
batch["pred_scores"] = anomaly_score.unsqueeze(0)
334316

335317
return batch
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Helper utilities for PatchCore model."""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Patchcore sampling utils."""
2+
3+
from .k_center_greedy import KCenterGreedy
4+
from .nearest_neighbors import NearestNeighbors
5+
from .random_projection import SparseRandomProjection
6+
7+
__all__ = ["KCenterGreedy", "NearestNeighbors", "SparseRandomProjection"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""This module comprises PatchCore Sampling Methods for the embedding.
2+
3+
- k Center Greedy Method
4+
Returns points that minimizes the maximum distance of any point to a center.
5+
. https://arxiv.org/abs/1708.00489
6+
"""
7+
8+
from typing import List, Optional
9+
10+
import torch
11+
import torch.nn.functional as F
12+
from torch import Tensor
13+
14+
from .random_projection import SparseRandomProjection
15+
16+
17+
class KCenterGreedy:
18+
"""Implements k-center-greedy method.
19+
20+
Args:
21+
model: model with scikit-like API with decision_function. Defaults to SparseRandomProjection.
22+
embedding (Tensor): Embedding vector extracted from a CNN
23+
sampling_ratio (float): Ratio to choose coreset size from the embedding size.
24+
25+
Example:
26+
>>> embedding.shape
27+
torch.Size([219520, 1536])
28+
>>> sampler = KCenterGreedy(embedding=embedding)
29+
>>> sampled_idxs = sampler.select_coreset_idxs()
30+
>>> coreset = embedding[sampled_idxs]
31+
>>> coreset.shape
32+
torch.Size([219, 1536])
33+
"""
34+
35+
def __init__(self, model: SparseRandomProjection, embedding: Tensor, sampling_ratio: float) -> None:
36+
self.model = model
37+
self.embedding = embedding
38+
self.coreset_size = int(embedding.shape[0] * sampling_ratio)
39+
40+
self.features: Tensor
41+
self.min_distances: Optional[Tensor] = None
42+
self.n_observations = self.embedding.shape[0]
43+
self.already_selected_idxs: List[int] = []
44+
45+
def reset_distances(self) -> None:
46+
"""Reset minimum distances."""
47+
self.min_distances = None
48+
49+
def get_new_cluster_centers(self, cluster_centers: List[int]) -> List[int]:
50+
"""Get new cluster center indexes from the list of cluster indexes.
51+
52+
Args:
53+
cluster_centers (List[int]): List of cluster center indexes.
54+
55+
Returns:
56+
List[int]: List of new cluster center indexes.
57+
"""
58+
return [d for d in cluster_centers if d not in self.already_selected_idxs]
59+
60+
def update_distances(self, cluster_centers: List[int]) -> None:
61+
"""Update min distances given cluster centers.
62+
63+
Args:
64+
cluster_centers (List[int]): indices of cluster centers
65+
"""
66+
67+
if cluster_centers:
68+
cluster_centers = self.get_new_cluster_centers(cluster_centers)
69+
centers = self.features[cluster_centers]
70+
71+
distance = F.pairwise_distance(self.features, centers, p=2).reshape(-1, 1)
72+
73+
if self.min_distances is None:
74+
self.min_distances = torch.min(distance, dim=1).values.reshape(-1, 1)
75+
else:
76+
self.min_distances = torch.minimum(self.min_distances, distance)
77+
78+
def get_new_idx(self) -> int:
79+
"""Get index value of a sample.
80+
81+
Based on (i) either minimum distance of the cluster or (ii) random subsampling from the embedding.
82+
83+
Returns:
84+
int: Sample index
85+
"""
86+
87+
if self.already_selected_idxs is None or len(self.already_selected_idxs) == 0:
88+
# Initialize centers with a randomly selected datapoint
89+
idx = int(torch.randint(high=self.n_observations, size=(1,)).item())
90+
else:
91+
if isinstance(self.min_distances, Tensor):
92+
idx = int(torch.argmax(self.min_distances).item())
93+
else:
94+
raise ValueError(f"self.min_distances must be of type Tensor. Got {type(self.min_distances)}")
95+
96+
return idx
97+
98+
def select_coreset_idxs(self, selected_idxs: Optional[List[int]] = None) -> List[int]:
99+
"""Greedily form a coreset to minimize the maximum distance of a cluster.
100+
101+
Args:
102+
selected_idxs: index of samples already selected. Defaults to an empty set.
103+
104+
Returns:
105+
indices of samples selected to minimize distance to cluster centers
106+
"""
107+
108+
if selected_idxs is None:
109+
selected_idxs = []
110+
111+
if self.embedding.ndim == 2:
112+
self.features = self.model.transform(self.embedding)
113+
self.reset_distances()
114+
else:
115+
self.features = self.embedding.reshape(self.embedding.shape[0], -1)
116+
self.update_distances(cluster_centers=selected_idxs)
117+
118+
selected_coreset_idxs: List[int] = []
119+
for _ in range(self.coreset_size):
120+
idx = self.get_new_idx()
121+
if idx in selected_idxs:
122+
raise ValueError("New indices should not be in selected indices.")
123+
124+
self.update_distances(cluster_centers=[idx])
125+
selected_coreset_idxs.append(idx)
126+
127+
self.already_selected_idxs = selected_idxs
128+
129+
return selected_coreset_idxs
130+
131+
def sample_coreset(self, selected_idxs: Optional[List[int]] = None) -> Tensor:
132+
"""Select coreset from the embedding.
133+
134+
Args:
135+
selected_idxs: index of samples already selected. Defaults to an empty set.
136+
137+
Returns:
138+
Tensor: Output coreset
139+
140+
Example:
141+
>>> embedding.shape
142+
torch.Size([219520, 1536])
143+
>>> sampler = KCenterGreedy(...)
144+
>>> coreset = sampler.sample_coreset()
145+
>>> coreset.shape
146+
torch.Size([219, 1536])
147+
"""
148+
149+
idxs = self.select_coreset_idxs(selected_idxs)
150+
coreset = self.embedding[idxs]
151+
152+
return coreset
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""This module comprises PatchCore Sampling Methods for the embedding.
2+
3+
- Nearest Neighbours
4+
"""
5+
6+
# Copyright (C) 2020 Intel Corporation
7+
#
8+
# Licensed under the Apache License, Version 2.0 (the "License");
9+
# you may not use this file except in compliance with the License.
10+
# You may obtain a copy of the License at
11+
#
12+
# http://www.apache.org/licenses/LICENSE-2.0
13+
#
14+
# Unless required by applicable law or agreed to in writing,
15+
# software distributed under the License is distributed on an "AS IS" BASIS,
16+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
# See the License for the specific language governing permissions
18+
# and limitations under the License.
19+
20+
from typing import Tuple
21+
22+
import torch
23+
from torch import Tensor
24+
25+
from anomalib.core.model.dynamic_module import DynamicBufferModule
26+
27+
28+
class NearestNeighbors(DynamicBufferModule):
29+
"""Nearest Neighbours using brute force method and euclidean norm.
30+
31+
Args:
32+
n_neighbors (int): Number of neighbors to look at
33+
"""
34+
35+
def __init__(self, n_neighbors: int):
36+
super().__init__()
37+
self.n_neighbors = n_neighbors
38+
39+
self.register_buffer("_fit_x", Tensor())
40+
self._fit_x: Tensor
41+
42+
def fit(self, train_features: Tensor):
43+
"""Saves the train features for NN search later.
44+
45+
Args:
46+
train_features (Tensor): Training data
47+
"""
48+
self._fit_x = train_features
49+
50+
def kneighbors(self, test_features: Tensor) -> Tuple[Tensor, Tensor]:
51+
"""Return k-nearest neighbors.
52+
53+
It is calculated based on bruteforce method.
54+
55+
Args:
56+
test_features (Tensor): test data
57+
58+
Returns:
59+
Tuple[Tensor, Tensor]: distances, indices
60+
"""
61+
distances = torch.cdist(test_features, self._fit_x, p=2.0) # euclidean norm
62+
return distances.topk(k=self.n_neighbors, largest=False, dim=1)

0 commit comments

Comments
 (0)