psnc commited on
Commit
b68936d
ยท
verified ยท
1 Parent(s): 0333b4f

Sync from GitHub Actions

Browse files
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 run_ensemble_async
 
 
 
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(df: pd.DataFrame, forecast_horizon: int) -> dict:
 
 
 
 
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;">7๊ฐœ ๋ชจ๋ธ์„ ๋ณ‘๋ ฌ ์‹คํ–‰ํ•œ ๋’ค ๋ฐ์ดํ„ฐ ํŠน์„ฑ(๋ณ€๋™์„ฑยท์ถ”์„ธยท๊ตฌ์กฐ ๋ณ€ํ™”)์— ๋”ฐ๋ผ ๊ฐ€์ค‘์น˜๋ฅผ ์กฐ์ •ํ•ด ํ‰๊ท ํ•ฉ๋‹ˆ๋‹ค. ๋ถˆํ™•์‹ค์„ฑ ๊ตฌ๊ฐ„์€ ๋ชจ๋ธ ๊ฐ„ ์˜ˆ์ธก ํ‘œ์ค€ํŽธ์ฐจ(ยฑ1.96ฯƒ)๋กœ ์‚ฐ์ถœ๋ฉ๋‹ˆ๋‹ค.</p>
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
- if (!name.endsWith('.csv')) return false;
600
- const text = await file.text();
601
- const lines = text.split('\n').filter(l => l.trim());
602
- if (lines.length < 3) return false;
603
- const header = lines[0].split(',').map(h => h.trim().toLowerCase().replace(/"/g, ''));
604
- const dateIdx = header.findIndex(h => h === 'date' || h === 'ds');
605
- if (dateIdx < 0) return false;
606
- const dates = lines.slice(1, 12).map(l => {
607
- const cols = l.split(',');
608
- return cols[dateIdx] ? cols[dateIdx].trim().replace(/"/g, '') : '';
609
- }).filter(Boolean);
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- setStatus('loading', '<span class="spinner"></span>7๊ฐœ ๋ชจ๋ธ ๋ณ‘๋ ฌ ์‹คํ–‰ ์ค‘... (์ตœ๋Œ€ 2๋ถ„ ์†Œ์š”)');
 
 
 
 
 
 
 
 
 
 
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');