Spaces:
Sleeping
Sleeping
Sync from GitHub Actions
Browse files- CLAUDE.md +3 -1
- main.py +20 -3
- src/domains/time_series_prediction/ensemble_service.py +43 -2
- static/hybrid.html +171 -13
CLAUDE.md
CHANGED
|
@@ -107,7 +107,7 @@ POST /nbeats/evaluate โ ํ๋ จ/ํ
์คํธ ๋ถ๋ฆฌ ํ๊ฐ
|
|
| 107 |
POST /api/{prophet,arima,xgboost,ets,lightgbm,theta}/predict
|
| 108 |
GET /api/{prophet,arima,xgboost,ets,lightgbm,theta}/parameters
|
| 109 |
|
| 110 |
-
POST /api/hybrid/predict โ 7๋ชจ๋ธ ์์๋ธ (file + forecast_horizon(1~500)
|
| 111 |
GET /api/timesfm/status โ TimesFM ๋ก๋ฉ ์ํ (not_loaded|loading|loaded|error)
|
| 112 |
POST /api/timesfm/predict โ TimesFM ์ ๋ก์ท ์์ธก
|
| 113 |
```
|
|
@@ -177,6 +177,8 @@ class DataProfile:
|
|
| 177 |
์์๋ธ ๊ฐ์ค์น๋ DataProfile๋ก ์กฐ์ ๋๋ค (ensemble_service.py):
|
| 178 |
volatility > 0.3 โ nbeatsยทprophet +0.05, trend stable โ etsยทtheta +0.05,
|
| 179 |
๊ตฌ์กฐ ๋ณํ์ ์กด์ฌ โ xgboostยทlightgbm +0.05, ํฉ๊ณ 1.0 ์ ๊ทํ.
|
|
|
|
|
|
|
| 180 |
|
| 181 |
---
|
| 182 |
|
|
|
|
| 107 |
POST /api/{prophet,arima,xgboost,ets,lightgbm,theta}/predict
|
| 108 |
GET /api/{prophet,arima,xgboost,ets,lightgbm,theta}/parameters
|
| 109 |
|
| 110 |
+
POST /api/hybrid/predict โ 7๋ชจ๋ธ ์์๋ธ (file + forecast_horizon(1~500) + model_weights(JSON, ์ ํ))
|
| 111 |
GET /api/timesfm/status โ TimesFM ๋ก๋ฉ ์ํ (not_loaded|loading|loaded|error)
|
| 112 |
POST /api/timesfm/predict โ TimesFM ์ ๋ก์ท ์์ธก
|
| 113 |
```
|
|
|
|
| 177 |
์์๋ธ ๊ฐ์ค์น๋ DataProfile๋ก ์กฐ์ ๋๋ค (ensemble_service.py):
|
| 178 |
volatility > 0.3 โ nbeatsยทprophet +0.05, trend stable โ etsยทtheta +0.05,
|
| 179 |
๊ตฌ์กฐ ๋ณํ์ ์กด์ฌ โ xgboostยทlightgbm +0.05, ํฉ๊ณ 1.0 ์ ๊ทํ.
|
| 180 |
+
`model_weights`(JSON, ์ ํ)๋ก ์๋ ๊ฐ์ค์น๋ฅผ ๋๊ธฐ๋ฉด ์๋ ์กฐ์ ๋์ ์ฌ์ฉ๋๋ฉฐ,
|
| 181 |
+
๊ฐ์ค์น 0์ธ ๋ชจ๋ธ์ ์คํ ์์ฒด๋ฅผ ๊ฑด๋๋ด๋ค (์์ ๋ชจ๋ธ 2๊ฐ ๋ฏธ๋ง์ด๋ฉด 422).
|
| 182 |
|
| 183 |
---
|
| 184 |
|
main.py
CHANGED
|
@@ -83,7 +83,10 @@ from src.domains.time_series_prediction.theta_service import (
|
|
| 83 |
predict_with_theta,
|
| 84 |
evaluate_with_theta,
|
| 85 |
)
|
| 86 |
-
from src.domains.time_series_prediction.ensemble_service import
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
logging.basicConfig(
|
| 89 |
level=logging.INFO,
|
|
@@ -580,12 +583,26 @@ async def hybrid_page():
|
|
| 580 |
async def hybrid_predict(
|
| 581 |
file: UploadFile = File(...),
|
| 582 |
forecast_horizon: int = Form(30, ge=1, le=500),
|
|
|
|
| 583 |
):
|
| 584 |
-
"""7๊ฐ ๋ชจ๋ธ ์์๋ธ ์์ธก (๊ฐ์ค ํ๊ท + ๋ชจ๋ธ ๊ฐ ๋ถํ์ค์ฑ ๊ตฌ๊ฐ).
|
|
|
|
|
|
|
|
|
|
| 585 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
df = await _load_validated_df(file)
|
| 587 |
|
| 588 |
-
ensemble = await run_ensemble_async(df, forecast_horizon)
|
| 589 |
if not ensemble.get("success"):
|
| 590 |
raise HTTPException(status_code=422, detail=ensemble.get("error", "์์๋ธ ์คํจ"))
|
| 591 |
|
|
|
|
| 83 |
predict_with_theta,
|
| 84 |
evaluate_with_theta,
|
| 85 |
)
|
| 86 |
+
from src.domains.time_series_prediction.ensemble_service import (
|
| 87 |
+
normalize_manual_weights,
|
| 88 |
+
run_ensemble_async,
|
| 89 |
+
)
|
| 90 |
|
| 91 |
logging.basicConfig(
|
| 92 |
level=logging.INFO,
|
|
|
|
| 583 |
async def hybrid_predict(
|
| 584 |
file: UploadFile = File(...),
|
| 585 |
forecast_horizon: int = Form(30, ge=1, le=500),
|
| 586 |
+
model_weights: Optional[str] = Form(None),
|
| 587 |
):
|
| 588 |
+
"""7๊ฐ ๋ชจ๋ธ ์์๋ธ ์์ธก (๊ฐ์ค ํ๊ท + ๋ชจ๋ธ ๊ฐ ๋ถํ์ค์ฑ ๊ตฌ๊ฐ).
|
| 589 |
+
|
| 590 |
+
model_weights(JSON, ์ ํ): ๋ชจ๋ธ๋ณ ์๋ ๊ฐ์ค์น. ๋ฏธ์ ์ก ์ DataProfile ์๋ ๊ฐ์ค์น.
|
| 591 |
+
"""
|
| 592 |
try:
|
| 593 |
+
manual = None
|
| 594 |
+
if model_weights:
|
| 595 |
+
try:
|
| 596 |
+
parsed = json.loads(model_weights)
|
| 597 |
+
if not isinstance(parsed, dict):
|
| 598 |
+
raise ValueError("model_weights๋ JSON ๊ฐ์ฒด์ฌ์ผ ํฉ๋๋ค.")
|
| 599 |
+
manual = normalize_manual_weights(parsed)
|
| 600 |
+
except ValueError as exc: # JSONDecodeError ํฌํจ
|
| 601 |
+
raise HTTPException(status_code=422, detail=f"model_weights ์ค๋ฅ: {exc}")
|
| 602 |
+
|
| 603 |
df = await _load_validated_df(file)
|
| 604 |
|
| 605 |
+
ensemble = await run_ensemble_async(df, forecast_horizon, manual_weights=manual)
|
| 606 |
if not ensemble.get("success"):
|
| 607 |
raise HTTPException(status_code=422, detail=ensemble.get("error", "์์๋ธ ์คํจ"))
|
| 608 |
|
src/domains/time_series_prediction/ensemble_service.py
CHANGED
|
@@ -70,6 +70,36 @@ def compute_model_weights(data_profile: DataProfile) -> dict[str, float]:
|
|
| 70 |
return {k: v / total for k, v in weights.items()}
|
| 71 |
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
def _df_to_data_list(result_df: pd.DataFrame) -> list[dict]:
|
| 74 |
"""๋ชจ๋ธ ๊ฒฐ๊ณผ DataFrame์ dict ๋ฆฌ์คํธ๋ก ๋ณํ."""
|
| 75 |
rows = []
|
|
@@ -178,17 +208,24 @@ def aggregate_predictions(
|
|
| 178 |
}
|
| 179 |
|
| 180 |
|
| 181 |
-
async def run_ensemble_async(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
"""
|
| 183 |
7๊ฐ ๋ชจ๋ธ์ ๋น๋๊ธฐ ๋ณ๋ ฌ ์คํ ํ ์์๋ธ ๊ฒฐ๊ณผ ๋ฐํ.
|
| 184 |
|
| 185 |
๊ฐ ๋ชจ๋ธ์ run_in_executor๋ก ThreadPool์์ ์คํํ๋ค.
|
| 186 |
์คํจํ ๋ชจ๋ธ์ ์ ์ธํ๊ณ ์ฑ๊ณต ๋ชจ๋ธ๋ง ์ง๊ณ.
|
| 187 |
์ฑ๊ณต ๋ชจ๋ธ์ด 2๊ฐ ๋ฏธ๋ง์ด๋ฉด ์ค๋ฅ๋ฅผ ๋ฐํํ๋ค.
|
|
|
|
|
|
|
|
|
|
| 188 |
"""
|
| 189 |
values = df["y"].values
|
| 190 |
data_profile = profile_series(values, df=df)
|
| 191 |
-
weights = compute_model_weights(data_profile)
|
| 192 |
|
| 193 |
loop = asyncio.get_running_loop()
|
| 194 |
|
|
@@ -203,6 +240,10 @@ async def run_ensemble_async(df: pd.DataFrame, forecast_horizon: int) -> dict:
|
|
| 203 |
("theta", predict_with_theta, lambda: apply_theta_defaults({})),
|
| 204 |
]
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
async def _run_one(name: str, fn, params: dict) -> tuple[str, Optional[dict]]:
|
| 207 |
"""๋จ์ผ ๋ชจ๋ธ์ ThreadPool์์ ์คํ. 180์ด ์ด๊ณผ ์ ํ์์์์ผ๋ก ์ ์ธ."""
|
| 208 |
async with _MODEL_SEMAPHORE:
|
|
|
|
| 70 |
return {k: v / total for k, v in weights.items()}
|
| 71 |
|
| 72 |
|
| 73 |
+
def normalize_manual_weights(weights: dict) -> dict[str, float]:
|
| 74 |
+
"""
|
| 75 |
+
์ฌ์ฉ์ ์ง์ ๊ฐ์ค์น ๊ฒ์ฆยท์ ๊ทํ.
|
| 76 |
+
|
| 77 |
+
- ๋ชจ๋ธ๋ช
์ _DEFAULT_WEIGHTS ํค๋ง ํ์ฉ, ๋ฏธ์ง์ ๋ชจ๋ธ์ 0(์คํ ์ ์ธ)
|
| 78 |
+
- ์์ยท๋น์ซ์ยท๋น์ ํ ๊ฐ ๊ฑฐ๋ถ
|
| 79 |
+
- ์์ ๊ฐ์ค์น ๋ชจ๋ธ์ด 2๊ฐ ๋ฏธ๋ง์ด๋ฉด ์์๋ธ ๋ถ๊ฐ๋ก ๊ฑฐ๋ถ
|
| 80 |
+
- ํฉ๊ณ 1.0์ผ๋ก ์ ๊ทํํด ๋ฐํ
|
| 81 |
+
"""
|
| 82 |
+
unknown = set(weights) - set(_DEFAULT_WEIGHTS)
|
| 83 |
+
if unknown:
|
| 84 |
+
raise ValueError(f"์ ์ ์๋ ๋ชจ๋ธ๋ช
: {sorted(unknown)}")
|
| 85 |
+
|
| 86 |
+
normalized: dict[str, float] = {}
|
| 87 |
+
for name in _DEFAULT_WEIGHTS:
|
| 88 |
+
v = weights.get(name, 0)
|
| 89 |
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or not np.isfinite(v):
|
| 90 |
+
raise ValueError(f"{name} ๊ฐ์ค์น๋ 0 ์ด์์ ์ซ์์ฌ์ผ ํฉ๋๋ค: {v!r}")
|
| 91 |
+
if v < 0:
|
| 92 |
+
raise ValueError(f"{name} ๊ฐ์ค์น๋ ์์์ผ ์ ์์ต๋๋ค: {v}")
|
| 93 |
+
normalized[name] = float(v)
|
| 94 |
+
|
| 95 |
+
positive = [n for n, v in normalized.items() if v > 0]
|
| 96 |
+
if len(positive) < 2:
|
| 97 |
+
raise ValueError("์์ ๊ฐ์ค์น ๋ชจ๋ธ์ด 2๊ฐ ์ด์ ํ์ํฉ๋๋ค (์์๋ธ ์ต์ ๊ตฌ์ฑ).")
|
| 98 |
+
|
| 99 |
+
total = sum(normalized.values())
|
| 100 |
+
return {k: v / total for k, v in normalized.items()}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
def _df_to_data_list(result_df: pd.DataFrame) -> list[dict]:
|
| 104 |
"""๋ชจ๋ธ ๊ฒฐ๊ณผ DataFrame์ dict ๋ฆฌ์คํธ๋ก ๋ณํ."""
|
| 105 |
rows = []
|
|
|
|
| 208 |
}
|
| 209 |
|
| 210 |
|
| 211 |
+
async def run_ensemble_async(
|
| 212 |
+
df: pd.DataFrame,
|
| 213 |
+
forecast_horizon: int,
|
| 214 |
+
manual_weights: Optional[dict[str, float]] = None,
|
| 215 |
+
) -> dict:
|
| 216 |
"""
|
| 217 |
7๊ฐ ๋ชจ๋ธ์ ๋น๋๊ธฐ ๋ณ๋ ฌ ์คํ ํ ์์๋ธ ๊ฒฐ๊ณผ ๋ฐํ.
|
| 218 |
|
| 219 |
๊ฐ ๋ชจ๋ธ์ run_in_executor๋ก ThreadPool์์ ์คํํ๋ค.
|
| 220 |
์คํจํ ๋ชจ๋ธ์ ์ ์ธํ๊ณ ์ฑ๊ณต ๋ชจ๋ธ๋ง ์ง๊ณ.
|
| 221 |
์ฑ๊ณต ๋ชจ๋ธ์ด 2๊ฐ ๋ฏธ๋ง์ด๋ฉด ์ค๋ฅ๋ฅผ ๋ฐํํ๋ค.
|
| 222 |
+
|
| 223 |
+
manual_weights๊ฐ ์ฃผ์ด์ง๋ฉด DataProfile ์๋ ๊ฐ์ค์น ๋์ ์ฌ์ฉํ๋ฉฐ,
|
| 224 |
+
๊ฐ์ค์น 0์ธ ๋ชจ๋ธ์ ์คํ ์์ฒด๋ฅผ ๊ฑด๋๋ด๋ค.
|
| 225 |
"""
|
| 226 |
values = df["y"].values
|
| 227 |
data_profile = profile_series(values, df=df)
|
| 228 |
+
weights = manual_weights if manual_weights is not None else compute_model_weights(data_profile)
|
| 229 |
|
| 230 |
loop = asyncio.get_running_loop()
|
| 231 |
|
|
|
|
| 240 |
("theta", predict_with_theta, lambda: apply_theta_defaults({})),
|
| 241 |
]
|
| 242 |
|
| 243 |
+
# ์๋ ๊ฐ์ค์น์์ 0์ธ ๋ชจ๋ธ์ ์คํํ์ง ์๋๋ค (์๊ฐ ์ ์ฝ + ๋ช
์์ ์ ์ธ)
|
| 244 |
+
if manual_weights is not None:
|
| 245 |
+
model_configs = [c for c in model_configs if manual_weights.get(c[0], 0) > 0]
|
| 246 |
+
|
| 247 |
async def _run_one(name: str, fn, params: dict) -> tuple[str, Optional[dict]]:
|
| 248 |
"""๋จ์ผ ๋ชจ๋ธ์ ThreadPool์์ ์คํ. 180์ด ์ด๊ณผ ์ ํ์์์์ผ๋ก ์ ์ธ."""
|
| 249 |
async with _MODEL_SEMAPHORE:
|
static/hybrid.html
CHANGED
|
@@ -217,6 +217,52 @@
|
|
| 217 |
margin-top: 0.2rem;
|
| 218 |
}
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
.opt-group input:focus {
|
| 221 |
outline: none;
|
| 222 |
border-color: var(--accent);
|
|
@@ -472,6 +518,17 @@
|
|
| 472 |
</div>
|
| 473 |
<div class="freq-hint" id="freqHint">ํ์ผ ์
๋ก๋ ์ ์๋ ๊ฐ์ง</div>
|
| 474 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
</div>
|
| 476 |
|
| 477 |
<div class="actions">
|
|
@@ -506,7 +563,7 @@
|
|
| 506 |
</div>
|
| 507 |
<div class="card">
|
| 508 |
<div class="card-title">์์๋ธ ๋ฐฉ์</div>
|
| 509 |
-
<p style="color:var(--text-muted);font-size:0.875rem;line-height:1.7;">
|
| 510 |
</div>
|
| 511 |
</div>
|
| 512 |
|
|
@@ -594,19 +651,105 @@
|
|
| 594 |
freqHint.textContent = `๊ฐ์ง๋ ์ฃผ๊ธฐ: ${info.label} ๋จ์`;
|
| 595 |
}
|
| 596 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
async function detectFreqFromFile(file) {
|
| 598 |
const name = file.name.toLowerCase();
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
const
|
| 608 |
-
|
| 609 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
const freq = detectFreqFromDates(dates);
|
| 611 |
if (freq) { applyFreq(freq); return true; }
|
| 612 |
return false;
|
|
@@ -637,18 +780,30 @@
|
|
| 637 |
predictBtn.disabled = true;
|
| 638 |
statusEl.className = 'status';
|
| 639 |
resultsSection.classList.remove('visible');
|
|
|
|
| 640 |
if (mainChart) { mainChart.destroy(); mainChart = null; }
|
| 641 |
if (weightsChart) { weightsChart.destroy(); weightsChart = null; }
|
| 642 |
});
|
| 643 |
|
| 644 |
predictBtn.addEventListener('click', async () => {
|
| 645 |
if (!fileInput.files[0]) return;
|
| 646 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
predictBtn.disabled = true;
|
| 648 |
|
| 649 |
const formData = new FormData();
|
| 650 |
formData.append('file', fileInput.files[0]);
|
| 651 |
formData.append('forecast_horizon', document.getElementById('forecastHorizon').value);
|
|
|
|
| 652 |
|
| 653 |
try {
|
| 654 |
const res = await fetch('/api/hybrid/predict', { method: 'POST', body: formData });
|
|
@@ -658,6 +813,9 @@
|
|
| 658 |
return;
|
| 659 |
}
|
| 660 |
renderResults(json);
|
|
|
|
|
|
|
|
|
|
| 661 |
latestResult = json;
|
| 662 |
setStatus('success', `์์ธก ์๋ฃ โ ์ฌ์ฉ ๋ชจ๋ธ: ${(json.models_used || []).map(escHtml).join(', ')}`);
|
| 663 |
resultsSection.classList.add('visible');
|
|
|
|
| 217 |
margin-top: 0.2rem;
|
| 218 |
}
|
| 219 |
|
| 220 |
+
.weight-mode-row {
|
| 221 |
+
display: flex;
|
| 222 |
+
gap: 1.25rem;
|
| 223 |
+
font-size: 0.85rem;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.weight-mode-row .radio-label {
|
| 227 |
+
display: flex;
|
| 228 |
+
align-items: center;
|
| 229 |
+
gap: 0.35rem;
|
| 230 |
+
cursor: pointer;
|
| 231 |
+
color: var(--text);
|
| 232 |
+
font-size: 0.85rem;
|
| 233 |
+
font-weight: 400;
|
| 234 |
+
text-transform: none;
|
| 235 |
+
letter-spacing: normal;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.weights-grid {
|
| 239 |
+
display: grid;
|
| 240 |
+
grid-template-columns: repeat(auto-fit, minmax(105px, 1fr));
|
| 241 |
+
gap: 0.5rem;
|
| 242 |
+
margin-top: 0.4rem;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.weight-item label {
|
| 246 |
+
display: block;
|
| 247 |
+
font-size: 0.68rem;
|
| 248 |
+
font-weight: 400;
|
| 249 |
+
color: var(--text-muted);
|
| 250 |
+
margin-bottom: 0.2rem;
|
| 251 |
+
text-transform: none;
|
| 252 |
+
letter-spacing: normal;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.weight-item input {
|
| 256 |
+
width: 100%;
|
| 257 |
+
padding: 0.35rem 0.5rem;
|
| 258 |
+
border: 1px solid var(--border);
|
| 259 |
+
border-radius: 8px;
|
| 260 |
+
background: var(--bg);
|
| 261 |
+
color: var(--accent);
|
| 262 |
+
font-size: 0.85rem;
|
| 263 |
+
font-family: 'JetBrains Mono', monospace;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
.opt-group input:focus {
|
| 267 |
outline: none;
|
| 268 |
border-color: var(--accent);
|
|
|
|
| 518 |
</div>
|
| 519 |
<div class="freq-hint" id="freqHint">ํ์ผ ์
๋ก๋ ์ ์๋ ๊ฐ์ง</div>
|
| 520 |
</div>
|
| 521 |
+
|
| 522 |
+
<div class="opt-group">
|
| 523 |
+
<label>๋ชจ๋ธ ๊ฐ์ค์น</label>
|
| 524 |
+
<div class="weight-mode-row">
|
| 525 |
+
<label class="radio-label"><input type="radio" name="weightMode" value="auto" checked> ์๋ (๋ฐ์ดํฐ ํน์ฑ ๊ธฐ๋ฐ)</label>
|
| 526 |
+
<label class="radio-label"><input type="radio" name="weightMode" value="manual"> ์๋</label>
|
| 527 |
+
</div>
|
| 528 |
+
<div class="weights-grid" id="weightsGrid" style="display:none;"></div>
|
| 529 |
+
<div class="freq-hint" id="weightsHint" style="display:none;">
|
| 530 |
+
ํฉ๊ณ๋ ์๋์ผ๋ก 1๋ก ์ ๊ทํ ยท 0 = ํด๋น ๋ชจ๋ธ ์ ์ธ (์์ ๊ฐ์ค์น ๋ชจ๋ธ 2๊ฐ ์ด์ ํ์)</div>
|
| 531 |
+
</div>
|
| 532 |
</div>
|
| 533 |
|
| 534 |
<div class="actions">
|
|
|
|
| 563 |
</div>
|
| 564 |
<div class="card">
|
| 565 |
<div class="card-title">์์๋ธ ๋ฐฉ์</div>
|
| 566 |
+
<p id="ensembleMethodDesc" style="color:var(--text-muted);font-size:0.875rem;line-height:1.7;"></p>
|
| 567 |
</div>
|
| 568 |
</div>
|
| 569 |
|
|
|
|
| 651 |
freqHint.textContent = `๊ฐ์ง๋ ์ฃผ๊ธฐ: ${info.label} ๋จ์`;
|
| 652 |
}
|
| 653 |
|
| 654 |
+
// โโโ ๋ชจ๋ธ ๊ฐ์ค์น ํจ๋ (์๋/์๋) โโโ
|
| 655 |
+
// ๊ธฐ๋ณธ๊ฐ์ ensemble_service._DEFAULT_WEIGHTS์ ๋์ผ
|
| 656 |
+
const WEIGHT_MODELS = [
|
| 657 |
+
['nbeats', 'N-BEATS', 0.20], ['prophet', 'Prophet', 0.20],
|
| 658 |
+
['arima', 'ARIMA', 0.15], ['xgboost', 'XGBoost', 0.15],
|
| 659 |
+
['lightgbm', 'LightGBM', 0.15], ['ets', 'ETS', 0.10], ['theta', 'Theta', 0.05],
|
| 660 |
+
];
|
| 661 |
+
const weightsGrid = document.getElementById('weightsGrid');
|
| 662 |
+
const weightsHint = document.getElementById('weightsHint');
|
| 663 |
+
|
| 664 |
+
WEIGHT_MODELS.forEach(([key, label, def]) => {
|
| 665 |
+
const item = document.createElement('div');
|
| 666 |
+
item.className = 'weight-item';
|
| 667 |
+
const lab = document.createElement('label');
|
| 668 |
+
lab.textContent = label;
|
| 669 |
+
lab.setAttribute('for', `weight-${key}`);
|
| 670 |
+
const input = document.createElement('input');
|
| 671 |
+
input.type = 'number';
|
| 672 |
+
input.id = `weight-${key}`;
|
| 673 |
+
input.min = '0';
|
| 674 |
+
input.step = '0.05';
|
| 675 |
+
input.value = def;
|
| 676 |
+
item.append(lab, input);
|
| 677 |
+
weightsGrid.appendChild(item);
|
| 678 |
+
});
|
| 679 |
+
|
| 680 |
+
function isManualWeightMode() {
|
| 681 |
+
return document.querySelector('input[name="weightMode"]:checked').value === 'manual';
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
function resetWeightPanel() {
|
| 685 |
+
document.querySelector('input[name="weightMode"][value="auto"]').checked = true;
|
| 686 |
+
WEIGHT_MODELS.forEach(([key, , def]) => {
|
| 687 |
+
document.getElementById(`weight-${key}`).value = def;
|
| 688 |
+
});
|
| 689 |
+
toggleWeightPanel();
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
function toggleWeightPanel() {
|
| 693 |
+
const manual = isManualWeightMode();
|
| 694 |
+
weightsGrid.style.display = manual ? 'grid' : 'none';
|
| 695 |
+
weightsHint.style.display = manual ? 'block' : 'none';
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
document.querySelectorAll('input[name="weightMode"]').forEach(r =>
|
| 699 |
+
r.addEventListener('change', toggleWeightPanel));
|
| 700 |
+
|
| 701 |
+
// ์๋ ๊ฐ์ค์น ์์งยท๊ฒ์ฆ. ์ ํจํ๋ฉด {๋ชจ๋ธ: ๊ฐ์ค์น}, ์๋๋ฉด ์ค๋ฅ ๋ฌธ์์ด ๋ฐํ.
|
| 702 |
+
function collectManualWeights() {
|
| 703 |
+
const weights = {};
|
| 704 |
+
for (const [key, label] of WEIGHT_MODELS) {
|
| 705 |
+
const v = parseFloat(document.getElementById(`weight-${key}`).value);
|
| 706 |
+
if (!isFinite(v) || v < 0) return `${label} ๊ฐ์ค์น๋ 0 ์ด์์ ์ซ์์ฌ์ผ ํฉ๋๋ค.`;
|
| 707 |
+
weights[key] = v;
|
| 708 |
+
}
|
| 709 |
+
const positive = Object.values(weights).filter(v => v > 0).length;
|
| 710 |
+
if (positive < 2) return '์์ ๊ฐ์ค์น ๋ชจ๋ธ์ด 2๊ฐ ์ด์ ํ์ํฉ๋๋ค.';
|
| 711 |
+
return weights;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
// ๋ฐฑ์๋(data_service)๊ฐ ds๋ก ๋งคํํ๋ ์ปฌ๋ผ๋ช
๊ณผ ๋์ผํ๊ฒ ์ ์ง
|
| 715 |
+
const DATE_HEADERS = ['ds', 'date', 'time', 'datetime'];
|
| 716 |
+
|
| 717 |
+
function extractDatesFromRows(rows) {
|
| 718 |
+
if (rows.length < 3) return null;
|
| 719 |
+
const header = rows[0].map(h => String(h).trim().toLowerCase().replace(/"/g, ''));
|
| 720 |
+
const dateIdx = header.findIndex(h => DATE_HEADERS.includes(h));
|
| 721 |
+
if (dateIdx < 0) return null;
|
| 722 |
+
return rows.slice(1, 12)
|
| 723 |
+
.map(r => (r[dateIdx] != null && r[dateIdx] !== '') ? r[dateIdx] : null)
|
| 724 |
+
.filter(v => v != null);
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
async function detectFreqFromFile(file) {
|
| 728 |
const name = file.name.toLowerCase();
|
| 729 |
+
let dates = null;
|
| 730 |
+
|
| 731 |
+
if (name.endsWith('.csv')) {
|
| 732 |
+
const text = await file.text();
|
| 733 |
+
const lines = text.split('\n').filter(l => l.trim());
|
| 734 |
+
const rows = lines.map(l => l.split(',').map(c => c.trim().replace(/"/g, '')));
|
| 735 |
+
dates = extractDatesFromRows(rows);
|
| 736 |
+
} else if (name.endsWith('.xlsx') || name.endsWith('.xls')) {
|
| 737 |
+
const buf = await file.arrayBuffer();
|
| 738 |
+
const wb = XLSX.read(buf, { type: 'array', cellDates: true });
|
| 739 |
+
const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, raw: true });
|
| 740 |
+
dates = extractDatesFromRows(rows);
|
| 741 |
+
} else if (name.endsWith('.json')) {
|
| 742 |
+
const arr = JSON.parse(await file.text());
|
| 743 |
+
if (Array.isArray(arr) && arr.length >= 2 && typeof arr[0] === 'object') {
|
| 744 |
+
const keys = Object.keys(arr[0]);
|
| 745 |
+
const dateKey = keys.find(k => DATE_HEADERS.includes(k.trim().toLowerCase()));
|
| 746 |
+
if (dateKey) dates = arr.slice(0, 11).map(r => r[dateKey]).filter(v => v != null);
|
| 747 |
+
}
|
| 748 |
+
} else {
|
| 749 |
+
return false;
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
if (!dates) return false;
|
| 753 |
const freq = detectFreqFromDates(dates);
|
| 754 |
if (freq) { applyFreq(freq); return true; }
|
| 755 |
return false;
|
|
|
|
| 780 |
predictBtn.disabled = true;
|
| 781 |
statusEl.className = 'status';
|
| 782 |
resultsSection.classList.remove('visible');
|
| 783 |
+
resetWeightPanel();
|
| 784 |
if (mainChart) { mainChart.destroy(); mainChart = null; }
|
| 785 |
if (weightsChart) { weightsChart.destroy(); weightsChart = null; }
|
| 786 |
});
|
| 787 |
|
| 788 |
predictBtn.addEventListener('click', async () => {
|
| 789 |
if (!fileInput.files[0]) return;
|
| 790 |
+
|
| 791 |
+
let manualWeights = null;
|
| 792 |
+
let modelCount = 7;
|
| 793 |
+
if (isManualWeightMode()) {
|
| 794 |
+
const collected = collectManualWeights();
|
| 795 |
+
if (typeof collected === 'string') { setStatus('error', collected); return; }
|
| 796 |
+
manualWeights = collected;
|
| 797 |
+
modelCount = Object.values(collected).filter(v => v > 0).length;
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
setStatus('loading', `<span class="spinner"></span>${modelCount}๊ฐ ๋ชจ๋ธ ๋ณ๋ ฌ ์คํ ์ค... (์ต๋ 2๋ถ ์์)`);
|
| 801 |
predictBtn.disabled = true;
|
| 802 |
|
| 803 |
const formData = new FormData();
|
| 804 |
formData.append('file', fileInput.files[0]);
|
| 805 |
formData.append('forecast_horizon', document.getElementById('forecastHorizon').value);
|
| 806 |
+
if (manualWeights) formData.append('model_weights', JSON.stringify(manualWeights));
|
| 807 |
|
| 808 |
try {
|
| 809 |
const res = await fetch('/api/hybrid/predict', { method: 'POST', body: formData });
|
|
|
|
| 813 |
return;
|
| 814 |
}
|
| 815 |
renderResults(json);
|
| 816 |
+
document.getElementById('ensembleMethodDesc').textContent = manualWeights
|
| 817 |
+
? `์ฌ์ฉ์ ์ง์ ๊ฐ์ค์น๋ก ${modelCount}๊ฐ ๋ชจ๋ธ์ ๋ณ๋ ฌ ์คํํด ํ๊ท ํฉ๋๋ค. ๋ถํ์ค์ฑ ๊ตฌ๊ฐ์ ๋ชจ๋ธ ๊ฐ ์์ธก ํ์คํธ์ฐจ(ยฑ1.96ฯ)๋ก ์ฐ์ถ๋ฉ๋๋ค.`
|
| 818 |
+
: '7๊ฐ ๋ชจ๋ธ์ ๋ณ๋ ฌ ์คํํ ๋ค ๋ฐ์ดํฐ ํน์ฑ(๋ณ๋์ฑยท์ถ์ธยท๊ตฌ์กฐ ๋ณํ)์ ๋ฐ๋ผ ๊ฐ์ค์น๋ฅผ ์กฐ์ ํด ํ๊ท ํฉ๋๋ค. ๋ถํ์ค์ฑ ๊ตฌ๊ฐ์ ๋ชจ๋ธ ๊ฐ ์์ธก ํ์คํธ์ฐจ(ยฑ1.96ฯ)๋ก ์ฐ์ถ๋ฉ๋๋ค.';
|
| 819 |
latestResult = json;
|
| 820 |
setStatus('success', `์์ธก ์๋ฃ โ ์ฌ์ฉ ๋ชจ๋ธ: ${(json.models_used || []).map(escHtml).join(', ')}`);
|
| 821 |
resultsSection.classList.add('visible');
|