hee_!J commited on
Commit ·
77d44e1
1
Parent(s): 8a48888
feat: Tier 1 이상 탐지 SECOM 기반 구현
Browse files- agents/detection.py +57 -5
- data/secom/preprocess.py +42 -0
- experiments/__init__.py +0 -0
- experiments/tier1_detection/__init__.py +0 -0
- experiments/tier1_detection/benchmark.py +147 -0
- experiments/tier1_detection/plots/pr.png +0 -0
- experiments/tier1_detection/plots/roc.png +0 -0
- experiments/tier1_detection/results.md +27 -0
- requirements.txt +2 -0
agents/detection.py
CHANGED
|
@@ -1,11 +1,63 @@
|
|
| 1 |
-
"""Tier 1 이상 탐지 에이전트
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from core.schema import Tier1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
def run_detection(alarm: dict) -> Tier1:
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tier 1 이상 탐지 에이전트
|
| 2 |
|
| 3 |
+
알람을 SECOM 웨이퍼 row에 매핑해 이상 점수와 기여 피처를 계산
|
| 4 |
+
모델: IsolationForest (experiments/tier1_detection 벤치마크에서 PR-AUC 가장 우수)
|
| 5 |
+
이상 점수는 학습 분포 대비 백분위로 0~1 정규화
|
| 6 |
+
기여 피처는 표준화 값의 절대크기 Top-N (정상 분포에서 가장 벗어난 센서)
|
| 7 |
"""
|
| 8 |
+
from functools import lru_cache
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
from sklearn.ensemble import IsolationForest
|
| 12 |
+
|
| 13 |
from core.schema import Tier1
|
| 14 |
+
from data.secom.loader import load_secom
|
| 15 |
+
from data.secom.preprocess import SecomPreprocessor
|
| 16 |
+
|
| 17 |
+
RANDOM_STATE = 42
|
| 18 |
+
TOP_N_FEATURES = 3
|
| 19 |
+
|
| 20 |
+
# SECOM은 익명화 데이터라 lot/step이 없음, MVP에선 알람을 특정 웨이퍼 row에 수동 매핑
|
| 21 |
+
# secom_row: fail 라벨 row 인덱스, wafers: 데모 컨텍스트상 영향 웨이퍼 수
|
| 22 |
+
ALARM_WAFER = {
|
| 23 |
+
"A1": {"secom_row": 2, "wafers": 25},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@lru_cache(maxsize=1)
|
| 28 |
+
def _fit_model():
|
| 29 |
+
"""SECOM 전체로 전처리기와 IsolationForest를 학습, 첫 호출 시 1회만"""
|
| 30 |
+
X, _ = load_secom()
|
| 31 |
+
pre = SecomPreprocessor().fit(X)
|
| 32 |
+
Xz = pre.transform(X)
|
| 33 |
+
model = IsolationForest(n_estimators=200, random_state=RANDOM_STATE)
|
| 34 |
+
model.fit(Xz)
|
| 35 |
+
train_scores = -model.score_samples(Xz)
|
| 36 |
+
return X, pre, model, train_scores
|
| 37 |
|
| 38 |
|
| 39 |
def run_detection(alarm: dict) -> Tier1:
|
| 40 |
+
mapping = ALARM_WAFER.get(alarm["id"])
|
| 41 |
+
if mapping is None:
|
| 42 |
+
raise ValueError(f"SECOM 매핑이 없는 알람: {alarm['id']}")
|
| 43 |
+
|
| 44 |
+
X, pre, model, train_scores = _fit_model()
|
| 45 |
+
Xz = pre.transform(X.iloc[[mapping["secom_row"]]])
|
| 46 |
+
|
| 47 |
+
raw_score = float(-model.score_samples(Xz)[0])
|
| 48 |
+
# 학습 분포 대비 백분위, 높을수록 이상
|
| 49 |
+
score = float((train_scores < raw_score).mean())
|
| 50 |
+
|
| 51 |
+
# 표준화 값의 절대크기 = 정상 분포에서 벗어난 정도, Top-N
|
| 52 |
+
deviations = np.abs(Xz[0])
|
| 53 |
+
top_idx = np.argsort(deviations)[::-1][:TOP_N_FEATURES]
|
| 54 |
+
features = [
|
| 55 |
+
{"name": pre.keep_cols[i], "value": round(float(deviations[i]), 2)}
|
| 56 |
+
for i in top_idx
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
"score": round(score, 2),
|
| 61 |
+
"features": features,
|
| 62 |
+
"lot": {"id": alarm["lot_id"], "wafers": mapping["wafers"]},
|
| 63 |
+
}
|
data/secom/preprocess.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SECOM 전처리
|
| 2 |
+
|
| 3 |
+
이상 탐지 모델에 넣기 전 공통 전처리
|
| 4 |
+
- 전부 결측이거나 분산이 0(상수)인 컬럼 제거
|
| 5 |
+
- 남은 결측치는 중앙값으로 임퓨테이션
|
| 6 |
+
- StandardScaler로 스케일링
|
| 7 |
+
|
| 8 |
+
agents/detection.py와 experiments/ 양쪽에서 공용으로 사용
|
| 9 |
+
fit은 train 데이터에만, transform은 train/test 공통으로 적용
|
| 10 |
+
"""
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pandas as pd
|
| 13 |
+
from sklearn.impute import SimpleImputer
|
| 14 |
+
from sklearn.preprocessing import StandardScaler
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SecomPreprocessor:
|
| 18 |
+
"""SECOM 센서 데이터 전처리, sklearn 스타일 fit/transform"""
|
| 19 |
+
|
| 20 |
+
def __init__(self, var_threshold: float = 0.0):
|
| 21 |
+
self.var_threshold = var_threshold
|
| 22 |
+
self.keep_cols: list[str] = []
|
| 23 |
+
self.imputer = SimpleImputer(strategy="median")
|
| 24 |
+
self.scaler = StandardScaler()
|
| 25 |
+
|
| 26 |
+
def fit(self, X: pd.DataFrame) -> "SecomPreprocessor":
|
| 27 |
+
# 전부 결측인 컬럼 제거 후, 분산이 임계 이하(거의 상수)인 컬럼도 제거
|
| 28 |
+
non_empty = X.columns[X.notna().any()]
|
| 29 |
+
variances = X[non_empty].var()
|
| 30 |
+
self.keep_cols = list(variances[variances > self.var_threshold].index)
|
| 31 |
+
|
| 32 |
+
kept = X[self.keep_cols]
|
| 33 |
+
self.imputer.fit(kept)
|
| 34 |
+
self.scaler.fit(self.imputer.transform(kept))
|
| 35 |
+
return self
|
| 36 |
+
|
| 37 |
+
def transform(self, X: pd.DataFrame) -> np.ndarray:
|
| 38 |
+
kept = X[self.keep_cols]
|
| 39 |
+
return self.scaler.transform(self.imputer.transform(kept))
|
| 40 |
+
|
| 41 |
+
def fit_transform(self, X: pd.DataFrame) -> np.ndarray:
|
| 42 |
+
return self.fit(X).transform(X)
|
experiments/__init__.py
ADDED
|
File without changes
|
experiments/tier1_detection/__init__.py
ADDED
|
File without changes
|
experiments/tier1_detection/benchmark.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tier 1 이상 탐지 모델 벤치마크
|
| 2 |
+
|
| 3 |
+
SECOM pass/fail 라벨을 정답으로 보고 비지도 이상 탐지 모델을 비교
|
| 4 |
+
- IsolationForest
|
| 5 |
+
- LocalOutlierFactor
|
| 6 |
+
- OneClassSVM
|
| 7 |
+
- baseline: 표준화 피처 절대값 평균 (단순 통계)
|
| 8 |
+
|
| 9 |
+
평가: ROC-AUC, PR-AUC (fail이 6.6%뿐인 불균형 데이터라 PR-AUC를 주지표로 봄)
|
| 10 |
+
train에만 fit, test에서 평가 (70/30 stratified split)
|
| 11 |
+
|
| 12 |
+
실행: python -m experiments.tier1_detection.benchmark
|
| 13 |
+
결과: results.md 표 + plots/ 그래프
|
| 14 |
+
"""
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import matplotlib
|
| 18 |
+
|
| 19 |
+
matplotlib.use("Agg")
|
| 20 |
+
import matplotlib.pyplot as plt
|
| 21 |
+
import numpy as np
|
| 22 |
+
from sklearn.ensemble import IsolationForest
|
| 23 |
+
from sklearn.metrics import (
|
| 24 |
+
average_precision_score,
|
| 25 |
+
precision_recall_curve,
|
| 26 |
+
roc_auc_score,
|
| 27 |
+
roc_curve,
|
| 28 |
+
)
|
| 29 |
+
from sklearn.model_selection import train_test_split
|
| 30 |
+
from sklearn.neighbors import LocalOutlierFactor
|
| 31 |
+
from sklearn.svm import OneClassSVM
|
| 32 |
+
|
| 33 |
+
from data.secom.loader import load_secom
|
| 34 |
+
from data.secom.preprocess import SecomPreprocessor
|
| 35 |
+
|
| 36 |
+
RANDOM_STATE = 42
|
| 37 |
+
OUT_DIR = Path(__file__).parent
|
| 38 |
+
PLOTS_DIR = OUT_DIR / "plots"
|
| 39 |
+
|
| 40 |
+
MODELS = {
|
| 41 |
+
"IsolationForest": IsolationForest(n_estimators=200, random_state=RANDOM_STATE),
|
| 42 |
+
"LocalOutlierFactor": LocalOutlierFactor(n_neighbors=20, novelty=True),
|
| 43 |
+
"OneClassSVM": OneClassSVM(nu=0.1, kernel="rbf", gamma="scale"),
|
| 44 |
+
"baseline": None,
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def anomaly_scores(name, model, X_train, X_test):
|
| 49 |
+
"""모델별 이상 점수 반환, 높을수록 이상"""
|
| 50 |
+
if name == "baseline":
|
| 51 |
+
# 표준화된 피처의 절대값 평균, 정상에서 멀수록 큼
|
| 52 |
+
return np.abs(X_test).mean(axis=1)
|
| 53 |
+
model.fit(X_train)
|
| 54 |
+
# score_samples는 높을수록 정상이므로 부호 반전
|
| 55 |
+
return -model.score_samples(X_test)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def main():
|
| 59 |
+
X, y = load_secom()
|
| 60 |
+
y_true = (y == 1).astype(int).to_numpy() # 1 = fail(이상)
|
| 61 |
+
|
| 62 |
+
X_train_df, X_test_df, y_train, y_test = train_test_split(
|
| 63 |
+
X, y_true, test_size=0.3, stratify=y_true, random_state=RANDOM_STATE
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
pre = SecomPreprocessor().fit(X_train_df)
|
| 67 |
+
X_train = pre.transform(X_train_df)
|
| 68 |
+
X_test = pre.transform(X_test_df)
|
| 69 |
+
|
| 70 |
+
PLOTS_DIR.mkdir(exist_ok=True)
|
| 71 |
+
fig_roc, ax_roc = plt.subplots(figsize=(6, 5))
|
| 72 |
+
fig_pr, ax_pr = plt.subplots(figsize=(6, 5))
|
| 73 |
+
|
| 74 |
+
results = []
|
| 75 |
+
for name, model in MODELS.items():
|
| 76 |
+
scores = anomaly_scores(name, model, X_train, X_test)
|
| 77 |
+
roc = roc_auc_score(y_test, scores)
|
| 78 |
+
ap = average_precision_score(y_test, scores)
|
| 79 |
+
results.append((name, roc, ap))
|
| 80 |
+
|
| 81 |
+
fpr, tpr, _ = roc_curve(y_test, scores)
|
| 82 |
+
ax_roc.plot(fpr, tpr, label=f"{name} (AUC={roc:.3f})")
|
| 83 |
+
prec, rec, _ = precision_recall_curve(y_test, scores)
|
| 84 |
+
ax_pr.plot(rec, prec, label=f"{name} (AP={ap:.3f})")
|
| 85 |
+
|
| 86 |
+
# 참조선, plot 텍스트는 폰트 의존 피하려고 영문 사용
|
| 87 |
+
ax_roc.plot([0, 1], [0, 1], "k--", alpha=0.4, label="random")
|
| 88 |
+
ax_roc.set_xlabel("False Positive Rate")
|
| 89 |
+
ax_roc.set_ylabel("True Positive Rate")
|
| 90 |
+
ax_roc.set_title("Tier 1 Anomaly Detection - ROC")
|
| 91 |
+
ax_roc.legend(loc="lower right", fontsize=8)
|
| 92 |
+
|
| 93 |
+
ax_pr.axhline(y_test.mean(), color="k", ls="--", alpha=0.4, label="random")
|
| 94 |
+
ax_pr.set_xlabel("Recall")
|
| 95 |
+
ax_pr.set_ylabel("Precision")
|
| 96 |
+
ax_pr.set_title("Tier 1 Anomaly Detection - Precision-Recall")
|
| 97 |
+
ax_pr.legend(loc="upper right", fontsize=8)
|
| 98 |
+
|
| 99 |
+
fig_roc.tight_layout()
|
| 100 |
+
fig_roc.savefig(PLOTS_DIR / "roc.png", dpi=120)
|
| 101 |
+
fig_pr.tight_layout()
|
| 102 |
+
fig_pr.savefig(PLOTS_DIR / "pr.png", dpi=120)
|
| 103 |
+
|
| 104 |
+
write_results(results, len(X_train_df), len(X_test_df), int(y_test.sum()))
|
| 105 |
+
for name, roc, ap in sorted(results, key=lambda r: -r[2]):
|
| 106 |
+
print(f"{name:24s} ROC-AUC={roc:.3f} PR-AUC={ap:.3f}")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def write_results(results, n_train, n_test, n_test_fail):
|
| 110 |
+
"""results.md에 평가 표 기록, PR-AUC 내림차순 정렬"""
|
| 111 |
+
ranked = sorted(results, key=lambda r: -r[2])
|
| 112 |
+
lines = [
|
| 113 |
+
"# Tier 1 이상 탐지 - 모델 벤치마크",
|
| 114 |
+
"",
|
| 115 |
+
"SECOM 데이터셋(반도체 제조 공정 센서)으로 비지도 이상 탐지 모델을 비교합니다.",
|
| 116 |
+
"",
|
| 117 |
+
"## 설정",
|
| 118 |
+
"",
|
| 119 |
+
f"- train {n_train} / test {n_test} (70/30 stratified split)",
|
| 120 |
+
f"- test의 fail(이상) 샘플: {n_test_fail}건",
|
| 121 |
+
"- 전처리: 전결측/상수 컬럼 제거 -> 중앙값 임퓨테이션 -> 표준화",
|
| 122 |
+
"- 평가 지표: ROC-AUC, PR-AUC (불균형 데이터라 PR-AUC가 주지표)",
|
| 123 |
+
"",
|
| 124 |
+
"## 결과 (PR-AUC 내림차순)",
|
| 125 |
+
"",
|
| 126 |
+
"| 모델 | ROC-AUC | PR-AUC |",
|
| 127 |
+
"|---|---|---|",
|
| 128 |
+
]
|
| 129 |
+
for name, roc, ap in ranked:
|
| 130 |
+
lines.append(f"| {name} | {roc:.3f} | {ap:.3f} |")
|
| 131 |
+
lines += [
|
| 132 |
+
"",
|
| 133 |
+
"",
|
| 134 |
+
"",
|
| 135 |
+
"",
|
| 136 |
+
"",
|
| 137 |
+
f"## 채택",
|
| 138 |
+
"",
|
| 139 |
+
f"PR-AUC 기준 최고 모델은 **{ranked[0][0]}** "
|
| 140 |
+
f"(PR-AUC {ranked[0][2]:.3f}), agents/detection.py의 baseline으로 사용합니다.",
|
| 141 |
+
"",
|
| 142 |
+
]
|
| 143 |
+
(OUT_DIR / "results.md").write_text("\n".join(lines), encoding="utf-8")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
if __name__ == "__main__":
|
| 147 |
+
main()
|
experiments/tier1_detection/plots/pr.png
ADDED
|
experiments/tier1_detection/plots/roc.png
ADDED
|
experiments/tier1_detection/results.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Tier 1 이상 탐지 - 모델 벤치마크
|
| 2 |
+
|
| 3 |
+
SECOM 데이터셋(반도체 제조 공정 센서)으로 비지도 이상 탐지 모델을 비교합니다.
|
| 4 |
+
|
| 5 |
+
## 설정
|
| 6 |
+
|
| 7 |
+
- train 1096 / test 471 (70/30 stratified split)
|
| 8 |
+
- test의 fail(이상) 샘플: 31건
|
| 9 |
+
- 전처리: 전결측/상수 컬럼 제거 -> 중앙값 임퓨테이션 -> 표준화
|
| 10 |
+
- 평가 지표: ROC-AUC, PR-AUC (불균형 데이터라 PR-AUC가 주지표)
|
| 11 |
+
|
| 12 |
+
## 결과 (PR-AUC 내림차순)
|
| 13 |
+
|
| 14 |
+
| 모델 | ROC-AUC | PR-AUC |
|
| 15 |
+
|---|---|---|
|
| 16 |
+
| IsolationForest | 0.600 | 0.129 |
|
| 17 |
+
| baseline | 0.565 | 0.119 |
|
| 18 |
+
| OneClassSVM | 0.563 | 0.098 |
|
| 19 |
+
| LocalOutlierFactor | 0.544 | 0.089 |
|
| 20 |
+
|
| 21 |
+

|
| 22 |
+
|
| 23 |
+

|
| 24 |
+
|
| 25 |
+
## 채택
|
| 26 |
+
|
| 27 |
+
PR-AUC 기준 최고 모델은 **IsolationForest** (PR-AUC 0.129), agents/detection.py의 baseline으로 사용합니다.
|
requirements.txt
CHANGED
|
@@ -2,3 +2,5 @@ streamlit>=1.36.0
|
|
| 2 |
openai>=1.0.0
|
| 3 |
python-dotenv>=1.0.0
|
| 4 |
pandas>=2.0.0
|
|
|
|
|
|
|
|
|
| 2 |
openai>=1.0.0
|
| 3 |
python-dotenv>=1.0.0
|
| 4 |
pandas>=2.0.0
|
| 5 |
+
scikit-learn>=1.4.0
|
| 6 |
+
matplotlib>=3.8.0
|