Files
opensearch-tracker/score.py

156 lines
6.6 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
import os
import glob
import argparse
from collections import Counter
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.mixture import GaussianMixture
from sklearn.cluster import KMeans
from sklearn.svm import OneClassSVM
# -----------------------------
# 기존 로직 (수정/보완 없이 그대로 사용)
# -----------------------------
def most_frequent(series):
vals = [v for v in series if pd.notna(v)]
return Counter(vals).most_common(1)[0][0] if vals else np.nan
def build_uid_features(df, uid_col="uid", type_col="type", count_col="count", nick_col="nickname"):
"""uid별 집계 피처 생성"""
agg = (
df.groupby(uid_col, as_index=False)
.agg(
nickname=(nick_col, most_frequent) if nick_col in df.columns else (uid_col, "size"),
total_count=(count_col, "sum"),
unique_types=(type_col, "nunique"),
rows=(uid_col, "size")
)
)
return agg
def score_with_type_weight(agg, w_count=1.0, w_type=1.5):
"""z-정규화 후 가중합 스코어"""
feats = agg[["total_count", "unique_types"]].values
scaler = StandardScaler()
Z = scaler.fit_transform(feats)
score = w_count * Z[:, 0] + w_type * Z[:, 1] * agg["unique_types"].to_numpy()
agg = agg.copy()
agg["score"] = score
return agg
def split_groups(agg, method="gmm", quantile=0.5, random_state=42):
"""
method:
- 'gmm' : GaussianMixture 2컴포넌트로 HIGH/LOW
- 'kmeans' : KMeans(k=2) 클러스터링
- 'quantile' : score 기준 분위수로 분리(기본: 중위수)
- 'oneclass' : One-Class SVM으로 'HIGH' 다수/정상군으로, 나머지 'LOW'
"""
agg = agg.copy()
s = agg["score"].to_numpy().reshape(-1, 1)
if method == "gmm":
gmm = GaussianMixture(n_components=2, random_state=random_state)
labels = gmm.fit_predict(s)
means = gmm.means_.flatten()
high_component = int(np.argmax(means))
agg["group"] = np.where(labels == high_component, "HIGH", "LOW")
elif method == "kmeans":
km = KMeans(n_clusters=2, n_init="auto", random_state=random_state)
labels = km.fit_predict(s)
centers = km.cluster_centers_.flatten()
high_component = int(np.argmax(centers))
agg["group"] = np.where(labels == high_component, "HIGH", "LOW")
elif method == "quantile":
thr = np.quantile(agg["score"], quantile)
agg["group"] = np.where(agg["score"] >= thr, "HIGH", "LOW")
elif method == "oneclass":
oc = OneClassSVM(kernel="rbf", gamma="scale", nu=0.1) # 필요 시 nu 조정
oc.fit(s)
pred = oc.predict(s) # 1: inliers, -1: outliers
agg["group"] = np.where(pred == 1, "HIGH", "LOW")
else:
raise ValueError("method must be one of: gmm, kmeans, quantile, oneclass")
return agg
# -----------------------------
# 추가: 입력 경로(파일/폴더) 처리 유틸
# -----------------------------
def load_input_path(path: str, pattern: str = "*.csv", recursive: bool = True) -> pd.DataFrame:
"""
- path가 파일이면 해당 CSV만 로드
- path가 폴더이면 내부의 CSV들을 전부 로드하여 concat
"""
if os.path.isdir(path):
glob_pattern = os.path.join(path, "**", pattern) if recursive else os.path.join(path, pattern)
files = sorted(glob.glob(glob_pattern, recursive=recursive))
if not files:
raise FileNotFoundError(f"No CSV files found under directory: {path}")
dfs = []
for f in files:
try:
dfs.append(pd.read_csv(f))
except Exception as e:
raise RuntimeError(f"Failed to read '{f}': {e}")
df = pd.concat(dfs, ignore_index=True)
else:
if not os.path.exists(path):
raise FileNotFoundError(f"Path not found: {path}")
df = pd.read_csv(path)
return df
# -----------------------------
# 실행 엔트리포인트
# -----------------------------
def main():
parser = argparse.ArgumentParser(description="UID 그룹 스코어링 및 분리 (파일 또는 폴더 입력)")
parser.add_argument("path", help="입력 CSV 파일 경로 또는 폴더 경로")
parser.add_argument("-o", "--out", default="hack_group.csv", help="출력 CSV 파일명 (기본: hack_group.csv)")
parser.add_argument("--pattern", default="*.csv", help="폴더 입력 시 읽을 파일 패턴 (기본: *.csv)")
parser.add_argument("--no-recursive", action="store_true", help="폴더 입력 시 하위 디렉토리 재귀 탐색 비활성화")
parser.add_argument("--uid-col", default="uid", help="UID 컬럼명 (기본: uid)")
parser.add_argument("--type-col", default="type", help="TYPE 컬럼명 (기본: type)")
parser.add_argument("--count-col", default="count", help="COUNT 컬럼명 (기본: count)")
parser.add_argument("--nick-col", default="nickname", help="닉네임 컬럼명 (기본: nickname)")
parser.add_argument("--w-count", type=float, default=1.0, help="스코어 가중치: count (기본: 1.0)")
parser.add_argument("--w-type", type=float, default=1.5, help="스코어 가중치: unique_types (기본: 1.5)")
parser.add_argument("--method", choices=["gmm", "kmeans", "quantile", "oneclass"], default="gmm",
help="그룹 분리 방법 (기본: gmm)")
parser.add_argument("--quantile", type=float, default=0.5, help="method=quantile 사용 시 분위수(0~1)")
parser.add_argument("--random-state", type=int, default=42, help="랜덤 시드 (기본: 42)")
args = parser.parse_args()
# 1) 입력 로드 (파일/폴더)
df = load_input_path(args.path, pattern=args.pattern, recursive=(not args.no_recursive))
if df.empty:
raise ValueError("Loaded dataframe is empty. Check input path or files.")
# 2) 집계 → 스코어 → 그룹 분리
agg = build_uid_features(df, uid_col=args.uid_col, type_col=args.type_col,
count_col=args.count_col, nick_col=args.nick_col)
agg = score_with_type_weight(agg, w_count=args.w_count, w_type=args.w_type)
if args.method == "quantile":
result = split_groups(agg, method=args.method, quantile=args.quantile, random_state=args.random_state)
else:
result = split_groups(agg, method=args.method, random_state=args.random_state)
# 3) 정렬 및 저장
result = result.sort_values(["group", "score"], ascending=[True, False])
result.to_csv(args.out, index=False)
print(f"[OK] input rows={len(df):,}, uids={result.shape[0]:,} → saved: {args.out}")
if __name__ == "__main__":
main()