fbdeme commited on
Commit
d99471c
Β·
verified Β·
1 Parent(s): f8edb91

Deploy approved upload classification regression and forecasting

Browse files
Files changed (5) hide show
  1. Dockerfile +2 -2
  2. README.md +3 -1
  3. app.py +98 -19
  4. requirements.txt +1 -1
  5. upload_predict.py +186 -0
Dockerfile CHANGED
@@ -6,14 +6,14 @@ WORKDIR /app
6
  COPY --chown=user:user requirements.txt .
7
  RUN pip install --no-cache-dir -r requirements.txt
8
 
9
- COPY --chown=user:user app.py .
10
  USER user
11
  ENV HOME=/home/user \
12
  HF_HOME=/home/user/.cache/huggingface \
13
  PYTHONUNBUFFERED=1
14
 
15
  # 곡개 체크포인트λ₯Ό 이미지 λΉŒλ“œ λ•Œ λ°›μ•„ 첫 μš”μ²­μ˜ λ‹€μš΄λ‘œλ“œ 지연을 μ—†μ•€λ‹€.
16
- RUN python -c "from huggingface_hub import hf_hub_download; hf_hub_download(repo_id='jingang/TabICL', filename='tabicl-classifier-v2-20260212.ckpt')"
17
 
18
  EXPOSE 7860
19
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
6
  COPY --chown=user:user requirements.txt .
7
  RUN pip install --no-cache-dir -r requirements.txt
8
 
9
+ COPY --chown=user:user app.py upload_predict.py .
10
  USER user
11
  ENV HOME=/home/user \
12
  HF_HOME=/home/user/.cache/huggingface \
13
  PYTHONUNBUFFERED=1
14
 
15
  # 곡개 체크포인트λ₯Ό 이미지 λΉŒλ“œ λ•Œ λ°›μ•„ 첫 μš”μ²­μ˜ λ‹€μš΄λ‘œλ“œ 지연을 μ—†μ•€λ‹€.
16
+ RUN python -c "from huggingface_hub import hf_hub_download; hf_hub_download(repo_id='jingang/TabICL', filename='tabicl-classifier-v2-20260212.ckpt'); hf_hub_download(repo_id='jingang/TabICL', filename='tabicl-regressor-v2-20260212.ckpt')"
17
 
18
  EXPOSE 7860
19
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -8,4 +8,6 @@ app_port: 7860
8
  license: bsd-3-clause
9
  ---
10
 
11
- ONTIC 곡개 μ œν’ˆ λ―Έλ¦¬λ³΄κΈ°μ—μ„œ μ‚¬μš©ν•˜λŠ” ν•©μ„± 데이터 μ „μš© TabICL μΆ”λ‘  API.
 
 
 
8
  license: bsd-3-clause
9
  ---
10
 
11
+ ONTIC 곡개 μ œν’ˆ λ―Έλ¦¬λ³΄κΈ°μ—μ„œ μ‚¬μš©ν•˜λŠ” ν•©μ„± 데λͺ¨ 및 μ‚¬μš©μž μ—…λ‘œλ“œ ν‘œμ˜ λΆ„λ₯˜Β·νšŒκ·€Β·μ‹œκ³„μ—΄ 예츑 API.
12
+
13
+ `/predict-upload`λŠ” 읡λͺ… 숫자 ν–‰λ ¬κ³Ό μ •λ‹΅, μ‹œκ³„μ—΄ λ‚ μ§œλ₯Ό λ°›μ•„ μ‹€μ œ TabICL μΆ”λ‘ κ³Ό 보λ₯˜ 검증을 μ‹€ν–‰ν•œλ‹€. μž…λ ₯ 행은 μ €μž₯ν•˜μ§€ μ•ŠλŠ”λ‹€. λΆ„λ₯˜Β·νšŒκ·€λŠ” μ΅œλŒ€ 1,000개 κ³Όκ±° 사둀·30개 λ³€μˆ˜Β·100개 예츑 λŒ€μƒ, μ‹œκ³„μ—΄μ€ μ΅œλŒ€ 1,000개 κ΄€μΈ‘κ³Ό 24개 미래 κΈ°κ°„μœΌλ‘œ μ œν•œν•œλ‹€.
app.py CHANGED
@@ -1,16 +1,23 @@
1
- """ONTIC 정적 데λͺ¨μš© μ‹€μ œ TabICL μΆ”λ‘  API β€” ν•©μ„± μ˜ˆμ‹œ λ°μ΄ν„°λ§Œ μ·¨κΈ‰ν•œλ‹€."""
2
  from __future__ import annotations
