Spaces:
Running
Running
Deploy approved upload classification regression and forecasting
Browse files- Dockerfile +2 -2
- README.md +3 -1
- app.py +98 -19
- requirements.txt +1 -1
- 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 κ³΅κ° μ ν 미리보기μμ μ¬μ©νλ ν©μ± λ°
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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="
|
|
|
|
| 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 |
-
"
|
|
|
|
| 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 |
-
"
|
|
|
|
| 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 |
-
"
|
|
|
|
| 59 |
},
|
| 60 |
}
|
|
|
|
| 61 |
|
| 62 |
_lock = Lock()
|
| 63 |
_cache: dict[str, dict] = {}
|
| 64 |
|
| 65 |
|
| 66 |
class DemoRequest(BaseModel):
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
def infer_scenario(key: str, selected_features: list[int]) -> tuple[dict, bool]:
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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":
|
|
|
|
|
|
|
|
|
|
| 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()
|