Spaces:
Sleeping
Sleeping
chore: untrack .env.example and add timesfm phase1 plan doc
Browse filesCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs/superpowers/plans/2026-04-03-timesfm-phase1.md
ADDED
|
@@ -0,0 +1,1074 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TimesFM Phase 1 Implementation Plan
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
| 4 |
+
|
| 5 |
+
**Goal:** 기존 FastAPI 백엔드에 TimesFM 2.5 예측 모델을 추가하고 전용 UI 페이지를 제공한다.
|
| 6 |
+
|
| 7 |
+
**Architecture:** `timesfm_service.py`에서 싱글톤 패턴으로 모델을 한 번만 로드하고, `main.py`의 lifespan 이벤트로 앱 시작 시 백그라운드 로딩을 실행한다. 기존 7개 모델 코드는 일절 수정하지 않는다.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** timesfm[torch], anthropic SDK, FastAPI lifespan, Chart.js, Vanilla JS
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 파일 맵
|
| 14 |
+
|
| 15 |
+
| 작업 | 경로 | 비고 |
|
| 16 |
+
|------|------|------|
|
| 17 |
+
| 수정 | `requirements.txt` | timesfm[torch], huggingface_hub 추가 |
|
| 18 |
+
| 신규 | `src/domains/time_series_prediction/timesfm_service.py` | 싱글톤 모델 + 예측 + Claude 해석 |
|
| 19 |
+
| 수정 | `main.py` | lifespan 추가, 4개 라우트 추가, 3개 import 추가 |
|
| 20 |
+
| 신규 | `static/timesfm.html` | TimesFM 전용 다크 테마 UI |
|
| 21 |
+
| 수정 | `Dockerfile` | HF 캐시 디렉터리 설정 |
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Task 1: requirements.txt에 패키지 추가
|
| 26 |
+
|
| 27 |
+
**Files:**
|
| 28 |
+
- Modify: `requirements.txt`
|
| 29 |
+
|
| 30 |
+
- [ ] **Step 1: requirements.txt 하단에 추가**
|
| 31 |
+
|
| 32 |
+
기존 내용 유지, 파일 맨 끝에 다음 블록 추가:
|
| 33 |
+
|
| 34 |
+
```
|
| 35 |
+
# TimesFM 관련 추가 패키지
|
| 36 |
+
timesfm[torch]
|
| 37 |
+
huggingface_hub>=0.23.0
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
> `anthropic>=0.25.0`은 이미 있으므로 추가하지 않는다.
|
| 41 |
+
|
| 42 |
+
- [ ] **Step 2: 커밋**
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
git add requirements.txt
|
| 46 |
+
git commit -m "chore: add timesfm and huggingface_hub to requirements"
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## Task 2: timesfm_service.py 신규 생성
|
| 52 |
+
|
| 53 |
+
**Files:**
|
| 54 |
+
- Create: `src/domains/time_series_prediction/timesfm_service.py`
|
| 55 |
+
|
| 56 |
+
- [ ] **Step 1: 파일 생성**
|
| 57 |
+
|
| 58 |
+
```python
|
| 59 |
+
"""
|
| 60 |
+
TimesFM 2.5 예측 서비스
|
| 61 |
+
싱글톤 패턴으로 모델을 한 번만 로드하고 재사용한다.
|
| 62 |
+
"""
|
| 63 |
+
import os
|
| 64 |
+
import logging
|
| 65 |
+
import threading
|
| 66 |
+
import math
|
| 67 |
+
import numpy as np
|
| 68 |
+
import pandas as pd
|
| 69 |
+
|
| 70 |
+
logger = logging.getLogger(__name__)
|
| 71 |
+
|
| 72 |
+
# ─── 싱글톤 모델 상태 ────────────────────────────────────────
|
| 73 |
+
_model = None
|
| 74 |
+
_model_status = "not_loaded" # "not_loaded" | "loading" | "loaded" | "error:<msg>"
|
| 75 |
+
_lock = threading.Lock()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def load_model():
|
| 79 |
+
"""앱 시작 시 1회 호출. CPU 백엔드로 TimesFM 2.5 로드."""
|
| 80 |
+
global _model, _model_status
|
| 81 |
+
with _lock:
|
| 82 |
+
if _model_status in ("loading", "loaded"):
|
| 83 |
+
return
|
| 84 |
+
_model_status = "loading"
|
| 85 |
+
try:
|
| 86 |
+
import timesfm
|
| 87 |
+
model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(
|
| 88 |
+
"google/timesfm-2.5-200m-pytorch"
|
| 89 |
+
)
|
| 90 |
+
model.compile(
|
| 91 |
+
timesfm.ForecastConfig(
|
| 92 |
+
max_context=1024,
|
| 93 |
+
max_horizon=256,
|
| 94 |
+
normalize_inputs=True,
|
| 95 |
+
use_continuous_quantile_head=True,
|
| 96 |
+
infer_is_positive=True,
|
| 97 |
+
fix_quantile_crossing=True,
|
| 98 |
+
)
|
| 99 |
+
)
|
| 100 |
+
with _lock:
|
| 101 |
+
_model = model
|
| 102 |
+
_model_status = "loaded"
|
| 103 |
+
logger.info("TimesFM 2.5 모델 로딩 완료")
|
| 104 |
+
except Exception as e:
|
| 105 |
+
with _lock:
|
| 106 |
+
_model_status = f"error: {str(e)}"
|
| 107 |
+
logger.error("TimesFM 모델 로딩 실패: %s", e)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def get_status() -> str:
|
| 111 |
+
"""현재 모델 로딩 상태 반환."""
|
| 112 |
+
return _model_status
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ─── 주파수 인디케이터 변환 ──────────────────────────────────
|
| 116 |
+
_FREQ_HIGH = {"D", "H", "T", "MIN", "S"}
|
| 117 |
+
_FREQ_LOW = {"YS", "Y", "A", "AS"}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def get_freq_indicator(freq_str: str) -> int:
|
| 121 |
+
"""pandas freq string → TimesFM freq indicator (0/1/2)."""
|
| 122 |
+
upper = freq_str.upper() if freq_str else "MS"
|
| 123 |
+
if upper in _FREQ_HIGH:
|
| 124 |
+
return 0
|
| 125 |
+
if upper in _FREQ_LOW:
|
| 126 |
+
return 2
|
| 127 |
+
return 1 # W, M, MS, QS, Q 등 중빈도
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ─── 메트릭 계산 ─────────────────────────────────────────────
|
| 131 |
+
def _calc_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
|
| 132 |
+
"""MAE, RMSE, MAPE, R² 계산."""
|
| 133 |
+
mae = float(np.mean(np.abs(y_true - y_pred)))
|
| 134 |
+
rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
|
| 135 |
+
|
| 136 |
+
# MAPE: y_true=0인 행 제외
|
| 137 |
+
mask = y_true != 0
|
| 138 |
+
mape = float(np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask]))) if mask.any() else 0.0
|
| 139 |
+
|
| 140 |
+
ss_res = np.sum((y_true - y_pred) ** 2)
|
| 141 |
+
ss_tot = np.sum((y_true - np.mean(y_true)) ** 2)
|
| 142 |
+
r2 = float(1 - ss_res / ss_tot) if ss_tot != 0 else 0.0
|
| 143 |
+
|
| 144 |
+
return {"mae": round(mae, 4), "rmse": round(rmse, 4), "mape": round(mape, 4), "r2": round(r2, 4)}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ─── 예측 메인 함수 ─────────────────────────────��────────────
|
| 148 |
+
def predict_with_timesfm(df: pd.DataFrame, params: dict) -> dict:
|
| 149 |
+
"""
|
| 150 |
+
TimesFM 2.5로 시계열 예측 수행.
|
| 151 |
+
|
| 152 |
+
params:
|
| 153 |
+
horizon (int): 예측 스텝 수 (기본 12)
|
| 154 |
+
freq (str): pandas freq string (기본 "MS")
|
| 155 |
+
context_len (int): 사용할 최대 과거 포인트 수 (기본 512)
|
| 156 |
+
test_size (float): 백테스트 비율 (기본 0.2)
|
| 157 |
+
quantile_low (int): 하한 분위수 % (기본 10)
|
| 158 |
+
quantile_high (int): 상한 분위수 % (기본 90)
|
| 159 |
+
"""
|
| 160 |
+
if _model_status != "loaded" or _model is None:
|
| 161 |
+
return {"success": False, "error": f"모델 미준비: {_model_status}"}
|
| 162 |
+
|
| 163 |
+
horizon = int(params.get("horizon", 12))
|
| 164 |
+
freq = str(params.get("freq", "MS"))
|
| 165 |
+
context_len = int(params.get("context_len", 512))
|
| 166 |
+
test_size = float(params.get("test_size", 0.2))
|
| 167 |
+
q_low_pct = int(params.get("quantile_low", 10))
|
| 168 |
+
q_high_pct = int(params.get("quantile_high", 90))
|
| 169 |
+
|
| 170 |
+
try:
|
| 171 |
+
# 1. 날짜/값 정규화
|
| 172 |
+
ds_col = "ds" if "ds" in df.columns else "date"
|
| 173 |
+
y_col = "y" if "y" in df.columns else "value"
|
| 174 |
+
df = df[[ds_col, y_col]].copy()
|
| 175 |
+
df.columns = ["ds", "y"]
|
| 176 |
+
df["ds"] = pd.to_datetime(df["ds"])
|
| 177 |
+
df = df.sort_values("ds").reset_index(drop=True)
|
| 178 |
+
df["y"] = pd.to_numeric(df["y"], errors="coerce")
|
| 179 |
+
df = df.dropna(subset=["y"])
|
| 180 |
+
|
| 181 |
+
# 2. context_len 슬라이싱
|
| 182 |
+
if len(df) > context_len:
|
| 183 |
+
df = df.iloc[-context_len:].reset_index(drop=True)
|
| 184 |
+
|
| 185 |
+
n = len(df)
|
| 186 |
+
test_n = max(1, int(n * test_size))
|
| 187 |
+
train_n = n - test_n
|
| 188 |
+
|
| 189 |
+
if train_n < 10:
|
| 190 |
+
return {"success": False, "error": f"학습 데이터 부족: {train_n}개 (최소 10개 필요)"}
|
| 191 |
+
|
| 192 |
+
train_values = df["y"].values[:train_n].tolist()
|
| 193 |
+
test_values = df["y"].values[train_n:].tolist()
|
| 194 |
+
freq_indicator = get_freq_indicator(freq)
|
| 195 |
+
|
| 196 |
+
# 3. test_n + horizon 스텝 예측 (test 구간 + 미래 포함)
|
| 197 |
+
predict_steps = test_n + horizon
|
| 198 |
+
point_forecast, quantile_forecast = _model.forecast(
|
| 199 |
+
horizon=predict_steps,
|
| 200 |
+
inputs=[train_values],
|
| 201 |
+
freq=[freq_indicator],
|
| 202 |
+
)
|
| 203 |
+
# point_forecast: (1, predict_steps)
|
| 204 |
+
# quantile_forecast: (1, predict_steps, 10) → 0=10%, 4=50%, 8=90%
|
| 205 |
+
pf = point_forecast[0] # (predict_steps,)
|
| 206 |
+
qf = quantile_forecast[0] # (predict_steps, 10)
|
| 207 |
+
|
| 208 |
+
# 4. 분위수 인덱스 계산 (10개 분위: 10,20,...,90,100)
|
| 209 |
+
q_low_idx = max(0, min(9, (q_low_pct // 10) - 1))
|
| 210 |
+
q_high_idx = max(0, min(9, (q_high_pct // 10) - 1))
|
| 211 |
+
|
| 212 |
+
# 5. test 구간 metrics 계산
|
| 213 |
+
y_true = np.array(test_values)
|
| 214 |
+
y_pred = pf[:test_n]
|
| 215 |
+
metrics = _calc_metrics(y_true, y_pred)
|
| 216 |
+
|
| 217 |
+
# 6. 결과 데이터 조립
|
| 218 |
+
# - 학습 구간: 실제값, yhat=None (차트에서 과거 실제값으로 표시)
|
| 219 |
+
# - test 구간: 실제값 + 예측값
|
| 220 |
+
# - 미래 구간: 실제값=None + 예측값
|
| 221 |
+
data = []
|
| 222 |
+
|
| 223 |
+
# 학습 구간
|
| 224 |
+
for i in range(train_n):
|
| 225 |
+
row = df.iloc[i]
|
| 226 |
+
ds_str = row["ds"].strftime("%Y-%m-%d") if hasattr(row["ds"], "strftime") else str(row["ds"])
|
| 227 |
+
data.append({
|
| 228 |
+
"ds": ds_str,
|
| 229 |
+
"y": float(row["y"]),
|
| 230 |
+
"yhat": None,
|
| 231 |
+
"yhat_lower": None,
|
| 232 |
+
"yhat_upper": None,
|
| 233 |
+
"is_future": False,
|
| 234 |
+
})
|
| 235 |
+
|
| 236 |
+
# test 구간
|
| 237 |
+
for i in range(test_n):
|
| 238 |
+
row = df.iloc[train_n + i]
|
| 239 |
+
ds_str = row["ds"].strftime("%Y-%m-%d") if hasattr(row["ds"], "strftime") else str(row["ds"])
|
| 240 |
+
data.append({
|
| 241 |
+
"ds": ds_str,
|
| 242 |
+
"y": float(row["y"]),
|
| 243 |
+
"yhat": round(float(pf[i]), 4),
|
| 244 |
+
"yhat_lower": round(float(qf[i][q_low_idx]), 4),
|
| 245 |
+
"yhat_upper": round(float(qf[i][q_high_idx]), 4),
|
| 246 |
+
"is_future": False,
|
| 247 |
+
})
|
| 248 |
+
|
| 249 |
+
# 미래 구간 — 마지막 날짜 기준으로 freq offset 생성
|
| 250 |
+
last_date = df["ds"].iloc[-1]
|
| 251 |
+
try:
|
| 252 |
+
future_dates = pd.date_range(start=last_date, periods=horizon + 1, freq=freq)[1:]
|
| 253 |
+
except Exception:
|
| 254 |
+
future_dates = pd.date_range(start=last_date, periods=horizon + 1, freq="MS")[1:]
|
| 255 |
+
|
| 256 |
+
for i in range(horizon):
|
| 257 |
+
ds_str = future_dates[i].strftime("%Y-%m-%d")
|
| 258 |
+
fi = test_n + i
|
| 259 |
+
data.append({
|
| 260 |
+
"ds": ds_str,
|
| 261 |
+
"y": None,
|
| 262 |
+
"yhat": round(float(pf[fi]), 4),
|
| 263 |
+
"yhat_lower": round(float(qf[fi][q_low_idx]), 4),
|
| 264 |
+
"yhat_upper": round(float(qf[fi][q_high_idx]), 4),
|
| 265 |
+
"is_future": True,
|
| 266 |
+
})
|
| 267 |
+
|
| 268 |
+
return {
|
| 269 |
+
"success": True,
|
| 270 |
+
"data": data,
|
| 271 |
+
"metrics": metrics,
|
| 272 |
+
"model_version": "timesfm-2.5-200m",
|
| 273 |
+
"test_size": test_n,
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
except Exception as e:
|
| 277 |
+
logger.exception("TimesFM 예측 오류")
|
| 278 |
+
return {"success": False, "error": str(e)}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ─── Claude 해석 ─────────────────────────────────────────────
|
| 282 |
+
def interpret_forecast(forecast_summary: dict, domain_hint: str = "기타") -> dict:
|
| 283 |
+
"""
|
| 284 |
+
Claude claude-sonnet-4-6 API로 예측 결과 한국어 해석 생성.
|
| 285 |
+
ANTHROPIC_API_KEY 환경변수 필요.
|
| 286 |
+
"""
|
| 287 |
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
| 288 |
+
if not api_key:
|
| 289 |
+
return {"error": "ANTHROPIC_API_KEY 환경변수가 설정되지 않았습니다."}
|
| 290 |
+
|
| 291 |
+
try:
|
| 292 |
+
import anthropic
|
| 293 |
+
client = anthropic.Anthropic(api_key=api_key)
|
| 294 |
+
message = client.messages.create(
|
| 295 |
+
model="claude-sonnet-4-6",
|
| 296 |
+
max_tokens=512,
|
| 297 |
+
system=(
|
| 298 |
+
"당신은 공공데이터 시계열 분석 전문가입니다.\n"
|
| 299 |
+
"시계열 예측 결과를 받아 다음 내용을 한국어로 작성하세요.\n"
|
| 300 |
+
"1. 예측 신뢰도 평가 (MAPE 기준: <5% 매우 높음, 5-15% 높음, 15-30% 보통, >30% 낮음)\n"
|
| 301 |
+
"2. 핵심 트렌드 요약 (2-3문장)\n"
|
| 302 |
+
"3. 활용 제안 또는 주의사항 (1-2문장)\n\n"
|
| 303 |
+
'JSON만 반환하세요 (마크다운 없이):\n'
|
| 304 |
+
'{"reliability": "매우 높음", "reliability_reason": "...", "summary": "...", "notes": "..."}'
|
| 305 |
+
),
|
| 306 |
+
messages=[
|
| 307 |
+
{
|
| 308 |
+
"role": "user",
|
| 309 |
+
"content": f"도메인: {domain_hint}\n예측 요약: {forecast_summary}",
|
| 310 |
+
}
|
| 311 |
+
],
|
| 312 |
+
)
|
| 313 |
+
import json
|
| 314 |
+
text = message.content[0].text
|
| 315 |
+
return json.loads(text)
|
| 316 |
+
except Exception as e:
|
| 317 |
+
logger.error("Claude 해석 오류: %s", e)
|
| 318 |
+
return {"error": str(e)}
|
| 319 |
+
```
|
| 320 |
+
|
| 321 |
+
- [ ] **Step 2: 커밋**
|
| 322 |
+
|
| 323 |
+
```bash
|
| 324 |
+
git add src/domains/time_series_prediction/timesfm_service.py
|
| 325 |
+
git commit -m "feat: add timesfm_service.py with singleton model loading"
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
+
---
|
| 329 |
+
|
| 330 |
+
## Task 3: main.py — lifespan 및 라우트 추가
|
| 331 |
+
|
| 332 |
+
**Files:**
|
| 333 |
+
- Modify: `main.py` (line 29, 96-100, 끝부분)
|
| 334 |
+
|
| 335 |
+
**세 곳을 수정한다:**
|
| 336 |
+
|
| 337 |
+
### 3-A. import 추가 (line 29 수정)
|
| 338 |
+
|
| 339 |
+
- [ ] **Step 1: fastapi import 라인에 Request 추가 (line 29)**
|
| 340 |
+
|
| 341 |
+
기존:
|
| 342 |
+
```python
|
| 343 |
+
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
|
| 344 |
+
```
|
| 345 |
+
변경 후:
|
| 346 |
+
```python
|
| 347 |
+
from contextlib import asynccontextmanager
|
| 348 |
+
import asyncio
|
| 349 |
+
|
| 350 |
+
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
| 351 |
+
```
|
| 352 |
+
|
| 353 |
+
### 3-B. app 선언 전에 lifespan 함수 추가, app 선언 수정
|
| 354 |
+
|
| 355 |
+
- [ ] **Step 2: app = FastAPI(...) 블록 교체 (line 96-100)**
|
| 356 |
+
|
| 357 |
+
기존:
|
| 358 |
+
```python
|
| 359 |
+
app = FastAPI(
|
| 360 |
+
title="시계열 예측 API",
|
| 361 |
+
description="N-BEATS, Prophet, ARIMA/SARIMA, XGBoost, ETS, LightGBM, Theta 기반 시계열 예측 서비스",
|
| 362 |
+
version="3.0.0",
|
| 363 |
+
)
|
| 364 |
+
```
|
| 365 |
+
|
| 366 |
+
변경 후:
|
| 367 |
+
```python
|
| 368 |
+
@asynccontextmanager
|
| 369 |
+
async def lifespan(app: FastAPI):
|
| 370 |
+
"""앱 시작 시 TimesFM 모델을 백그라운드 스레드에서 로드"""
|
| 371 |
+
from src.domains.time_series_prediction import timesfm_service
|
| 372 |
+
loop = asyncio.get_event_loop()
|
| 373 |
+
loop.run_in_executor(None, timesfm_service.load_model)
|
| 374 |
+
yield
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
app = FastAPI(
|
| 378 |
+
title="시계열 예측 API",
|
| 379 |
+
description="N-BEATS, Prophet, ARIMA/SARIMA, XGBoost, ETS, LightGBM, Theta 기반 시계열 예측 서비스",
|
| 380 |
+
version="3.0.0",
|
| 381 |
+
lifespan=lifespan,
|
| 382 |
+
)
|
| 383 |
+
```
|
| 384 |
+
|
| 385 |
+
### 3-C. 파일 끝에 TimesFM 라우트 추가
|
| 386 |
+
|
| 387 |
+
- [ ] **Step 3: main.py 맨 끝(line 886~)에 아래 코드 추가**
|
| 388 |
+
|
| 389 |
+
```python
|
| 390 |
+
|
| 391 |
+
# ─── TimesFM 라우트 ──────────────────────────────────────────
|
| 392 |
+
|
| 393 |
+
@app.get("/timesfm")
|
| 394 |
+
async def timesfm_page():
|
| 395 |
+
"""TimesFM 2.5 예측 페이지"""
|
| 396 |
+
return _serve_html("timesfm.html")
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
@app.get("/api/timesfm/status")
|
| 400 |
+
async def timesfm_status():
|
| 401 |
+
"""TimesFM 모델 로딩 상태 확인"""
|
| 402 |
+
from src.domains.time_series_prediction import timesfm_service
|
| 403 |
+
return {"status": timesfm_service.get_status()}
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
@app.post("/api/timesfm/predict")
|
| 407 |
+
async def timesfm_predict(
|
| 408 |
+
file: UploadFile = File(...),
|
| 409 |
+
params: str = Form("{}"),
|
| 410 |
+
):
|
| 411 |
+
"""TimesFM 2.5로 시계열 예측"""
|
| 412 |
+
from src.domains.time_series_prediction import timesfm_service
|
| 413 |
+
try:
|
| 414 |
+
content = await file.read()
|
| 415 |
+
df, error = process_uploaded_file(content, file.filename or "data.csv")
|
| 416 |
+
if error:
|
| 417 |
+
raise HTTPException(status_code=400, detail=error)
|
| 418 |
+
is_valid, validation_error = validate_data(df)
|
| 419 |
+
if not is_valid:
|
| 420 |
+
raise HTTPException(status_code=400, detail=validation_error)
|
| 421 |
+
params_dict = json.loads(params) if params.strip() else {}
|
| 422 |
+
result = timesfm_service.predict_with_timesfm(df, params_dict)
|
| 423 |
+
if not result.get("success"):
|
| 424 |
+
raise HTTPException(status_code=422, detail=result.get("error", "예측 실패"))
|
| 425 |
+
return result
|
| 426 |
+
except HTTPException:
|
| 427 |
+
raise
|
| 428 |
+
except Exception as e:
|
| 429 |
+
logger.exception("TimesFM 예측 오류")
|
| 430 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
@app.post("/api/timesfm/interpret")
|
| 434 |
+
async def timesfm_interpret(request: Request):
|
| 435 |
+
"""Claude API로 TimesFM 예측 결과 한국어 해석"""
|
| 436 |
+
from src.domains.time_series_prediction import timesfm_service
|
| 437 |
+
try:
|
| 438 |
+
body = await request.json()
|
| 439 |
+
result = timesfm_service.interpret_forecast(
|
| 440 |
+
body.get("forecast_summary"),
|
| 441 |
+
body.get("domain_hint", "기타"),
|
| 442 |
+
)
|
| 443 |
+
return result
|
| 444 |
+
except Exception as e:
|
| 445 |
+
logger.exception("TimesFM 해석 오류")
|
| 446 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 447 |
+
```
|
| 448 |
+
|
| 449 |
+
- [ ] **Step 4: 서버 기동 확인**
|
| 450 |
+
|
| 451 |
+
```bash
|
| 452 |
+
cd /Users/pjkk/Developer/Projects/nbeats
|
| 453 |
+
uvicorn main:app --port 8000 --reload
|
| 454 |
+
```
|
| 455 |
+
|
| 456 |
+
예상 출력 (timesfm 패키지 미설치 환경에서는 load_model이 error 상태로 전환):
|
| 457 |
+
```
|
| 458 |
+
INFO: Application startup complete.
|
| 459 |
+
```
|
| 460 |
+
`http://localhost:8000/api/timesfm/status` 응답: `{"status": "loading"}` 또는 `{"status": "error: ..."}`
|
| 461 |
+
|
| 462 |
+
- [ ] **Step 5: 커밋**
|
| 463 |
+
|
| 464 |
+
```bash
|
| 465 |
+
git add main.py
|
| 466 |
+
git commit -m "feat: add TimesFM lifespan loader and API routes to main.py"
|
| 467 |
+
```
|
| 468 |
+
|
| 469 |
+
---
|
| 470 |
+
|
| 471 |
+
## Task 4: static/timesfm.html 신규 생성
|
| 472 |
+
|
| 473 |
+
**Files:**
|
| 474 |
+
- Create: `static/timesfm.html`
|
| 475 |
+
|
| 476 |
+
- [ ] **Step 1: 파일 생성**
|
| 477 |
+
|
| 478 |
+
아래 전체 내용을 `static/timesfm.html`로 저장한다. 기존 index.html의 CSS 변수·네비·카드 구조를 그대로 사용한다.
|
| 479 |
+
|
| 480 |
+
```html
|
| 481 |
+
<!DOCTYPE html>
|
| 482 |
+
<html lang="ko">
|
| 483 |
+
<head>
|
| 484 |
+
<meta charset="UTF-8">
|
| 485 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 486 |
+
<title>TimesFM 2.5 - 시계열 예측</title>
|
| 487 |
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔮</text></svg>">
|
| 488 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 489 |
+
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
| 490 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 491 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 492 |
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 493 |
+
<style>
|
| 494 |
+
:root {
|
| 495 |
+
--bg: #0f0f12;
|
| 496 |
+
--surface: #1a1a1f;
|
| 497 |
+
--surface-hover: #222228;
|
| 498 |
+
--border: #2a2a32;
|
| 499 |
+
--text: #e8e8ed;
|
| 500 |
+
--text-muted: #8b8b96;
|
| 501 |
+
--accent: #6366f1;
|
| 502 |
+
--accent-hover: #818cf8;
|
| 503 |
+
--success: #22c55e;
|
| 504 |
+
--error: #ef4444;
|
| 505 |
+
--warning: #f59e0b;
|
| 506 |
+
--sky: #38bdf8;
|
| 507 |
+
--radius: 12px;
|
| 508 |
+
}
|
| 509 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 510 |
+
body { font-family: 'Outfit', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; line-height: 1.6; }
|
| 511 |
+
|
| 512 |
+
/* 네비게이션 */
|
| 513 |
+
.top-nav { background: var(--surface); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 100; }
|
| 514 |
+
.nav-inner { max-width: 1060px; margin: 0 auto; padding: 0 2rem; display: flex; align-items: center; gap: 2rem; height: 56px; overflow-x: auto; }
|
| 515 |
+
.nav-brand { font-weight: 700; font-size: 1rem; color: var(--text); text-decoration: none; letter-spacing: -0.01em; white-space: nowrap; }
|
| 516 |
+
.nav-links { display: flex; gap: 0.25rem; list-style: none; }
|
| 517 |
+
.nav-links a { display: flex; align-items: center; gap: 0.4rem; padding: 0.5rem 0.75rem; border-radius: 8px; text-decoration: none; font-size: 0.82rem; font-weight: 500; white-space: nowrap; color: var(--text-muted); transition: all 0.15s; }
|
| 518 |
+
.nav-links a:hover { color: var(--text); background: var(--surface-hover); }
|
| 519 |
+
.nav-links a.active { color: var(--sky); background: rgba(56, 189, 248, 0.1); }
|
| 520 |
+
.model-tag { font-size: 0.6rem; padding: 0.1rem 0.35rem; border-radius: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; }
|
| 521 |
+
.tag-dl { background: rgba(99,102,241,0.15); color: #a5b4fc; }
|
| 522 |
+
.tag-stat { background: rgba(34,197,94,0.15); color: #86efac; }
|
| 523 |
+
.tag-ml { background: rgba(245,158,11,0.15); color: #fcd34d; }
|
| 524 |
+
.tag-ets { background: rgba(16,185,129,0.15); color: #6ee7b7; }
|
| 525 |
+
.tag-theta { background: rgba(168,85,247,0.15); color: #c4b5fd; }
|
| 526 |
+
.tag-fm { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
| 527 |
+
|
| 528 |
+
/* 레이아웃 */
|
| 529 |
+
.container { max-width: 1060px; margin: 0 auto; padding: 2rem; }
|
| 530 |
+
header { margin-bottom: 2rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--border); display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; }
|
| 531 |
+
h1 { font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em; background: linear-gradient(135deg, #fff 0%, #7dd3fc 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
| 532 |
+
.subtitle { margin-top: 0.25rem; font-size: 0.875rem; color: var(--text-muted); }
|
| 533 |
+
|
| 534 |
+
/* 상태 뱃지 */
|
| 535 |
+
.status-badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.35rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; border: 1px solid; white-space: nowrap; }
|
| 536 |
+
.badge-loading { background: rgba(99,102,241,0.1); color: var(--accent); border-color: rgba(99,102,241,0.3); }
|
| 537 |
+
.badge-loaded { background: rgba(34,197,94,0.1); color: var(--success); border-color: rgba(34,197,94,0.3); }
|
| 538 |
+
.badge-error { background: rgba(239,68,68,0.1); color: var(--error); border-color: rgba(239,68,68,0.3); }
|
| 539 |
+
.badge-not_loaded { background: rgba(139,139,150,0.1); color: var(--text-muted); border-color: var(--border); }
|
| 540 |
+
|
| 541 |
+
/* 카드 */
|
| 542 |
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; margin-bottom: 1.25rem; }
|
| 543 |
+
.card-title { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 1rem; }
|
| 544 |
+
|
| 545 |
+
/* 업로드 */
|
| 546 |
+
.upload-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 1.75rem; text-align: center; cursor: pointer; transition: all 0.2s; background: rgba(56,189,248,0.03); }
|
| 547 |
+
.upload-zone:hover, .upload-zone.dragover { border-color: var(--sky); background: rgba(56,189,248,0.08); }
|
| 548 |
+
.upload-zone input { display: none; }
|
| 549 |
+
.upload-zone .icon { font-size: 2.25rem; margin-bottom: 0.35rem; opacity: 0.7; }
|
| 550 |
+
.upload-zone p { color: var(--text-muted); font-size: 0.875rem; }
|
| 551 |
+
.upload-zone .file-name { margin-top: 0.4rem; font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: var(--sky); }
|
| 552 |
+
|
| 553 |
+
/* 옵션 행 */
|
| 554 |
+
.options-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; align-items: flex-end; }
|
| 555 |
+
.opt-group { display: flex; flex-direction: column; gap: 0.3rem; }
|
| 556 |
+
.opt-group label { font-size: 0.75rem; color: var(--text-muted); font-weight: 500; }
|
| 557 |
+
.opt-group input[type="number"],
|
| 558 |
+
.opt-group select { padding: 0.5rem 0.7rem; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--text); font-size: 0.9rem; font-family: inherit; min-width: 110px; }
|
| 559 |
+
.opt-group input[type="number"]:focus,
|
| 560 |
+
.opt-group select:focus { outline: none; border-color: var(--sky); }
|
| 561 |
+
|
| 562 |
+
/* 버튼 */
|
| 563 |
+
.actions { display: flex; gap: 0.75rem; margin-top: 1rem; flex-wrap: wrap; }
|
| 564 |
+
.btn { padding: 0.6rem 1.2rem; border: none; border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: all 0.2s; font-family: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: 0.3rem; }
|
| 565 |
+
.btn-primary { background: var(--sky); color: #0f172a; }
|
| 566 |
+
.btn-primary:hover:not(:disabled) { background: #7dd3fc; transform: translateY(-1px); }
|
| 567 |
+
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 568 |
+
.btn-secondary { background: var(--surface-hover); color: var(--text); border: 1px solid var(--border); }
|
| 569 |
+
.btn-secondary:hover { background: var(--border); }
|
| 570 |
+
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
|
| 571 |
+
|
| 572 |
+
/* 상태 메시지 */
|
| 573 |
+
.status-msg { margin-top: 1rem; padding: 0.7rem 1rem; border-radius: 8px; font-size: 0.875rem; display: none; }
|
| 574 |
+
.status-msg.error { display: block; background: rgba(239,68,68,0.12); color: var(--error); border: 1px solid rgba(239,68,68,0.25); }
|
| 575 |
+
.status-msg.loading { display: block; background: rgba(56,189,248,0.08); color: var(--sky); border: 1px solid rgba(56,189,248,0.2); }
|
| 576 |
+
.status-msg.success { display: block; background: rgba(34,197,94,0.08); color: var(--success); border: 1px solid rgba(34,197,94,0.2); }
|
| 577 |
+
|
| 578 |
+
/* 결과 섹션 */
|
| 579 |
+
.results-section { margin-top: 1.5rem; display: none; }
|
| 580 |
+
.results-section.visible { display: block; }
|
| 581 |
+
|
| 582 |
+
/* 메트릭 */
|
| 583 |
+
.metrics-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.6rem; }
|
| 584 |
+
@media (max-width: 600px) { .metrics-grid { grid-template-columns: repeat(2, 1fr); } }
|
| 585 |
+
.metric-card { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 0.9rem 1rem; }
|
| 586 |
+
.metric-label { font-size: 0.7rem; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
| 587 |
+
.metric-value { font-family: 'JetBrains Mono', monospace; font-size: 1.3rem; font-weight: 700; margin-top: 0.2rem; color: var(--sky); }
|
| 588 |
+
|
| 589 |
+
/* 차트 */
|
| 590 |
+
.chart-container { height: 340px; }
|
| 591 |
+
|
| 592 |
+
/* AI 해석 */
|
| 593 |
+
.ai-section { margin-top: 1.25rem; }
|
| 594 |
+
.ai-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; }
|
| 595 |
+
.reliability-badge { padding: 0.25rem 0.7rem; border-radius: 999px; font-size: 0.78rem; font-weight: 700; }
|
| 596 |
+
.rel-very-high { background: rgba(34,197,94,0.15); color: #4ade80; }
|
| 597 |
+
.rel-high { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
| 598 |
+
.rel-medium { background: rgba(245,158,11,0.15); color: #fcd34d; }
|
| 599 |
+
.rel-low { background: rgba(239,68,68,0.15); color: #f87171; }
|
| 600 |
+
.ai-text { font-size: 0.9rem; color: var(--text); line-height: 1.7; margin-bottom: 0.5rem; }
|
| 601 |
+
.ai-notes { font-size: 0.85rem; color: var(--text-muted); font-style: italic; }
|
| 602 |
+
.ai-loading { color: var(--text-muted); font-size: 0.875rem; display: flex; align-items: center; gap: 0.5rem; }
|
| 603 |
+
|
| 604 |
+
/* 스피너 */
|
| 605 |
+
.spinner { display: inline-block; width: 1em; height: 1em; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; vertical-align: -0.15em; margin-right: 0.4em; }
|
| 606 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 607 |
+
</style>
|
| 608 |
+
</head>
|
| 609 |
+
<body>
|
| 610 |
+
<nav class="top-nav">
|
| 611 |
+
<div class="nav-inner">
|
| 612 |
+
<a href="/" class="nav-brand">시계열 예측</a>
|
| 613 |
+
<ul class="nav-links">
|
| 614 |
+
<li><a href="/">N-BEATS <span class="model-tag tag-dl">DL</span></a></li>
|
| 615 |
+
<li><a href="/prophet">Prophet <span class="model-tag tag-stat">Stat</span></a></li>
|
| 616 |
+
<li><a href="/xgboost">XGBoost <span class="model-tag tag-ml">ML</span></a></li>
|
| 617 |
+
<li><a href="/lightgbm">LightGBM <span class="model-tag tag-ml">ML</span></a></li>
|
| 618 |
+
<li><a href="/arima">ARIMA <span class="model-tag tag-stat">Classical</span></a></li>
|
| 619 |
+
<li><a href="/theta">Theta <span class="model-tag tag-theta">Classical</span></a></li>
|
| 620 |
+
<li><a href="/ets">ETS <span class="model-tag tag-ets">Smoothing</span></a></li>
|
| 621 |
+
<li><a href="/hybrid">Hybrid <span class="model-tag" style="background:rgba(236,72,153,0.15);color:#f472b6;">LLM</span></a></li>
|
| 622 |
+
<li><a href="/timesfm" class="active">TimesFM <span class="model-tag tag-fm">FM</span></a></li>
|
| 623 |
+
</ul>
|
| 624 |
+
</div>
|
| 625 |
+
</nav>
|
| 626 |
+
|
| 627 |
+
<div class="container">
|
| 628 |
+
<header>
|
| 629 |
+
<div>
|
| 630 |
+
<h1>TimesFM 2.5 시계열 예측</h1>
|
| 631 |
+
<p class="subtitle">Google Research Foundation Model · Zero-Shot · 200M params · 신뢰구간 지원</p>
|
| 632 |
+
</div>
|
| 633 |
+
<div id="modelStatusBadge" class="status-badge badge-not_loaded">● 모델 미로딩</div>
|
| 634 |
+
</header>
|
| 635 |
+
|
| 636 |
+
<!-- 업로드 + 옵션 -->
|
| 637 |
+
<section class="card">
|
| 638 |
+
<div class="card-title">데이터 업로드 및 옵션</div>
|
| 639 |
+
<div class="upload-zone" id="uploadZone">
|
| 640 |
+
<input type="file" id="fileInput" accept=".csv,.xlsx,.xls,.json">
|
| 641 |
+
<div class="icon">📁</div>
|
| 642 |
+
<p>CSV, Excel, JSON 파일을 드래그하거나 클릭하여 선택</p>
|
| 643 |
+
<p class="file-name" id="fileName"></p>
|
| 644 |
+
</div>
|
| 645 |
+
|
| 646 |
+
<div class="options-row">
|
| 647 |
+
<div class="opt-group">
|
| 648 |
+
<label for="domainSelect">도메인</label>
|
| 649 |
+
<select id="domainSelect">
|
| 650 |
+
<option value="교통">교통</option>
|
| 651 |
+
<option value="재정">재정</option>
|
| 652 |
+
<option value="인구">인구</option>
|
| 653 |
+
<option value="기타" selected>기타</option>
|
| 654 |
+
</select>
|
| 655 |
+
</div>
|
| 656 |
+
<div class="opt-group">
|
| 657 |
+
<label for="horizonInput">예측 기간 (스텝)</label>
|
| 658 |
+
<input type="number" id="horizonInput" value="12" min="1" max="256">
|
| 659 |
+
</div>
|
| 660 |
+
<div class="opt-group">
|
| 661 |
+
<label for="freqSelect">데이터 주기</label>
|
| 662 |
+
<select id="freqSelect">
|
| 663 |
+
<option value="D">일별 (D)</option>
|
| 664 |
+
<option value="W">주별 (W)</option>
|
| 665 |
+
<option value="MS" selected>월별 (MS)</option>
|
| 666 |
+
<option value="QS">분기별 (QS)</option>
|
| 667 |
+
<option value="YS">연별 (YS)</option>
|
| 668 |
+
</select>
|
| 669 |
+
</div>
|
| 670 |
+
<div class="opt-group">
|
| 671 |
+
<label for="ciSelect">신뢰구간</label>
|
| 672 |
+
<select id="ciSelect">
|
| 673 |
+
<option value="80">80%</option>
|
| 674 |
+
<option value="90" selected>90%</option>
|
| 675 |
+
<option value="95">95%</option>
|
| 676 |
+
</select>
|
| 677 |
+
</div>
|
| 678 |
+
</div>
|
| 679 |
+
|
| 680 |
+
<div class="actions">
|
| 681 |
+
<button class="btn btn-primary" id="predictBtn" disabled>🔮 예측 실행</button>
|
| 682 |
+
<button class="btn btn-secondary" id="resetBtn">초기화</button>
|
| 683 |
+
<button class="btn btn-secondary" id="exportCsvBtn" disabled>CSV 다운로드</button>
|
| 684 |
+
<button class="btn btn-secondary" id="exportXlsxBtn" disabled>Excel 다운로드</button>
|
| 685 |
+
</div>
|
| 686 |
+
|
| 687 |
+
<div class="status-msg" id="statusMsg"></div>
|
| 688 |
+
</section>
|
| 689 |
+
|
| 690 |
+
<!-- 결과 -->
|
| 691 |
+
<section class="results-section" id="resultsSection">
|
| 692 |
+
<!-- 메트릭 -->
|
| 693 |
+
<div class="card">
|
| 694 |
+
<div class="card-title">예측 성능 지표 (백테스트)</div>
|
| 695 |
+
<div class="metrics-grid">
|
| 696 |
+
<div class="metric-card"><div class="metric-label">MAE</div><div class="metric-value" id="metMAE">—</div></div>
|
| 697 |
+
<div class="metric-card"><div class="metric-label">RMSE</div><div class="metric-value" id="metRMSE">—</div></div>
|
| 698 |
+
<div class="metric-card"><div class="metric-label">MAPE</div><div class="metric-value" id="metMAPE">—</div></div>
|
| 699 |
+
<div class="metric-card"><div class="metric-label">R²</div><div class="metric-value" id="metR2">—</div></div>
|
| 700 |
+
</div>
|
| 701 |
+
</div>
|
| 702 |
+
|
| 703 |
+
<!-- 차트 -->
|
| 704 |
+
<div class="card">
|
| 705 |
+
<div class="card-title">예측 결과 차트</div>
|
| 706 |
+
<div class="chart-container">
|
| 707 |
+
<canvas id="forecastChart"></canvas>
|
| 708 |
+
</div>
|
| 709 |
+
</div>
|
| 710 |
+
|
| 711 |
+
<!-- AI 해석 -->
|
| 712 |
+
<div class="card ai-section">
|
| 713 |
+
<div class="card-title">AI 해석 (Claude claude-sonnet-4-6)</div>
|
| 714 |
+
<div id="aiContent">
|
| 715 |
+
<div class="ai-loading"><span class="spinner"></span> 해석 생성 중...</div>
|
| 716 |
+
</div>
|
| 717 |
+
</div>
|
| 718 |
+
</section>
|
| 719 |
+
</div>
|
| 720 |
+
|
| 721 |
+
<script>
|
| 722 |
+
(() => {
|
| 723 |
+
// ── 상태 폴링 ─────────────────────────────────────────
|
| 724 |
+
let modelReady = false;
|
| 725 |
+
let forecastData = null;
|
| 726 |
+
let chartInstance = null;
|
| 727 |
+
|
| 728 |
+
function updateStatusBadge(status) {
|
| 729 |
+
const badge = document.getElementById('modelStatusBadge');
|
| 730 |
+
badge.className = 'status-badge';
|
| 731 |
+
if (status === 'loaded') {
|
| 732 |
+
badge.classList.add('badge-loaded');
|
| 733 |
+
badge.textContent = '● 모델 준비됨';
|
| 734 |
+
modelReady = true;
|
| 735 |
+
const f = document.getElementById('fileInput').files[0];
|
| 736 |
+
document.getElementById('predictBtn').disabled = !f;
|
| 737 |
+
} else if (status === 'loading') {
|
| 738 |
+
badge.classList.add('badge-loading');
|
| 739 |
+
badge.innerHTML = '<span class="spinner"></span> 모델 로딩 중...';
|
| 740 |
+
modelReady = false;
|
| 741 |
+
document.getElementById('predictBtn').disabled = true;
|
| 742 |
+
} else if (status && status.startsWith('error')) {
|
| 743 |
+
badge.classList.add('badge-error');
|
| 744 |
+
badge.textContent = '● 로딩 오류';
|
| 745 |
+
modelReady = false;
|
| 746 |
+
} else {
|
| 747 |
+
badge.classList.add('badge-not_loaded');
|
| 748 |
+
badge.textContent = '● 모델 미로딩';
|
| 749 |
+
}
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
async function pollStatus() {
|
| 753 |
+
try {
|
| 754 |
+
const res = await fetch('/api/timesfm/status');
|
| 755 |
+
const { status } = await res.json();
|
| 756 |
+
updateStatusBadge(status);
|
| 757 |
+
if (status !== 'loaded') setTimeout(pollStatus, 2000);
|
| 758 |
+
} catch { setTimeout(pollStatus, 3000); }
|
| 759 |
+
}
|
| 760 |
+
pollStatus();
|
| 761 |
+
|
| 762 |
+
// ── 파일 업로드 ───────────────────────────────────────
|
| 763 |
+
const uploadZone = document.getElementById('uploadZone');
|
| 764 |
+
const fileInput = document.getElementById('fileInput');
|
| 765 |
+
const fileName = document.getElementById('fileName');
|
| 766 |
+
|
| 767 |
+
uploadZone.addEventListener('click', () => fileInput.click());
|
| 768 |
+
uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
|
| 769 |
+
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
|
| 770 |
+
uploadZone.addEventListener('drop', e => {
|
| 771 |
+
e.preventDefault(); uploadZone.classList.remove('dragover');
|
| 772 |
+
if (e.dataTransfer.files[0]) { fileInput.files = e.dataTransfer.files; onFileChange(); }
|
| 773 |
+
});
|
| 774 |
+
fileInput.addEventListener('change', onFileChange);
|
| 775 |
+
|
| 776 |
+
function onFileChange() {
|
| 777 |
+
const f = fileInput.files[0];
|
| 778 |
+
if (f) {
|
| 779 |
+
fileName.textContent = f.name;
|
| 780 |
+
document.getElementById('predictBtn').disabled = !modelReady;
|
| 781 |
+
}
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
// ── 예측 실행 ─────────────────────────────────────────
|
| 785 |
+
document.getElementById('predictBtn').addEventListener('click', async () => {
|
| 786 |
+
const f = fileInput.files[0];
|
| 787 |
+
if (!f) return;
|
| 788 |
+
|
| 789 |
+
const horizon = parseInt(document.getElementById('horizonInput').value) || 12;
|
| 790 |
+
const freq = document.getElementById('freqSelect').value;
|
| 791 |
+
const ci = parseInt(document.getElementById('ciSelect').value);
|
| 792 |
+
const domain = document.getElementById('domainSelect').value;
|
| 793 |
+
|
| 794 |
+
const ciLow = 100 - ci;
|
| 795 |
+
const ciHigh = ci;
|
| 796 |
+
|
| 797 |
+
setStatus('loading', '예측 중...');
|
| 798 |
+
document.getElementById('predictBtn').disabled = true;
|
| 799 |
+
|
| 800 |
+
const fd = new FormData();
|
| 801 |
+
fd.append('file', f);
|
| 802 |
+
fd.append('params', JSON.stringify({ horizon, freq, quantile_low: ciLow, quantile_high: ciHigh }));
|
| 803 |
+
|
| 804 |
+
try {
|
| 805 |
+
const res = await fetch('/api/timesfm/predict', { method: 'POST', body: fd });
|
| 806 |
+
const result = await res.json();
|
| 807 |
+
if (!res.ok || !result.success) {
|
| 808 |
+
setStatus('error', result.detail || result.error || '예측 실패');
|
| 809 |
+
return;
|
| 810 |
+
}
|
| 811 |
+
forecastData = result;
|
| 812 |
+
renderResults(result, domain);
|
| 813 |
+
setStatus('success', `예측 완료 · 모델: ${result.model_version} · 백테스트: ${result.test_size}개`);
|
| 814 |
+
} catch (e) {
|
| 815 |
+
setStatus('error', `네트워크 오류: ${e.message}`);
|
| 816 |
+
} finally {
|
| 817 |
+
document.getElementById('predictBtn').disabled = false;
|
| 818 |
+
}
|
| 819 |
+
});
|
| 820 |
+
|
| 821 |
+
// ── 결과 렌더링 ───────────────────────────────────────
|
| 822 |
+
function renderResults(result, domain) {
|
| 823 |
+
// 메트릭
|
| 824 |
+
const m = result.metrics;
|
| 825 |
+
document.getElementById('metMAE').textContent = m.mae != null ? m.mae.toFixed(3) : '—';
|
| 826 |
+
document.getElementById('metRMSE').textContent = m.rmse != null ? m.rmse.toFixed(3) : '—';
|
| 827 |
+
document.getElementById('metMAPE').textContent = m.mape != null ? (m.mape * 100).toFixed(1) + '%' : '—';
|
| 828 |
+
document.getElementById('metR2').textContent = m.r2 != null ? m.r2.toFixed(3) : '—';
|
| 829 |
+
|
| 830 |
+
// 차트
|
| 831 |
+
renderChart(result.data);
|
| 832 |
+
|
| 833 |
+
// 결과 섹션 표시
|
| 834 |
+
const sec = document.getElementById('resultsSection');
|
| 835 |
+
sec.classList.add('visible');
|
| 836 |
+
|
| 837 |
+
// 다운로드 버튼 활성화
|
| 838 |
+
document.getElementById('exportCsvBtn').disabled = false;
|
| 839 |
+
document.getElementById('exportXlsxBtn').disabled = false;
|
| 840 |
+
|
| 841 |
+
// AI 해석
|
| 842 |
+
document.getElementById('aiContent').innerHTML =
|
| 843 |
+
'<div class="ai-loading"><span class="spinner"></span> 해석 생성 중...</div>';
|
| 844 |
+
fetchInterpretation(m, domain);
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
function renderChart(data) {
|
| 848 |
+
const labels = data.map(d => d.ds);
|
| 849 |
+
const actuals = data.map(d => d.y);
|
| 850 |
+
const predicted = data.map(d => d.yhat);
|
| 851 |
+
const lowers = data.map(d => d.yhat_lower);
|
| 852 |
+
const uppers = data.map(d => d.yhat_upper);
|
| 853 |
+
|
| 854 |
+
// 신뢰구간 fill: lower → upper
|
| 855 |
+
const ciUpper = data.map((d, i) => d.yhat_upper);
|
| 856 |
+
const ciLower = data.map((d, i) => d.yhat_lower);
|
| 857 |
+
|
| 858 |
+
if (chartInstance) chartInstance.destroy();
|
| 859 |
+
const ctx = document.getElementById('forecastChart').getContext('2d');
|
| 860 |
+
chartInstance = new Chart(ctx, {
|
| 861 |
+
type: 'line',
|
| 862 |
+
data: {
|
| 863 |
+
labels,
|
| 864 |
+
datasets: [
|
| 865 |
+
{
|
| 866 |
+
label: '신뢰구간 상한',
|
| 867 |
+
data: ciUpper,
|
| 868 |
+
borderColor: 'transparent',
|
| 869 |
+
backgroundColor: 'rgba(56,189,248,0.12)',
|
| 870 |
+
pointRadius: 0,
|
| 871 |
+
fill: '+1',
|
| 872 |
+
tension: 0.3,
|
| 873 |
+
},
|
| 874 |
+
{
|
| 875 |
+
label: '신뢰구간 하한',
|
| 876 |
+
data: ciLower,
|
| 877 |
+
borderColor: 'transparent',
|
| 878 |
+
backgroundColor: 'rgba(56,189,248,0.12)',
|
| 879 |
+
pointRadius: 0,
|
| 880 |
+
fill: false,
|
| 881 |
+
tension: 0.3,
|
| 882 |
+
},
|
| 883 |
+
{
|
| 884 |
+
label: '실제값',
|
| 885 |
+
data: actuals,
|
| 886 |
+
borderColor: '#e8e8ed',
|
| 887 |
+
backgroundColor: 'transparent',
|
| 888 |
+
pointRadius: 2,
|
| 889 |
+
borderWidth: 2,
|
| 890 |
+
tension: 0.3,
|
| 891 |
+
spanGaps: false,
|
| 892 |
+
},
|
| 893 |
+
{
|
| 894 |
+
label: '예측값',
|
| 895 |
+
data: predicted,
|
| 896 |
+
borderColor: '#38bdf8',
|
| 897 |
+
backgroundColor: 'transparent',
|
| 898 |
+
pointRadius: 2,
|
| 899 |
+
borderWidth: 2,
|
| 900 |
+
borderDash: [],
|
| 901 |
+
tension: 0.3,
|
| 902 |
+
spanGaps: false,
|
| 903 |
+
},
|
| 904 |
+
],
|
| 905 |
+
},
|
| 906 |
+
options: {
|
| 907 |
+
responsive: true,
|
| 908 |
+
maintainAspectRatio: false,
|
| 909 |
+
interaction: { mode: 'index', intersect: false },
|
| 910 |
+
plugins: {
|
| 911 |
+
legend: { labels: { color: '#8b8b96', font: { size: 12 } } },
|
| 912 |
+
tooltip: { backgroundColor: '#1a1a1f', borderColor: '#2a2a32', borderWidth: 1, titleColor: '#e8e8ed', bodyColor: '#e8e8ed' },
|
| 913 |
+
},
|
| 914 |
+
scales: {
|
| 915 |
+
x: { ticks: { color: '#8b8b96', maxTicksLimit: 12 }, grid: { color: 'rgba(255,255,255,0.04)' } },
|
| 916 |
+
y: { ticks: { color: '#8b8b96' }, grid: { color: 'rgba(255,255,255,0.04)' } },
|
| 917 |
+
},
|
| 918 |
+
},
|
| 919 |
+
});
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
// ── Claude 해석 ───────────────────────────────────────
|
| 923 |
+
async function fetchInterpretation(metrics, domain) {
|
| 924 |
+
try {
|
| 925 |
+
const res = await fetch('/api/timesfm/interpret', {
|
| 926 |
+
method: 'POST',
|
| 927 |
+
headers: { 'Content-Type': 'application/json' },
|
| 928 |
+
body: JSON.stringify({ forecast_summary: metrics, domain_hint: domain }),
|
| 929 |
+
});
|
| 930 |
+
const data = await res.json();
|
| 931 |
+
if (data.error) { renderAiError(data.error); return; }
|
| 932 |
+
renderAiResult(data);
|
| 933 |
+
} catch (e) { renderAiError(e.message); }
|
| 934 |
+
}
|
| 935 |
+
|
| 936 |
+
function reliabilityClass(r) {
|
| 937 |
+
if (r === '매우 높음') return 'rel-very-high';
|
| 938 |
+
if (r === '높음') return 'rel-high';
|
| 939 |
+
if (r === '보통') return 'rel-medium';
|
| 940 |
+
return 'rel-low';
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
function renderAiResult(d) {
|
| 944 |
+
document.getElementById('aiContent').innerHTML = `
|
| 945 |
+
<div class="ai-header">
|
| 946 |
+
<span class="reliability-badge ${reliabilityClass(d.reliability)}">${d.reliability}</span>
|
| 947 |
+
<span style="color:var(--text-muted);font-size:0.85rem;">${d.reliability_reason || ''}</span>
|
| 948 |
+
</div>
|
| 949 |
+
<p class="ai-text">${d.summary || ''}</p>
|
| 950 |
+
<p class="ai-notes">${d.notes || ''}</p>
|
| 951 |
+
`;
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
function renderAiError(msg) {
|
| 955 |
+
document.getElementById('aiContent').innerHTML =
|
| 956 |
+
`<p style="color:var(--text-muted);font-size:0.875rem;">해석 불가: ${msg}</p>`;
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
// ── 초기화 ────────────────────────────────────────────
|
| 960 |
+
document.getElementById('resetBtn').addEventListener('click', () => {
|
| 961 |
+
fileInput.value = '';
|
| 962 |
+
fileName.textContent = '';
|
| 963 |
+
forecastData = null;
|
| 964 |
+
document.getElementById('predictBtn').disabled = true;
|
| 965 |
+
document.getElementById('exportCsvBtn').disabled = true;
|
| 966 |
+
document.getElementById('exportXlsxBtn').disabled = true;
|
| 967 |
+
document.getElementById('resultsSection').classList.remove('visible');
|
| 968 |
+
setStatus('', '');
|
| 969 |
+
if (chartInstance) { chartInstance.destroy(); chartInstance = null; }
|
| 970 |
+
});
|
| 971 |
+
|
| 972 |
+
// ── 다운로드 ─────────────────────────────────────────
|
| 973 |
+
document.getElementById('exportCsvBtn').addEventListener('click', () => {
|
| 974 |
+
if (!forecastData) return;
|
| 975 |
+
const rows = ['ds,y,yhat,yhat_lower,yhat_upper,is_future'];
|
| 976 |
+
forecastData.data.forEach(d => {
|
| 977 |
+
rows.push([d.ds, d.y ?? '', d.yhat ?? '', d.yhat_lower ?? '', d.yhat_upper ?? '', d.is_future].join(','));
|
| 978 |
+
});
|
| 979 |
+
download('timesfm_forecast.csv', rows.join('\n'), 'text/csv');
|
| 980 |
+
});
|
| 981 |
+
|
| 982 |
+
document.getElementById('exportXlsxBtn').addEventListener('click', () => {
|
| 983 |
+
if (!forecastData) return;
|
| 984 |
+
const ws_data = [['날짜', '실제값', '예측값', '신뢰구간 하한', '신뢰구간 상한', '미래여부']];
|
| 985 |
+
forecastData.data.forEach(d => ws_data.push([d.ds, d.y, d.yhat, d.yhat_lower, d.yhat_upper, d.is_future]));
|
| 986 |
+
const ws = XLSX.utils.aoa_to_sheet(ws_data);
|
| 987 |
+
const wb = XLSX.utils.book_new();
|
| 988 |
+
XLSX.utils.book_append_sheet(wb, ws, 'TimesFM');
|
| 989 |
+
XLSX.writeFile(wb, 'timesfm_forecast.xlsx');
|
| 990 |
+
});
|
| 991 |
+
|
| 992 |
+
function download(name, content, type) {
|
| 993 |
+
const a = Object.assign(document.createElement('a'), {
|
| 994 |
+
href: URL.createObjectURL(new Blob([content], { type })),
|
| 995 |
+
download: name,
|
| 996 |
+
});
|
| 997 |
+
a.click();
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
// ── 유틸 ─────────────────────────────────────────────
|
| 1001 |
+
function setStatus(type, msg) {
|
| 1002 |
+
const el = document.getElementById('statusMsg');
|
| 1003 |
+
el.className = 'status-msg';
|
| 1004 |
+
if (type) { el.classList.add(type); el.innerHTML = type === 'loading' ? `<span class="spinner"></span>${msg}` : msg; }
|
| 1005 |
+
}
|
| 1006 |
+
})();
|
| 1007 |
+
</script>
|
| 1008 |
+
</body>
|
| 1009 |
+
</html>
|
| 1010 |
+
```
|
| 1011 |
+
|
| 1012 |
+
- [ ] **Step 2: 브라우저 확인**
|
| 1013 |
+
|
| 1014 |
+
`http://localhost:8000/timesfm` 접속 → 헤더, 네비, 업로드 영역, 상태 뱃지 표시 확인.
|
| 1015 |
+
|
| 1016 |
+
- [ ] **Step 3: 커밋**
|
| 1017 |
+
|
| 1018 |
+
```bash
|
| 1019 |
+
git add static/timesfm.html
|
| 1020 |
+
git commit -m "feat: add timesfm.html UI with confidence intervals and AI interpretation"
|
| 1021 |
+
```
|
| 1022 |
+
|
| 1023 |
+
---
|
| 1024 |
+
|
| 1025 |
+
## Task 5: Dockerfile 수정
|
| 1026 |
+
|
| 1027 |
+
**Files:**
|
| 1028 |
+
- Modify: `Dockerfile`
|
| 1029 |
+
|
| 1030 |
+
- [ ] **Step 1: `useradd` 라인 전에 ENV + mkdir 삽입**
|
| 1031 |
+
|
| 1032 |
+
기존 (line 33-35):
|
| 1033 |
+
```dockerfile
|
| 1034 |
+
# 비루트 사용자 (Hugging Face 보안 정책)
|
| 1035 |
+
RUN useradd -m -u 1000 appuser && chown -R appuser /app
|
| 1036 |
+
USER appuser
|
| 1037 |
+
```
|
| 1038 |
+
|
| 1039 |
+
변경 후:
|
| 1040 |
+
```dockerfile
|
| 1041 |
+
# HuggingFace 모델 캐시 디렉터리 (appuser 접근 가능)
|
| 1042 |
+
ENV HF_HOME=/app/.cache/huggingface
|
| 1043 |
+
ENV TRANSFORMERS_CACHE=/app/.cache/huggingface
|
| 1044 |
+
RUN mkdir -p /app/.cache/huggingface
|
| 1045 |
+
|
| 1046 |
+
# 비루트 사용자 (Hugging Face 보안 정책)
|
| 1047 |
+
RUN useradd -m -u 1000 appuser && chown -R appuser /app
|
| 1048 |
+
USER appuser
|
| 1049 |
+
```
|
| 1050 |
+
|
| 1051 |
+
- [ ] **Step 2: 커밋**
|
| 1052 |
+
|
| 1053 |
+
```bash
|
| 1054 |
+
git add Dockerfile
|
| 1055 |
+
git commit -m "chore: add HF cache dir env vars to Dockerfile"
|
| 1056 |
+
```
|
| 1057 |
+
|
| 1058 |
+
---
|
| 1059 |
+
|
| 1060 |
+
## Task 6: 기존 HTML 파일 nav에 TimesFM 링크 추가
|
| 1061 |
+
|
| 1062 |
+
> **주의:** 스펙에서 "기존 `static/*.html` 파일 수정 금지"라고 명시되어 있으므로, 이 Task는 **건너뛴다**.
|
| 1063 |
+
> 기존 파일들은 수정하지 않는다. `/timesfm` 경로는 네비바에 새로 추가한 `timesfm.html`에서만 표시된다.
|
| 1064 |
+
|
| 1065 |
+
---
|
| 1066 |
+
|
| 1067 |
+
## Self-Review 체크리스트
|
| 1068 |
+
|
| 1069 |
+
- [x] **1-1** requirements.txt: timesfm[torch], huggingface_hub 추가 ✓
|
| 1070 |
+
- [x] **1-2** timesfm_service.py: load_model, get_status, predict_with_timesfm, get_freq_indicator, interpret_forecast ✓
|
| 1071 |
+
- [x] **1-3** main.py: lifespan 추가 + 4개 라우트 (/timesfm, /api/timesfm/status, /api/timesfm/predict, /api/timesfm/interpret) ✓
|
| 1072 |
+
- [x] **1-4** static/timesfm.html: 상태 뱃지, 파일 업로드, 옵션 패널, 차트(신뢰구간), 메트릭, AI 해석, 다운로드 ✓
|
| 1073 |
+
- [x] **1-5** Dockerfile: HF_HOME, TRANSFORMERS_CACHE, mkdir ✓
|
| 1074 |
+
- [x] **금지 사항**: 기존 *_service.py 미수정, 기존 static/*.html 미수정 ✓
|
timesfm-frontend/.env.example
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
# HF Space URL — 서버사이드 프록시 라우트(/api/hf/*)에서 사용 (CORS 회피)
|
| 2 |
-
HF_API_BASE=https://jkpaul-time-series-prediction.hf.space
|
| 3 |
-
ANTHROPIC_API_KEY=
|
|
|
|
|
|
|
|
|
|
|
|
timesfm-frontend/.gitignore
CHANGED
|
@@ -32,7 +32,7 @@ yarn-error.log*
|
|
| 32 |
|
| 33 |
# env files (can opt-in for committing if needed)
|
| 34 |
.env*
|
| 35 |
-
|
| 36 |
|
| 37 |
# vercel
|
| 38 |
.vercel
|
|
|
|
| 32 |
|
| 33 |
# env files (can opt-in for committing if needed)
|
| 34 |
.env*
|
| 35 |
+
.env.example
|
| 36 |
|
| 37 |
# vercel
|
| 38 |
.vercel
|