3
 
4
  from copy import deepcopy
5
  from importlib.metadata import version
 
 
6
  from threading import Lock
7
  from time import perf_counter
 
 
8
 
9
  import numpy as np
10
- from fastapi import FastAPI, HTTPException
 
11
  from fastapi.middleware.cors import CORSMiddleware
12
- from pydantic import BaseModel, Field
 
13
  from tabicl import TabICLClassifier
 
14
 
15
  HF_REPO = "jingang/TabICL"
16
  CHECKPOINT = "tabicl-classifier-v2-20260212.ckpt"
@@ -20,13 +27,60 @@ ALLOWED_ORIGINS = [
20
  "http://localhost:8787",
21
  ]
22
 
23
- app = FastAPI(title="ONTIC TabICL Demo API", version="1.0.0")
 
24
  app.add_middleware(
25
  CORSMiddleware,
26
  allow_origins=ALLOWED_ORIGINS,
27
  allow_methods=["GET", "POST", "OPTIONS"],
28
  allow_headers=["Content-Type"],
29
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
 
32
  SCENARIOS = {
@@ -37,7 +91,8 @@ SCENARIOS = {
37
  "features": ["문의 증가", "ꡬ맀 간격", "μ£Όλ¬Έ κΈˆμ•‘ κ°μ†Œ", "λ°©λ¬Έ κ°μ†Œ"],
38
  "weights": [1.2, 1.0, 0.8, 0.55],
39
  "queries": [[0.5, 0.4, 0.3, 0.2], [0.35, 0.3, 0.2, 0.15], [0.25, 0.2, 0.15, 0.1], [0.15, 0.1, 0.1, 0.05], [0.05, 0.05, 0.0, 0.0]],
40
- "summary": "졜근 문의 증가와 ꡬ맀 간격 ν™•λŒ€κ°€ ν•¨κ»˜ λ‚˜νƒ€λ‚œ 고객을 μš°μ„  κ²€ν† ν•©λ‹ˆλ‹€.",
 
41
  },
42
  "stockout": {
43
  "seed": 84,
@@ -46,7 +101,8 @@ SCENARIOS = {
46
  "features": ["μˆ˜μš” 속도", "재고 μ†Œμ§„", "곡급 μ§€μ—°", "미처리 μ£Όλ¬Έ"],
47
  "weights": [1.1, 1.25, 0.85, 0.65],
48
  "queries": [[0.5, 0.45, 0.3, 0.2], [0.35, 0.3, 0.25, 0.15], [0.25, 0.2, 0.15, 0.1], [0.15, 0.1, 0.05, 0.05], [0.05, 0.0, 0.05, 0.0]],
49
- "summary": "μˆ˜μš” 속도와 μ˜ˆμƒ 재고 μ†Œμ§„ μ‹œμ μ„ ν•¨κ»˜ 보고 보좩 μš°μ„ μˆœμœ„λ₯Ό μ •ν•©λ‹ˆλ‹€.",
 
50
  },
51
  "defect": {
52
  "seed": 126,
@@ -55,17 +111,30 @@ SCENARIOS = {
55
  "features": ["μ˜¨λ„ 편차", "진동 증가", "비가동 μ‹œκ°„", "μž‘μ—… λ³€κ²½"],
56
  "weights": [1.25, 1.15, 0.7, 0.5],
57
  "queries": [[0.5, 0.4, 0.3, 0.2], [0.35, 0.3, 0.2, 0.15], [0.25, 0.2, 0.15, 0.1], [0.15, 0.1, 0.1, 0.05], [0.05, 0.05, 0.0, 0.0]],
58
- "summary": "곡정 μ˜¨λ„ νŽΈμ°¨μ™€ 진동 증가가 겹친 배치λ₯Ό μš°μ„  μ κ²€ν•©λ‹ˆλ‹€.",
 
59
  },
60
  }
 
61
 
62
  _lock = Lock()
63
  _cache: dict[str, dict] = {}
64
 
65
 
66
  class DemoRequest(BaseModel):
67
- question: str = Field(default=SCENARIOS["churn"]["question"], max_length=200)
68
- selected_features: list[int] = Field(default_factory=lambda: [0, 1, 2, 3])
 
 
 
 
 
 
 
 
 
 
 
69
 
70
 
71
  def model_info() -> dict:
@@ -81,23 +150,29 @@ def model_info() -> dict:
81
 
82
 
83
  def scenario_for(question: str) -> str:
84
- if "재고" in question or "ν’ˆλͺ©" in question:
85
- return "stockout"
86
- if "λΆˆλŸ‰" in question or "생산" in question or "배치" in question:
87
- return "defect"
88
- return "churn"
 
89
 
90
 
91
  def infer_scenario(key: str, selected_features: list[int]) -> tuple[dict, bool]:
92
- selected = tuple(sorted(set(selected_features)))
93
- if not selected or any(index < 0 or index > 3 for index in selected):
 
 
 
94
  raise HTTPException(status_code=422, detail="Select at least one valid feature index (0-3).")
 
95
  cache_key = f"{key}:{','.join(map(str, selected))}"
96
  with _lock:
97
  if cache_key in _cache:
98
  return deepcopy(_cache[cache_key]), True
99
 
100
  spec = SCENARIOS[key]
 
101
  rng = np.random.default_rng(spec["seed"])
102
  context = rng.normal(size=(96, 4))
103
  weights = np.asarray(spec["weights"], dtype=float)
@@ -128,7 +203,7 @@ def infer_scenario(key: str, selected_features: list[int]) -> tuple[dict, bool]:
128
  signals = sorted(
129
  [
130
  {"feature": feature, "value": round(float(value), 2)}
131
- for feature, value in zip((spec["features"][index] for index in selected), top_query)
132
  ],
133
  key=lambda item: abs(item["value"]),
134
  reverse=True,
@@ -136,12 +211,16 @@ def infer_scenario(key: str, selected_features: list[int]) -> tuple[dict, bool]:
136
  result = {
137
  "scenario": key,
138
  "question": spec["question"],
139
- "summary": spec["summary"],
 
 
 
140
  "data": "synthetic_demo",
141
  "model": model_info(),
142
  "predictions": predictions,
143
  "signals": signals,
144
  "selected_features": list(selected),
 
145
  "seconds": round(elapsed, 3),
146
  }
147
  _cache[cache_key] = result
@@ -151,7 +230,7 @@ def infer_scenario(key: str, selected_features: list[int]) -> tuple[dict, bool]:
151
  @app.get("/")
152
  @app.get("/health")
153
  def health() -> dict:
154
- return {"ok": True, "model": model_info(), "scenarios": list(SCENARIOS)}
155
 
156
 
157
  @app.post("/demo")
 
1
+ """ONTIC μ‹€μ œ TabICL μΆ”λ‘  API β€” ν•©μ„± 데λͺ¨μ™€ 승인된 μ‚¬μš©μž ν‘œλ₯Ό μ²˜λ¦¬ν•œλ‹€."""
2
  from __future__ import annotations
3
 
4
  from copy import deepcopy
5
  from importlib.metadata import version
6
+ import json
7
+ import logging
8
  from threading import Lock
9
  from time import perf_counter
10
+ from typing import Annotated
11
+ from uuid import UUID, uuid4
12
 
13
  import numpy as np
14
+ from fastapi import FastAPI, HTTPException, Request
15
+ from fastapi.exceptions import RequestValidationError
16
  from fastapi.middleware.cors import CORSMiddleware
17
+ from fastapi.responses import JSONResponse
18
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
19
  from tabicl import TabICLClassifier
20
+ from upload_predict import router as upload_router
21
 
22
  HF_REPO = "jingang/TabICL"
23
  CHECKPOINT = "tabicl-classifier-v2-20260212.ckpt"
 
27
  "http://localhost:8787",
28
  ]
29
 
30
+ app = FastAPI(title="ONTIC TabICL Demo API", version="2.0.0")
31
+ app.include_router(upload_router)
32
  app.add_middleware(
33
  CORSMiddleware,
34
  allow_origins=ALLOWED_ORIGINS,
35
  allow_methods=["GET", "POST", "OPTIONS"],
36
  allow_headers=["Content-Type"],
37
  )
38
+ logger = logging.getLogger("uvicorn.error")
39
+
40
+ @app.exception_handler(RequestValidationError)
41
+ async def input_error(request, exc):
42
+ if request.url.path != '/predict-upload':
43
+ from fastapi.exception_handlers import request_validation_exception_handler
44
+ return await request_validation_exception_handler(request, exc)
45
+ messages = [e['msg'].removeprefix('Value error, ') for e in exc.errors() if e['type'] == 'value_error']
46
+ detail = messages[0] if messages else 'μž…λ ₯ ν¬κΈ°Β·μžλ£Œν˜•Β·ν•„μˆ˜ ν•„λ“œλ₯Ό 확인해 μ£Όμ„Έμš”.'
47
+ return JSONResponse(status_code=422, content={'detail': detail})
48
+
49
+
50
+
51
+ @app.middleware("http")
52
+ async def log_request_status(request: Request, call_next):
53
+ try:
54
+ request_id = str(UUID(request.headers.get("x-request-id", "")))
55
+ except ValueError:
56
+ request_id = str(uuid4())
57
+ started = perf_counter()
58
+ try:
59
+ if request.url.path == '/predict-upload':
60
+ chunks = []
61
+ size = 0
62
+ async for chunk in request.stream():
63
+ size += len(chunk)
64
+ if size > 512000:
65
+ return JSONResponse(status_code=413, content={'detail': '예츑 μš”μ²­μ€ 500KB μ΄ν•˜λ‘œ 쀄여 μ£Όμ„Έμš”.'})
66
+ chunks.append(chunk)
67
+ request._body = b''.join(chunks)
68
+ response = await call_next(request)
69
+ except Exception:
70
+ response = JSONResponse(
71
+ status_code=500,
72
+ content={"detail": "μš”μ²­μ„ μ²˜λ¦¬ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”."},
73
+ )
74
+ status = response.status_code
75
+ response.headers["x-request-id"] = request_id
76
+ logger.info(json.dumps({
77
+ "request_id": request_id,
78
+ "stage": "hf_demo" if request.url.path == "/demo" else "hf_request",
79
+ "status": status,
80
+ "code": "ok" if status < 400 else "invalid_request" if status == 422 else "request_failed",
81
+ "elapsed_ms": round((perf_counter() - started) * 1000, 1),
82
+ }))
83
+ return response
84
 
85
 
86
  SCENARIOS = {
 
91
  "features": ["문의 증가", "ꡬ맀 간격", "μ£Όλ¬Έ κΈˆμ•‘ κ°μ†Œ", "λ°©λ¬Έ κ°μ†Œ"],
92
  "weights": [1.2, 1.0, 0.8, 0.55],
93
  "queries": [[0.5, 0.4, 0.3, 0.2], [0.35, 0.3, 0.2, 0.15], [0.25, 0.2, 0.15, 0.1], [0.15, 0.1, 0.1, 0.05], [0.05, 0.05, 0.0, 0.0]],
94
+ "entity": "고객",
95
+ "risk": "μ΄νƒˆ μœ„ν—˜",
96
  },
97
  "stockout": {
98
  "seed": 84,
 
101
  "features": ["μˆ˜μš” 속도", "재고 μ†Œμ§„", "곡급 μ§€μ—°", "미처리 μ£Όλ¬Έ"],
102
  "weights": [1.1, 1.25, 0.85, 0.65],
103
  "queries": [[0.5, 0.45, 0.3, 0.2], [0.35, 0.3, 0.25, 0.15], [0.25, 0.2, 0.15, 0.1], [0.15, 0.1, 0.05, 0.05], [0.05, 0.0, 0.05, 0.0]],
104
+ "entity": "ν’ˆλͺ©",
105
+ "risk": "재고 λΆ€μ‘± μœ„ν—˜",
106
  },
107
  "defect": {
108
  "seed": 126,
 
111
  "features": ["μ˜¨λ„ 편차", "진동 증가", "비가동 μ‹œκ°„", "μž‘μ—… λ³€κ²½"],
112
  "weights": [1.25, 1.15, 0.7, 0.5],
113
  "queries": [[0.5, 0.4, 0.3, 0.2], [0.35, 0.3, 0.2, 0.15], [0.25, 0.2, 0.15, 0.1], [0.15, 0.1, 0.1, 0.05], [0.05, 0.05, 0.0, 0.0]],
114
+ "entity": "생산 배치",
115
+ "risk": "λΆˆλŸ‰ λ°œμƒ μœ„ν—˜",
116
  },
117
  }
118
+ QUESTION_SCENARIOS = {spec["question"]: key for key, spec in SCENARIOS.items()}
119
 
120
  _lock = Lock()
121
  _cache: dict[str, dict] = {}
122
 
123
 
124
  class DemoRequest(BaseModel):
125
+ model_config = ConfigDict(extra="forbid", strict=True)
126
+
127
+ question: str = Field(min_length=1, max_length=200)
128
+ selected_features: list[Annotated[int, Field(strict=True, ge=0, le=3)]] = Field(
129
+ min_length=1, max_length=4
130
+ )
131
+
132
+ @field_validator("selected_features")
133
+ @classmethod
134
+ def unique_features(cls, selected: list[int]) -> list[int]:
135
+ if len(selected) != len(set(selected)):
136
+ raise ValueError("예츑 기쀀은 쀑볡 없이 선택해 μ£Όμ„Έμš”.")
137
+ return selected
138
 
139
 
140
  def model_info() -> dict:
 
150
 
151
 
152
  def scenario_for(question: str) -> str:
153
+ if question not in QUESTION_SCENARIOS:
154
+ raise HTTPException(
155
+ status_code=422,
156
+ detail="μ§€μ›ν•˜μ§€ μ•ŠλŠ” μ§ˆλ¬Έμž…λ‹ˆλ‹€. 곡개 데λͺ¨μ˜ μ΄νƒˆΒ·μž¬κ³ Β·λΆˆλŸ‰ μ˜ˆμ‹œ 질문만 μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.",
157
+ )
158
+ return QUESTION_SCENARIOS[question]
159
 
160
 
161
  def infer_scenario(key: str, selected_features: list[int]) -> tuple[dict, bool]:
162
+ if (
163
+ not selected_features
164
+ or any(type(index) is not int or index < 0 or index > 3 for index in selected_features)
165
+ or len(selected_features) != len(set(selected_features))
166
+ ):
167
  raise HTTPException(status_code=422, detail="Select at least one valid feature index (0-3).")
168
+ selected = tuple(sorted(selected_features))
169
  cache_key = f"{key}:{','.join(map(str, selected))}"
170
  with _lock:
171
  if cache_key in _cache:
172
  return deepcopy(_cache[cache_key]), True
173
 
174
  spec = SCENARIOS[key]
175
+ selected_feature_names = [spec["features"][index] for index in selected]
176
  rng = np.random.default_rng(spec["seed"])
177
  context = rng.normal(size=(96, 4))
178
  weights = np.asarray(spec["weights"], dtype=float)
 
203
  signals = sorted(
204
  [
205
  {"feature": feature, "value": round(float(value), 2)}
206
+ for feature, value in zip(selected_feature_names, top_query)
207
  ],
208
  key=lambda item: abs(item["value"]),
209
  reverse=True,
 
211
  result = {
212
  "scenario": key,
213
  "question": spec["question"],
214
+ "summary": (
215
+ f"ν•©μ„± {spec['entity']} λ°μ΄ν„°μ—μ„œ μ„ νƒν•œ {', '.join(selected_feature_names)} "
216
+ f"κΈ°μ€€μœΌλ‘œ {spec['risk']} μˆœμœ„λ₯Ό κ³„μ‚°ν–ˆμŠ΅λ‹ˆλ‹€."
217
+ ),
218
  "data": "synthetic_demo",
219
  "model": model_info(),
220
  "predictions": predictions,
221
  "signals": signals,
222
  "selected_features": list(selected),
223
+ "selected_feature_names": selected_feature_names,
224
  "seconds": round(elapsed, 3),
225
  }
226
  _cache[cache_key] = result
 
230
  @app.get("/")
231
  @app.get("/health")
232
  def health() -> dict:
233
+ return {"ok": True, "model": model_info(), "scenarios": list(SCENARIOS), "upload_modes": ["classification", "regression", "forecast"]}
234
 
235
 
236
  @app.post("/demo")
requirements.txt CHANGED
@@ -1,5 +1,5 @@
1
  fastapi==0.141.1
2
  numpy==2.5.3
3
- tabicl==2.2.0
4
  torch==2.14.0
5
  uvicorn==0.52.4
 
1
  fastapi==0.141.1
2
  numpy==2.5.3
3
+ tabicl[forecast]==2.2.0
4
  torch==2.14.0
5
  uvicorn==0.52.4
upload_predict.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded inference on user-approved anonymous tables; never logs or persists rows."""
2
+
3
+ from typing import Literal
4
+ from threading import Lock
5
+ import numpy as np
6
+ import pandas as pd
7
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
8
+ from fastapi import APIRouter, HTTPException
9
+ from sklearn.metrics import accuracy_score, mean_absolute_error
10
+ from sklearn.model_selection import train_test_split
11
+
12
+ router = APIRouter()
13
+ lock = Lock()
14
+ REGRESSION = "tabicl-regressor-v2-20260212.ckpt"
15
+ CLASSIFICATION = "tabicl-classifier-v2-20260212.ckpt"
16
+
17
+
18
+ class PredictionInput(BaseModel):
19
+ model_config = ConfigDict(extra="forbid")
20
+ mode: Literal["classification", "regression", "forecast"]
21
+ x: list[list[float | None]] = Field(default_factory=list, max_length=1000)
22
+ y: list[float] = Field(min_length=12, max_length=1000)
23
+ query: list[list[float | None]] = Field(default_factory=list, max_length=100)
24
+ times: list[str] = Field(default_factory=list, max_length=1000)
25
+ horizon: int = Field(default=3, ge=1, le=24)
26
+
27
+ @model_validator(mode="after")
28
+ def check(self):
29
+ if not np.isfinite(self.y).all():
30
+ raise ValueError("μ •λ‹΅ 값은 μœ ν•œν•œ μˆ«μžμ—¬μ•Ό ν•©λ‹ˆλ‹€.")
31
+ if self.mode == "forecast":
32
+ if len(self.times) != len(self.y):
33
+ raise ValueError("λ‚ μ§œμ™€ κ΄€μΈ‘κ°’ κ°œμˆ˜κ°€ λ‹€λ¦…λ‹ˆλ‹€.")
34
+ if len(self.y) < max(12, self.horizon * 3):
35
+ raise ValueError("예츑 κΈ°κ°„μ˜ 3λ°° 이상, μ΅œμ†Œ 12개 μ‹œμ μ΄ ν•„μš”ν•©λ‹ˆλ‹€.")
36
+ dates = pd.to_datetime(self.times, errors="coerce")
37
+ if (
38
+ dates.isna().any()
39
+ or dates.has_duplicates
40
+ or not dates.is_monotonic_increasing
41
+ ):
42
+ raise ValueError("λ‚ μ§œλŠ” 쀑볡 없이 μ‹œκ°„μˆœμ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€.")
43
+ if pd.infer_freq(dates) is None:
44
+ raise ValueError(
45
+ "일·월·연 λ‹¨μœ„ λ“± μΌμ •ν•œ κ°„κ²©μ˜ μ‹œκ³„μ—΄μ„ 선택해 μ£Όμ„Έμš”."
46
+ )
47
+ else:
48
+ if len(self.x) != len(self.y) or not self.query:
49
+ raise ValueError("ν•™μŠ΅ 데이터와 예츑 λŒ€μƒμ΄ ν•„μš”ν•©λ‹ˆλ‹€.")
50
+ width = len(self.x[0])
51
+ if not 1 <= width <= 30 or any(
52
+ len(row) != width for row in self.x + self.query
53
+ ):
54
+ raise ValueError("λ³€μˆ˜ κ°œμˆ˜λŠ” 1~30개이며 λͺ¨λ“  행이 κ°™μ•„μ•Ό ν•©λ‹ˆλ‹€.")
55
+ for row in self.x + self.query:
56
+ if any(v is not None and not np.isfinite(v) for v in row):
57
+ raise ValueError("μž…λ ₯값은 μœ ν•œν•œ μˆ«μžμ—¬μ•Ό ν•©λ‹ˆλ‹€.")
58
+ if any(all(row[i] is None for row in self.x) for i in range(width)):
59
+ raise ValueError("전체가 결츑인 λ³€μˆ˜λŠ” μ œμ™Έν•΄ μ£Όμ„Έμš”.")
60
+ if self.mode == "classification":
61
+ classes, counts = np.unique(self.y, return_counts=True)
62
+ if len(classes) < 2 or len(classes) > 10 or counts.min() < 3:
63
+ raise ValueError(
64
+ "λΆ„λ₯˜λŠ” 2~10μ’…μ˜ 결과와 μ’…λ₯˜λ³„ μ΅œμ†Œ 3개 사둀가 ν•„μš”ν•©λ‹ˆλ‹€."
65
+ )
66
+ return self
67
+
68
+
69
+ def infer_upload(data: PredictionInput):
70
+ from tabicl import TabICLClassifier, TabICLRegressor
71
+
72
+ config = dict(
73
+ device="cpu", n_estimators=2, random_state=42, allow_auto_download=False
74
+ )
75
+ y = np.asarray(data.y)
76
+ if data.mode == "forecast":
77
+ from tabicl import TabICLForecaster
78
+
79
+ frame = pd.DataFrame({"timestamp": pd.to_datetime(data.times), "target": y})
80
+ n = data.horizon
81
+ forecaster = TabICLForecaster(
82
+ tabicl_config={**config, "checkpoint_version": REGRESSION}
83
+ )
84
+ validation = forecaster.predict_df(
85
+ frame.iloc[:-n], prediction_length=n
86
+ ).reset_index()
87
+ mae = mean_absolute_error(y[-n:], validation["target"].to_numpy())
88
+ baseline = mean_absolute_error(y[-n:], np.repeat(y[-n - 1], n))
89
+ future = forecaster.predict_df(frame, prediction_length=n).reset_index()
90
+ predictions = [
91
+ {"time": str(row["timestamp"]), "value": float(row["target"])}
92
+ for _, row in future.iterrows()
93
+ ]
94
+ return dict(
95
+ mode=data.mode,
96
+ predictions=predictions,
97
+ validation={
98
+ "metric": "MAE",
99
+ "value": float(mae),
100
+ "baseline": float(baseline),
101
+ "rows": n,
102
+ "split": "λ§ˆμ§€λ§‰ κΈ°κ°„ 보λ₯˜ 검증",
103
+ },
104
+ checkpoint=REGRESSION,
105
+ context_rows=len(y),
106
+ )
107
+ x = np.asarray(data.x, dtype=float)
108
+ query = np.asarray(data.query, dtype=float)
109
+ indices = np.arange(len(y))
110
+ if data.mode == "classification":
111
+ train, test = train_test_split(
112
+ indices,
113
+ test_size=max(len(np.unique(y)), int(np.ceil(len(y) * 0.2))),
114
+ random_state=42,
115
+ stratify=y,
116
+ )
117
+ model = TabICLClassifier(**config, checkpoint_version=CLASSIFICATION)
118
+ else:
119
+ train, test = train_test_split(indices, test_size=0.2, random_state=42)
120
+ model = TabICLRegressor(**config, checkpoint_version=REGRESSION)
121
+ model.fit(x[train], y[train])
122
+ held = model.predict(x[test])
123
+ if data.mode == "classification":
124
+ values, counts = np.unique(y[train], return_counts=True)
125
+ baseline = float(np.mean(y[test] == values[counts.argmax()]))
126
+ validation = dict(
127
+ metric="accuracy",
128
+ value=float(accuracy_score(y[test], held)),
129
+ baseline=baseline,
130
+ )
131
+ else:
132
+ validation = dict(
133
+ metric="MAE",
134
+ value=float(mean_absolute_error(y[test], held)),
135
+ baseline=float(
136
+ mean_absolute_error(y[test], np.repeat(np.mean(y[train]), len(test)))
137
+ ),
138
+ )
139
+ validation.update(
140
+ rows=len(test), split="λ¬΄μž‘μœ„ 20% 보λ₯˜ 검증 (μ‹œκ³„μ—΄μ—λŠ” μ‚¬μš©ν•˜μ§€ μ•ŠμŒ)"
141
+ )
142
+ model.fit(x, y)
143
+ if data.mode == "classification":
144
+ probs = model.predict_proba(query)
145
+ predictions = [
146
+ dict(
147
+ value=float(model.classes_[row.argmax()]),
148
+ probabilities=[float(p) for p in row],
149
+ )
150
+ for row in probs
151
+ ]
152
+ classes = [float(c) for c in model.classes_]
153
+ else:
154
+ predictions = [dict(value=float(v)) for v in model.predict(query)]
155
+ classes = []
156
+ return dict(
157
+ mode=data.mode,
158
+ predictions=predictions,
159
+ classes=classes,
160
+ validation=validation,
161
+ checkpoint=CLASSIFICATION if data.mode == "classification" else REGRESSION,
162
+ context_rows=len(y),
163
+ )
164
+
165
+
166
+ @router.post("/predict-upload")
167
+ def predict_upload(data: PredictionInput):
168
+ if not lock.acquire(blocking=False):
169
+ raise HTTPException(
170
+ 429, "λͺ¨λΈμ΄ λ‹€λ₯Έ μš”μ²­μ„ μ²˜λ¦¬ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹€ν–‰ν•΄ μ£Όμ„Έμš”."
171
+ )
172
+ try:
173
+ result = infer_upload(data)
174
+ result["real_inference"] = True
175
+ return result
176
+ except (ValueError, ImportError) as exc:
177
+ # Return a fixed message; exception strings may contain user values.
178
+ raise HTTPException(
179
+ 422, "λͺ¨λΈ μž…λ ₯μ΄λ‚˜ μ‹œκ³„μ—΄ 간격을 확인해 μ£Όμ„Έμš”. μ§€μ›λ˜μ§€ μ•ŠλŠ” ν˜•μ‹μž…λ‹ˆλ‹€."
180
+ ) from exc
181
+ except Exception as exc:
182
+ raise HTTPException(
183
+ 503, "λͺ¨λΈμ„ μ€€λΉ„ν•˜κ±°λ‚˜ κ³„μ‚°ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹€ν–‰ν•΄ μ£Όμ„Έμš”."
184
+ ) from exc
185
+ finally:
186
+ lock.release()