임의의 3차원 특성 벡터를 가정하자. 이 벡터는 1개의 큰 클러스터(샘플 100개)와 또다른 1개의 작은 클러스터(샘플 30)를 형성하고 있다. 그리고 나머지 10개는 이 두개의 클러스터와 거리를 두고 흩어져 있다. 이와 같은 특성 벡터 샘플을 이용하여 정상치와 이상치를 구분하는 LOF를 구현하는 파이썬 코드를 작성해본다.

특성 공간에서 정상임에도 소규모 클러스터를 형성하게 되는 예를 MVTechAD 데이터셋에서 설명
가죽은 천연 소재이기 때문에 표면의 패턴(Grain)이 일정하지 않습니다.
상황: 가죽 제품의 표면을 확대해서 보면, 어떤 부분은 아주 매끄럽고(Smooth), 어떤 부분은 미세한 주름이나 거친 질감(Rough texture)이 있습니다.
특징 공간에서의 분포:
대규모 클러스터 (Main Cluster): 가죽의 가장 일반적이고 평균적인 질감을 가진 패치들이 아주 크게 모여 있습니다. chance
소규모 클러스터 (Small Cluster): 가죽 특유의 아주 미세한 '깊은 주름'이 나타나는 패치들이 있습니다. 이 패치들은 서로는 매우 비슷하지만(자기들끼리 뭉쳐 있음), 전체적인 가죽의 평균적인 질감과는 확연히 다릅니다.
가우시안 모델의 오류: 가우시안 모델은 전체 평균을 기준으로 삼기 때문에, 이 '주름진 부분(소규모 클러스터)'을 "평균적인 매끄러운 가죽과는 너무 다르게 생겼어!"라고 판단하여 결함(Anomaly)으로 분류해 버립니다.
금속 재질은 빛을 받는 각도에 따라 하이라이트(밝은 점)와 그림자(어두운 부분)가 매우 강하게 나타납니다.
상황: 금속 너트를 촬영하면, 빛이 강하게 반사되어 아주 밝은 부분(Specular Highlight)이 생기고, 반대로 아주 어두운 그림자 부분도 생깁니다.
특징 공간에서의 분포:
대규모 클러스터 (Main Cluster): 금속 본연의 색상을 나타내는 대부분의 패치들이 모여 있는 거대한 영역입니다.
소규모 클러스터 (Small Cluster): 아주 밝게 빛나는 '하이라이트 패치'들이 모여 있는 작은 영역, 그리고 아주 어두운 '그림자 패치'들이 모여 있는 또 다른 작은 영역이 존재합니다.
가우시안 모델의 오류: 가우시안 모델은 "금속의 평균적인 밝기"를 기준으로 삼습니다. 따라서 하이라이트 패치나 그림자 패치는 평균에서 멀리 떨어져 있기 때문에, 모델은 이들을 "금속 표면에 생긴 얼룩이나 변색(Anomaly)"으로 오인하여 제거해 버립니다.
전체 샘플 수: 140 (큰 클러스터 100 / 작은 클러스터 30 / 흩어진 점 10)
직접 구현한 LOF vs sklearn LOF 점수 상관계수: 1.0000 (1.0에 가까울수록 구현이 정확함)
threshold τ=0.15 적용 → 상위 21개 패치를 이상치로 판별
판별된 이상치의 실제 소속 집단 분포:
large_cluster와 small_cluster는 서로 거리가 멀리 떨어져 있습니다.
만약 가우시안 모델을 쓴다면, 5번(Small Cluster)은 전체 평균에서 너무 멀기 때문에 모두 빨간색(이상치)으로 변할 것입니다.
하지만 LOF는 각 데이터의 '국소적 밀도'를 보기 때문에, 30개의 샘플이 자기들끼리 뭉쳐 있다면 그들을 하나의 '정상 그룹'으로 인식합니다.
파란색 점들: large_cluster의 데이터들.
초록색 점들 (코드에서는 파란색으로 통합 표시될 수 있음): small_cluster의 데이터들. LOF가 이들을 "주변에 친구들이 많네?"라고 판단하여 정상으로 분류합니다.
빨간색 점들: outliers로 설정된 10개의 점들. 주변에 친구가 없으므로 정확히 이상치로 분류됩니다.
이 값이 너무 작으면(예: 2) 너무 예민해져서 정상 데이터도 이상치로 몰아붙일 수 있고, 너무 크면(예: 100) 소규모 클러스터를 흡수해버릴 수 있습니다. 코드에서는 10으로 설정하여 적절한 국소 범위를 갖도록 했습니다.
이 코드를 실행하면, 3차원 공간상에 떠 있는 데이터들이 "전체 기준이 아닌, 자기 주변을 기준으로" 어떻게 분류되는지 시각적으로 명확하게 확인할 수 있습니다.
"""
LOF(Local Outlier Factor)를 이용한 정상치/이상치 판별 예제
- SoftPatch 논문 3.2.3절의 수식 (4), (5), (6)을 그대로 구현
- 데이터 구성: 큰 클러스터(100개) + 작은 클러스터(30개) + 흩어진 이상치(10개)
- sklearn의 LocalOutlierFactor와 결과를 비교하여 구현이 올바른지 검증
"""
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
# 한글 폰트 설정
for font_path in fm.findSystemFonts():
if "Nanum" in font_path:
fm.fontManager.addfont(font_path)
plt.rcParams["font.family"] = fm.FontProperties(fname=font_path).get_name()
break
plt.rcParams["axes.unicode_minus"] = False
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (3D 프로젝션 등록)
from sklearn.neighbors import NearestNeighbors
from sklearn.neighbors import LocalOutlierFactor
np.random.seed(42)
# ----------------------------------------------------------------------
# 1. 3차원 특성 벡터 샘플 생성
# - 큰 클러스터: 100개, 중심 (0, 0, 0) 근처, 밀도 높음(표준편차 작음)
# - 작은 클러스터: 30개, 중심 (6, 6, 6) 근처, 밀도 낮음(표준편차 조금 큼)
# - 흩어진 이상치: 10개, 두 클러스터에서 멀리 떨어진 무작위 위치
# ----------------------------------------------------------------------
n_big, n_small, n_outlier = 100, 30, 10
big_cluster = np.random.normal(loc=[0, 0, 0], scale=0.8, size=(n_big, 3))
small_cluster = np.random.normal(loc=[6, 6, 6], scale=1.2, size=(n_small, 3))
# 두 클러스터와 거리를 두고 무작위로 흩어진 점들
outliers = np.random.uniform(low=-10, high=16, size=(n_outlier, 3))
# 혹시 클러스터 근처에 우연히 생성되면 밀어내기(간단한 후처리)
for i in range(n_outlier):
while (np.linalg.norm(outliers[i] - [0, 0, 0]) < 4 or
np.linalg.norm(outliers[i] - [6, 6, 6]) < 4):
outliers[i] = np.random.uniform(low=-10, high=16, size=3)
X = np.vstack([big_cluster, small_cluster, outliers])
labels_true = np.array(
["big_cluster"] * n_big + ["small_cluster"] * n_small + ["scattered"] * n_outlier
)
print(f"전체 샘플 수: {X.shape[0]} (큰 클러스터 {n_big} / 작은 클러스터 {n_small} / 흩어진 점 {n_outlier})")
# ----------------------------------------------------------------------
# 2. LOF를 논문 수식(4)~(6) 그대로 구현
# W_LOF_i = ( sum_{b in Nk(i)} lrd_b ) / ( |Nk(i)| * lrd_i )
# -> 값이 1에 가까우면 정상(이웃과 밀도가 비슷함)
# 값이 클수록 이상치(자신의 밀도가 이웃보다 훨씬 낮음)
# ----------------------------------------------------------------------
def compute_lof(X, k=6):
n = X.shape[0]
# k+1개를 구하는 이유: 첫 번째 이웃은 자기 자신이므로 제외하기 위함
nn = NearestNeighbors(n_neighbors=k + 1).fit(X)
distances, indices = nn.kneighbors(X)
distances = distances[:, 1:] # 자기 자신(거리 0) 제외
indices = indices[:, 1:]
# k-distance: k번째 이웃까지의 거리 (수식 5의 dist_k)
k_distance = distances[:, -1]
# reachability distance (수식 5)
# dist_reach_k(i, b) = max(dist_k(b), d(i, b))
reach_dist = np.zeros((n, k))
for i in range(n):
for j, b in enumerate(indices[i]):
reach_dist[i, j] = max(k_distance[b], distances[i, j])
# local reachability density (수식 4)
lrd = 1.0 / (reach_dist.mean(axis=1) + 1e-12)
# 상대 밀도 = LOF 점수 (수식 6)
lof_score = np.zeros(n)
for i in range(n):
neighbor_lrd = lrd[indices[i]]
lof_score[i] = neighbor_lrd.mean() / (lrd[i] + 1e-12)
return lof_score, lrd, k_distance
k = 6 # 논문에서 사용한 기본값(LOF-K = 6)
lof_score_manual, lrd_manual, kdist_manual = compute_lof(X, k=k)
# ----------------------------------------------------------------------
# 3. sklearn의 LocalOutlierFactor로 검증
# (sklearn은 negative_outlier_factor_ = -LOF 를 반환하므로 부호를 뒤집어 비교)
# ----------------------------------------------------------------------
sk_lof = LocalOutlierFactor(n_neighbors=k)
sk_lof.fit_predict(X)
lof_score_sklearn = -sk_lof.negative_outlier_factor_
corr = np.corrcoef(lof_score_manual, lof_score_sklearn)[0, 1]
print(f"직접 구현한 LOF vs sklearn LOF 점수 상관계수: {corr:.4f} (1.0에 가까울수록 구현이 정확함)")
# ----------------------------------------------------------------------
# 4. 이상치 판별 (논문의 threshold tau 방식: 상위 tau% 를 이상치로 간주)
# ----------------------------------------------------------------------
tau = 0.15 # 논문 기본값 15%
n_remove = int(np.ceil(len(X) * tau))
threshold_idx = np.argsort(lof_score_manual)[::-1][:n_remove]
is_outlier = np.zeros(len(X), dtype=bool)
is_outlier[threshold_idx] = True
print(f"\nthreshold τ={tau} 적용 → 상위 {n_remove}개 패치를 이상치로 판별")
print("판별된 이상치의 실제 소속 집단 분포:")
unique, counts = np.unique(labels_true[is_outlier], return_counts=True)
for u, c in zip(unique, counts):
print(f" - {u}: {c}개")
# ----------------------------------------------------------------------
# 5. 3D 시각화 : 정상(파랑) vs 이상치(빨강) + LOF 점수 크기로 표현
# ----------------------------------------------------------------------
fig = plt.figure(figsize=(14, 6))
# (a) 실제 소속 클러스터 기준 (Ground Truth)
ax1 = fig.add_subplot(1, 2, 1, projection="3d")
colors = {"big_cluster": "tab:blue", "small_cluster": "tab:green", "scattered": "tab:red"}
for label in np.unique(labels_true):
mask = labels_true == label
ax1.scatter(X[mask, 0], X[mask, 1], X[mask, 2],
c=colors[label], label=label, s=40, alpha=0.7)
ax1.set_title("(a) 실제 데이터 구성 (Ground Truth)")
ax1.legend()
# (b) LOF 계산 결과 기준 (정상 vs 이상치)
ax2 = fig.add_subplot(1, 2, 2, projection="3d")
sc = ax2.scatter(X[~is_outlier, 0], X[~is_outlier, 1], X[~is_outlier, 2],
c=lof_score_manual[~is_outlier], cmap="viridis", s=40,
edgecolors="black", linewidths=0.3,
label="정상(inlier)",
vmin=lof_score_manual[~is_outlier].min(),
vmax=lof_score_manual[~is_outlier].max())
ax2.scatter(X[is_outlier, 0], X[is_outlier, 1], X[is_outlier, 2],
c="red", marker="x", s=80, linewidths=2, label=f"이상치(top {tau*100:.0f}%)")
ax2.set_title("(b) LOF 기반 이상치 판별 결과")
ax2.legend()
fig.colorbar(sc, ax=ax2, shrink=0.6, label="LOF score (정상 데이터)")
plt.tight_layout()
#plt.savefig("/mnt/user-data/outputs/lof_result.png", dpi=150)
print("\n시각화 결과 저장: /mnt/user-data/outputs/lof_result.png")