Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ scripts/benchmark/output/
# A fresh clone must run --collect with AWS access before --offline can succeed.
data_science/SMSModel/artifacts/stacking/llm_*_predictions.json
data_science/SMSModel/artifacts/stacking/*.json.tmp

# 파인튜닝 인코더가 들어간 stacking artifact (약 66MB).
# 얼린 인코더를 쓰던 시절에는 2.5MB라 커밋했지만, 파인튜닝 가중치는
# 모델 자체라 artifact에 포함해야 하고 재학습마다 히스토리에 영구
# 누적된다(레포 .git 전체가 21MB). 학습을 CPU + 고정 시드로
# 결정적으로 만들어 뒀으므로 run_stacking_training.py로 정확히
# 재생성할 수 있고, metadata.json의 model_sha256으로 검증한다.
data_science/SMSModel/artifacts/stacking/v9-experiment/model.joblib

Large diffs are not rendered by default.

38 changes: 19 additions & 19 deletions data_science/SMSModel/artifacts/stacking/metadata.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"artifact_version": "v9",
"created_at": "2026-08-23T03:45:42.286006+00:00",
"created_at": "2026-08-23T04:02:34.020134+00:00",
"dataset": {
"dataset_fingerprint": "06c0756121240d61a0619d967fe128295500f21a6fbc6fcd2887052ace17ff3c",
"dataset_fingerprint": "500884cc4bd2ed9a45ce8b7dfbd16aff6c7672422bbe7959f5b190fcdc98b1b3",
"holdout_rows": 510,
"total_csv_rows": 3584,
"training_pool_rows": 971
"total_csv_rows": 3562,
"training_pool_rows": 949
},
"dataset_path": "Data/SMSData/phishing_total_dataset_reclassified.csv",
"library_versions": {
Expand Down Expand Up @@ -46,19 +46,19 @@
"has_investment_lure"
],
"model_name": "stacking_phishing_classifier",
"oof_splits": 5,
"oof_splits": 10,
"random_state": 42,
"threshold": 0.09572428195555654
"threshold": 0.1501592596230264
},
"model_configuration_sha256": "1f73f22f901a047e6abcff41d12dba5369da97ce803a8666dd17c3f4608dfe28",
"model_sha256": "bca83c5cba615a8634e52e844f5c1c413da8b37b5e50f68c5d332bb06aa0fefd",
"model_configuration_sha256": "2525b390b815ca60d58d73386f4ea28096c8e0cf857fc42f756217d3a24699b9",
"model_sha256": "fc5d2065c324df9783c4653b9f5d75e1774b651ab6beba15e1592948513112a5",
"schema_version": 2,
"split_manifest": "sms_split_v6.csv",
"split_manifest_sha256": "5899694abbae33038b6df21b4470f5bff846a62712ed20f734360454630a73f1",
"split_manifest_sha256": "214dd3385689f18907720b8f29c8710390672c7f3410fe3acd1af1ee74af17b2",
"splits": {
"test": 138,
"train": 692,
"validation": 141
"test": 135,
"train": 678,
"validation": 136
},
"training_policy": {
"final_evaluation_split": "test",
Expand All @@ -69,18 +69,18 @@
"training_split": "train"
},
"validation": {
"f2": 0.7596685082872928,
"false_positive_rate": 0.6594360086767896,
"max_false_positive_rate": 0.6594360086767896,
"f2": 0.8596345514950167,
"false_positive_rate": 0.279826464208243,
"max_false_positive_rate": 0.279826464208243,
"measurable_false_positive_rate": 0.0021691973969631237,
"recall": 0.9523809523809523,
"recall": 0.9539170506912442,
"target_recall": 0.95,
"target_recall_met": true
},
"validation_reference": {
"false_positive_rate": 0.5760869565217391,
"false_positive_rate": 0.2717391304347826,
"normal_count": 92,
"recall": 0.9795918367346939,
"sample_count": 141
"recall": 0.9772727272727273,
"sample_count": 136
}
}
Binary file modified data_science/SMSModel/artifacts/stacking/model.joblib
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"artifact_version": "v9",
"created_at": "2026-08-22T15:03:35.871661+00:00",
"created_at": "2026-08-23T03:19:14.349356+00:00",
"dataset": {
"dataset_fingerprint": "06c0756121240d61a0619d967fe128295500f21a6fbc6fcd2887052ace17ff3c",
"dataset_fingerprint": "500884cc4bd2ed9a45ce8b7dfbd16aff6c7672422bbe7959f5b190fcdc98b1b3",
"holdout_rows": 510,
"total_csv_rows": 3584,
"training_pool_rows": 971
"total_csv_rows": 3562,
"training_pool_rows": 949
},
"dataset_path": "Data/SMSData/phishing_total_dataset_reclassified.csv",
"library_versions": {
Expand Down Expand Up @@ -48,19 +48,19 @@
"has_investment_lure"
],
"model_name": "stacking_phishing_classifier",
"oof_splits": 5,
"oof_splits": 10,
"random_state": 42,
"threshold": 0.1501995113220475
"threshold": 0.1638286294849425
},
"model_configuration_sha256": "6f10b756c8e963e7ad9afbc60f44ba6f570090a4f451eb0929ee41b25dcf2229",
"model_sha256": "f05284390cf1b3198145c726d4af464602fca161b4a4c6739467bd30ee5884fe",
"model_configuration_sha256": "f664233b082d1ddae8ae1c58954a4acf858eff6c8e016c89142852b5eba3d17a",
"model_sha256": "a2c9d5a4340f61662db2fa2256632ba5298c96f43ca98c7dc6f6ab22d900e8a3",
"schema_version": 2,
"split_manifest": "sms_split_v6.csv",
"split_manifest_sha256": "5899694abbae33038b6df21b4470f5bff846a62712ed20f734360454630a73f1",
"split_manifest_sha256": "214dd3385689f18907720b8f29c8710390672c7f3410fe3acd1af1ee74af17b2",
"splits": {
"test": 138,
"train": 692,
"validation": 141
"test": 135,
"train": 678,
"validation": 136
},
"training_policy": {
"final_evaluation_split": "test",
Expand All @@ -71,18 +71,18 @@
"training_split": "train"
},
"validation": {
"f2": 0.8587041373926619,
"false_positive_rate": 0.29718004338394793,
"max_false_positive_rate": 0.29718004338394793,
"f2": 0.9094903339191565,
"false_positive_rate": 0.13665943600867678,
"max_false_positive_rate": 0.13665943600867678,
"measurable_false_positive_rate": 0.0021691973969631237,
"recall": 0.9523809523809523,
"recall": 0.9539170506912442,
"target_recall": 0.95,
"target_recall_met": true
},
"validation_reference": {
"false_positive_rate": 0.2826086956521739,
"false_positive_rate": 0.09782608695652174,
"normal_count": 92,
"recall": 0.9591836734693877,
"sample_count": 141
"recall": 0.9318181818181818,
"sample_count": 136
}
}
Binary file not shown.
20 changes: 20 additions & 0 deletions data_science/SMSModel/dataset_splitting/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ class DatasetSplitConfig:
# 적은 유형이 한쪽 split에서 통째로 빠지기 쉬워 함께 평가한다.
stratified_candidate_count: int = 200

# 임계값·경계 선정에 쓰는 split을 특정 출처 위주로 채우기 위한 설정.
#
# validation은 학습 pool에서 무작위로 뽑히는데, pool 정상의 60%가 증강
# 데이터(synthetic_normal_v3, real_collected_v4)라 실제 수집 문자보다
# 훨씬 쉬웠다. 그 결과 validation이 포화돼(정상 96.7~98.9%가 경계 아래)
# 하이퍼파라미터 구성 간 차이가 표본 1건 수준으로 뭉개졌고, 실제로
# validation에서 가장 좋아 보이던 설정이 판정셋에서는 더 나빴다(#102).
#
# 출처별 난이도(OOF 실측, 정상 평균 확률):
# synthetic_normal_v3 0.099 < real_collected_v4 0.073 < original 0.142
# 판정셋(real_holdout) 0.179
# original이 판정셋에 가장 가까워 선정용 split을 이쪽으로 몰아준다.
selection_source_column: str | None = "source"
selection_source: str | None = None
selection_source_weight: float = 0.0

def __post_init__(self) -> None:
total_size = self.train_size + self.val_size + self.test_size
if abs(total_size - 1.0) > 1e-9:
Expand All @@ -44,4 +60,8 @@ def __post_init__(self) -> None:
if self.stratified_candidate_count < 0:
raise ValueError(
"stratified_candidate_count must not be negative"
)
if self.selection_source_weight < 0.0:
raise ValueError(
"selection_source_weight must not be negative"
)
33 changes: 31 additions & 2 deletions data_science/SMSModel/dataset_splitting/splitter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""그룹 보존과 클래스·유형 비율 최적화를 적용한 데이터 분할"""
from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, replace

import numpy as np
import pandas as pd
Expand Down Expand Up @@ -91,6 +91,9 @@ def _candidate_score(
type_column: str | None,
type_values: list[str],
type_weight: float,
selection_source_column: str | None = None,
selection_source: str | None = None,
selection_source_weight: float = 0.0,
) -> tuple[float, int, float, float]:
"""목표 행 비율과 클래스·유형 분포에 가까울수록 낮은 점수를 반환"""
size_error = abs((len(selected) / len(full_data)) - target_size)
Expand Down Expand Up @@ -120,6 +123,23 @@ def _candidate_score(
values=type_values,
)

# 선정용 split은 지정 출처(실수집 데이터)로 채울수록 좋다. 유형 커버리지와
# 크기를 먼저 맞춘 뒤, 같은 조건이면 지정 출처 비율이 높은 후보를 고른다.
if (
selection_source is not None
and selection_source_column is not None
and selection_source_column in selected.columns
and selection_source_weight > 0.0
and len(selected) > 0
):
off_source_share = float(
(
selected[selection_source_column].astype(str)
!= selection_source
).mean()
)
distribution_error += selection_source_weight * off_source_share

# 유형이 통째로 빠지는 것을 가장 먼저 막고, 그다음 크기와 분포를 맞춘다.
# 크기는 1%p 단위 등급으로 비교해 미세한 차이로 후보가 뒤집히지 않게 한다.
size_error_bucket = int(size_error / 0.01)
Expand Down Expand Up @@ -270,6 +290,9 @@ def _select_best_group_split(
type_column=type_column,
type_values=type_values,
type_weight=config.type_weight,
selection_source_column=config.selection_source_column,
selection_source=config.selection_source,
selection_source_weight=config.selection_source_weight,
)
if score < best_score:
best_score = score
Expand Down Expand Up @@ -308,13 +331,19 @@ def split_grouped_dataset(
if df[config.group_column].nunique() < 3:
raise ValueError("at least three template groups are required")

# test는 최종 평가용이라 전체 분포를 그대로 닮아야 한다 - 출처 선호를 걸지 않는다.
train_validation, test = _select_best_group_split(
df,
selected_size=config.test_size,
config=config,
config=replace(
config,
selection_source=None,
selection_source_weight=0.0,
),
random_state_offset=0,
)
relative_validation_size = config.val_size / (config.train_size + config.val_size)
# validation은 임계값·경계 선정에 쓰이므로 판정셋과 난이도가 비슷해야 한다.
train, validation = _select_best_group_split(
train_validation,
selected_size=relative_validation_size,
Expand Down
Loading
Loading