713 lines
27 KiB
Python
713 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from datetime import date, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import torch
|
|
import torch.nn as nn
|
|
from fastapi import FastAPI, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
from sklearn.preprocessing import MinMaxScaler
|
|
from torch.utils.data import DataLoader, TensorDataset
|
|
from selenium import webdriver
|
|
from selenium.webdriver.chrome.options import Options
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DB_PATH = Path(os.getenv("NBEATS_DB_PATH", ROOT / "data deploy hasil.sqlite"))
|
|
MODEL_PATH = Path(os.getenv("NBEATS_MODEL_PATH", ROOT / "best_nbeats_model.pth"))
|
|
HYPERPARAMETERS_PATH = Path(
|
|
os.getenv("NBEATS_HYPERPARAMETERS_PATH", ROOT / "best_nbeats_hyperparameters.json")
|
|
)
|
|
TABLE_NAME = "data_deploy_hasil"
|
|
VALUE_COLUMNS = ("Harga Medium 1", "Harga Medium 2", "rata2")
|
|
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
MODEL_LOCK = threading.RLock()
|
|
UPDATE_LOCK = threading.Lock()
|
|
SCRAPING_URL = "https://www.bi.go.id/hargapangan/TabelHarga/PasarTradisionalDaerah"
|
|
SCRAPING_COMMODITIES = ("Beras Kualitas Medium I", "Beras Kualitas Medium II")
|
|
SCRAPING_PROVINCE = "Jawa Timur"
|
|
SCRAPING_REGENCY = "Kab. Banyuwangi"
|
|
FORECAST_HISTORY_DAYS = 90
|
|
|
|
|
|
def trend_basis(theta: torch.Tensor, size: int) -> torch.Tensor:
|
|
time = torch.linspace(0, 1, steps=size, device=theta.device)
|
|
basis = torch.stack([time**i for i in range(theta.shape[1])], dim=0)
|
|
return theta @ basis
|
|
|
|
|
|
def seasonality_basis(theta: torch.Tensor, size: int) -> torch.Tensor:
|
|
harmonics = theta.shape[1] // 2
|
|
time = torch.linspace(0, 2 * np.pi, steps=size, device=theta.device)
|
|
sine = torch.stack([torch.sin((i + 1) * time) for i in range(harmonics)], dim=0)
|
|
cosine = torch.stack([torch.cos((i + 1) * time) for i in range(harmonics)], dim=0)
|
|
return theta @ torch.cat([sine, cosine], dim=0)
|
|
|
|
|
|
class NBeatsBlock(nn.Module):
|
|
def __init__(self, input_size: int, output_size: int, hidden_size: int,
|
|
theta_size: int, basis_type: str) -> None:
|
|
super().__init__()
|
|
self.input_size = input_size
|
|
self.output_size = output_size
|
|
self.basis_type = basis_type
|
|
self.fc = nn.Sequential(
|
|
nn.Linear(input_size, hidden_size), nn.GELU(),
|
|
nn.Linear(hidden_size, hidden_size), nn.GELU(),
|
|
nn.Linear(hidden_size, hidden_size), nn.GELU(),
|
|
nn.Linear(hidden_size, hidden_size), nn.GELU(),
|
|
)
|
|
self.theta_back = nn.Linear(hidden_size, theta_size)
|
|
self.theta_fore = nn.Linear(hidden_size, theta_size)
|
|
|
|
def forward(self, values: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
hidden = self.fc(values)
|
|
theta_back = self.theta_back(hidden)
|
|
theta_fore = self.theta_fore(hidden)
|
|
if self.basis_type == "trend":
|
|
return trend_basis(theta_back, self.input_size), trend_basis(theta_fore, self.output_size)
|
|
return seasonality_basis(theta_back, self.input_size), seasonality_basis(theta_fore, self.output_size)
|
|
|
|
|
|
class NBeatsStack(nn.Module):
|
|
def __init__(self, input_size: int, output_size: int, hidden_size: int,
|
|
theta_size: int, blocks: int, basis_type: str) -> None:
|
|
super().__init__()
|
|
self.blocks = nn.ModuleList(
|
|
NBeatsBlock(input_size, output_size, hidden_size, theta_size, basis_type)
|
|
for _ in range(blocks)
|
|
)
|
|
|
|
def forward(self, values: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, list[torch.Tensor], list[torch.Tensor]]:
|
|
residual = values
|
|
forecast = torch.zeros((values.size(0), self.blocks[0].output_size), device=values.device)
|
|
backcasts, inputs = [], []
|
|
for block in self.blocks:
|
|
backcast, block_forecast = block(residual)
|
|
inputs.append(residual.detach())
|
|
backcasts.append(backcast)
|
|
residual = residual - backcast
|
|
forecast = forecast + block_forecast
|
|
return residual, forecast, backcasts, inputs
|
|
|
|
|
|
class NBeats(nn.Module):
|
|
def __init__(self, input_size: int, output_size: int, hidden_size: int,
|
|
n_stacks_trend: int, n_stacks_seasonal: int,
|
|
blocks_per_stack: int, theta_size_trend: int,
|
|
theta_size_season: int) -> None:
|
|
super().__init__()
|
|
self.output_size = output_size
|
|
self.trend_stacks = nn.ModuleList(
|
|
NBeatsStack(input_size, output_size, hidden_size, theta_size_trend,
|
|
blocks_per_stack, "trend") for _ in range(n_stacks_trend)
|
|
)
|
|
self.season_stacks = nn.ModuleList(
|
|
NBeatsStack(input_size, output_size, hidden_size, theta_size_season * 2,
|
|
blocks_per_stack, "seasonality") for _ in range(n_stacks_seasonal)
|
|
)
|
|
|
|
def forward(self, values: torch.Tensor) -> tuple[torch.Tensor, list[torch.Tensor], list[torch.Tensor]]:
|
|
residual = values
|
|
forecast = torch.zeros((values.size(0), self.output_size), device=values.device)
|
|
backcasts, inputs = [], []
|
|
for stack in [*self.trend_stacks, *self.season_stacks]:
|
|
residual, stack_forecast, stack_backcasts, stack_inputs = stack(residual)
|
|
forecast = forecast + stack_forecast
|
|
backcasts.extend(stack_backcasts)
|
|
inputs.extend(stack_inputs)
|
|
return forecast, backcasts, inputs
|
|
|
|
|
|
def load_hyperparameters() -> dict[str, Any]:
|
|
# Load tuned parameters when available; otherwise use the built-in defaults.
|
|
if HYPERPARAMETERS_PATH.exists():
|
|
return json.loads(HYPERPARAMETERS_PATH.read_text(encoding="utf-8"))
|
|
return {
|
|
"input_size": 7, "output_size": 1, "hidden_size": 512,
|
|
"n_stacks_trend": 3, "n_stacks_seasonal": 4,
|
|
"blocks_per_stack": 3, "theta_size_trend": 2, "theta_size_season": 4,
|
|
}
|
|
|
|
|
|
def build_model(hyperparameters: dict[str, Any]) -> NBeats:
|
|
# Construct the model architecture from the selected hyperparameters.
|
|
model = NBeats(
|
|
input_size=int(hyperparameters["input_size"]),
|
|
output_size=int(hyperparameters["output_size"]),
|
|
hidden_size=int(hyperparameters["hidden_size"]),
|
|
n_stacks_trend=int(hyperparameters["n_stacks_trend"]),
|
|
n_stacks_seasonal=int(hyperparameters["n_stacks_seasonal"]),
|
|
blocks_per_stack=int(hyperparameters["blocks_per_stack"]),
|
|
theta_size_trend=int(hyperparameters["theta_size_trend"]),
|
|
theta_size_season=int(hyperparameters["theta_size_season"]),
|
|
).to(DEVICE)
|
|
return model
|
|
|
|
|
|
def clean_data(data: pd.DataFrame, value_column: str) -> pd.DataFrame:
|
|
# Normalize dates and values before sorting and removing duplicate dates.
|
|
data["Tanggal"] = pd.to_datetime(data["Tanggal"], errors="coerce")
|
|
data[value_column] = pd.to_numeric(data[value_column], errors="coerce")
|
|
data = (
|
|
data
|
|
.dropna(subset=["Tanggal"])
|
|
.sort_values("Tanggal")
|
|
.drop_duplicates(subset=["Tanggal"], keep="last")
|
|
.reset_index(drop=True)
|
|
)
|
|
|
|
if data.empty:
|
|
return data
|
|
|
|
window = 3
|
|
previous_mean = (
|
|
data[value_column]
|
|
.shift(1)
|
|
.rolling(window=window, min_periods=1)
|
|
.mean()
|
|
)
|
|
next_mean = (
|
|
data[value_column]
|
|
.shift(-1)
|
|
.rolling(window=window, min_periods=1)
|
|
.mean()
|
|
)
|
|
data[value_column] = (
|
|
# Fill missing values using neighboring trends, then interpolation as fallback.
|
|
data[value_column]
|
|
.fillna((previous_mean + next_mean) / 2)
|
|
.fillna(previous_mean)
|
|
.fillna(next_mean)
|
|
.interpolate(method="linear")
|
|
.bfill()
|
|
.ffill()
|
|
)
|
|
|
|
q1 = data[value_column].quantile(0.25)
|
|
q3 = data[value_column].quantile(0.75)
|
|
iqr = q3 - q1
|
|
lower_bound = q1 - 1.5 * iqr
|
|
upper_bound = q3 + 1.5 * iqr
|
|
outlier_mask = (
|
|
(data[value_column] < lower_bound)
|
|
| (data[value_column] > upper_bound)
|
|
)
|
|
# Replace IQR outliers and interpolate them using surrounding observations.
|
|
data.loc[outlier_mask, value_column] = np.nan
|
|
data[value_column] = (
|
|
data[value_column]
|
|
.interpolate(method="linear")
|
|
.bfill()
|
|
.ffill()
|
|
)
|
|
return data
|
|
|
|
|
|
def read_data(value_column: str) -> pd.DataFrame:
|
|
# Read one selected series for forecasting or retraining.
|
|
if not DB_PATH.exists():
|
|
raise HTTPException(status_code=404, detail=f"Database tidak ditemukan: {DB_PATH}")
|
|
try:
|
|
with sqlite3.connect(DB_PATH) as connection:
|
|
data = pd.read_sql_query(
|
|
f'SELECT "Tanggal", "{value_column}" FROM "{TABLE_NAME}" ORDER BY "Tanggal"',
|
|
connection,
|
|
)
|
|
except (sqlite3.Error, pd.errors.DatabaseError) as error:
|
|
raise HTTPException(status_code=400, detail=f"Gagal membaca database: {error}") from error
|
|
data = clean_data(data, value_column)
|
|
if data.empty:
|
|
raise HTTPException(status_code=422, detail="Tidak ada data numerik yang dapat digunakan.")
|
|
if data[value_column].isna().any():
|
|
raise HTTPException(status_code=422, detail="Data masih memiliki nilai kosong setelah preprocessing.")
|
|
return data
|
|
|
|
|
|
def read_all_data() -> pd.DataFrame:
|
|
"""Read every column and row from SQLite without preprocessing."""
|
|
# Keep the raw database response unchanged for GET /data.
|
|
if not DB_PATH.exists():
|
|
raise HTTPException(status_code=404, detail=f"Database tidak ditemukan: {DB_PATH}")
|
|
try:
|
|
with sqlite3.connect(DB_PATH) as connection:
|
|
return pd.read_sql_query(
|
|
f'SELECT * FROM "{TABLE_NAME}"',
|
|
connection,
|
|
)
|
|
except (sqlite3.Error, pd.errors.DatabaseError) as error:
|
|
raise HTTPException(status_code=400, detail=f"Gagal membaca database: {error}") from error
|
|
|
|
|
|
def preprocess_all_data(data: pd.DataFrame) -> pd.DataFrame:
|
|
"""Clean dates and numeric columns, then add a sequential index."""
|
|
# Validate the complete schema before applying transformations.
|
|
required_columns = {"Tanggal", *VALUE_COLUMNS}
|
|
missing_columns = required_columns.difference(data.columns)
|
|
if missing_columns:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Kolom wajib tidak ditemukan: {', '.join(sorted(missing_columns))}",
|
|
)
|
|
|
|
data = data.copy()
|
|
data["Tanggal"] = pd.to_datetime(data["Tanggal"], errors="coerce")
|
|
for column in VALUE_COLUMNS:
|
|
data[column] = pd.to_numeric(data[column], errors="coerce")
|
|
data = (
|
|
# Keep the latest record for duplicate dates after chronological sorting.
|
|
data
|
|
.dropna(subset=["Tanggal"])
|
|
.sort_values("Tanggal")
|
|
.drop_duplicates(subset=["Tanggal"], keep="last")
|
|
.reset_index(drop=True)
|
|
)
|
|
if data.empty:
|
|
raise HTTPException(status_code=422, detail="Tidak ada data dengan tanggal valid.")
|
|
|
|
for column in VALUE_COLUMNS:
|
|
# Fill missing values first so IQR bounds are calculated from usable data.
|
|
data[column] = (
|
|
data[column]
|
|
.interpolate(method="linear", limit_direction="both")
|
|
.bfill()
|
|
.ffill()
|
|
)
|
|
q1 = data[column].quantile(0.25)
|
|
q3 = data[column].quantile(0.75)
|
|
iqr = q3 - q1
|
|
lower_bound = q1 - 1.5 * iqr
|
|
upper_bound = q3 + 1.5 * iqr
|
|
data.loc[
|
|
(data[column] < lower_bound) | (data[column] > upper_bound),
|
|
column,
|
|
] = np.nan
|
|
# Fill values identified as outliers and verify that no missing values remain.
|
|
data[column] = data[column].interpolate(
|
|
method="linear", limit_direction="both"
|
|
).bfill().ffill()
|
|
if data[column].isna().any():
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Kolom {column} tidak memiliki nilai valid untuk mengisi missing value.",
|
|
)
|
|
|
|
data["Tanggal"] = data["Tanggal"].dt.date.astype(str)
|
|
data.insert(0, "index", range(len(data)))
|
|
return data
|
|
|
|
|
|
def records_from_dataframe(data: pd.DataFrame) -> list[dict[str, Any]]:
|
|
"""Convert a dataframe to JSON-safe records, including null values."""
|
|
records = data.astype(object).where(pd.notna(data), None).to_dict(orient="records")
|
|
for record in records:
|
|
for key, value in record.items():
|
|
if isinstance(value, (np.integer, np.floating)):
|
|
record[key] = value.item()
|
|
return records
|
|
|
|
|
|
def _click_scraping_tree_item(driver: webdriver.Chrome, wait: WebDriverWait,
|
|
tree_id: str, item_name: str) -> None:
|
|
def find_checkbox(browser: webdriver.Chrome):
|
|
rows = browser.find_elements(By.CSS_SELECTOR, f"#{tree_id} tr.dx-data-row")
|
|
for row in rows:
|
|
if row.text.replace("\u2003", " ").strip() == item_name:
|
|
return row.find_element(By.CSS_SELECTOR, ".dx-select-checkbox")
|
|
return False
|
|
|
|
checkbox = wait.until(find_checkbox)
|
|
if checkbox.get_attribute("aria-checked") != "true":
|
|
driver.execute_script(
|
|
"arguments[0].scrollIntoView({block: 'center'});", checkbox
|
|
)
|
|
checkbox.click()
|
|
wait.until(lambda browser: checkbox.get_attribute("aria-checked") == "true")
|
|
|
|
|
|
def _set_scraping_date(driver: webdriver.Chrome, date_box_id: str, value: date) -> None:
|
|
driver.execute_script(
|
|
"window.jQuery(arguments[0]).dxDateBox('instance').option('value', arguments[1]);",
|
|
f"#{date_box_id}",
|
|
value.isoformat(),
|
|
)
|
|
|
|
|
|
def _read_scraped_prices(driver: webdriver.Chrome) -> pd.DataFrame:
|
|
header_row = next(
|
|
row for row in driver.find_elements(
|
|
By.CSS_SELECTOR, ".dx-datagrid-headers tr.dx-header-row"
|
|
)
|
|
if any(
|
|
"/" in (cell.get_attribute("aria-label") or "")
|
|
for cell in row.find_elements(By.CSS_SELECTOR, 'td[role="columnheader"]')
|
|
)
|
|
)
|
|
headers = [
|
|
(cell.get_attribute("aria-label") or "").replace("Column ", "").strip()
|
|
for cell in header_row.find_elements(By.CSS_SELECTOR, 'td[role="columnheader"]')
|
|
if (cell.get_attribute("aria-label") or "").startswith("Column ")
|
|
]
|
|
label_rows: list[list[str]] = []
|
|
value_rows: list[list[str]] = []
|
|
for table in driver.find_elements(By.CSS_SELECTOR, ".dx-datagrid-rowsview table"):
|
|
rows = table.find_elements(By.CSS_SELECTOR, "tbody tr.dx-data-row")
|
|
parsed_rows = [
|
|
[cell.text.replace("\u2003", " ").strip() for cell in row.find_elements(By.CSS_SELECTOR, "td")]
|
|
for row in rows
|
|
]
|
|
if any(
|
|
any(commodity in " ".join(row) for commodity in SCRAPING_COMMODITIES)
|
|
for row in parsed_rows
|
|
):
|
|
label_rows = parsed_rows
|
|
elif parsed_rows:
|
|
value_rows = parsed_rows
|
|
|
|
if not headers or not label_rows or not value_rows:
|
|
raise RuntimeError("Tabel harga belum menampilkan data.")
|
|
|
|
records: list[dict[str, str]] = []
|
|
for label_row, value_row in zip(label_rows, value_rows):
|
|
if len(label_row) < 2:
|
|
continue
|
|
commodity = label_row[1]
|
|
commodity = commodity.split(" ", 1)[1] if commodity[:1].isdigit() else commodity
|
|
if commodity not in SCRAPING_COMMODITIES:
|
|
continue
|
|
for header, value in zip(headers[2:], value_row[2:]):
|
|
if value and value != "-":
|
|
records.append({"komoditas": commodity, "tanggal": header, "harga": value})
|
|
return pd.DataFrame(records)
|
|
|
|
|
|
def scrape_missing_prices(start_date: date, end_date: date) -> pd.DataFrame:
|
|
options = Options()
|
|
options.add_argument("--headless=new")
|
|
options.add_argument("--window-size=1920,1080")
|
|
driver = webdriver.Chrome(options=options)
|
|
wait = WebDriverWait(driver, 30)
|
|
try:
|
|
driver.get(SCRAPING_URL)
|
|
wait.until(lambda browser: browser.find_element(By.TAG_NAME, "body"))
|
|
for commodity in SCRAPING_COMMODITIES:
|
|
_click_scraping_tree_item(driver, wait, "CommodityTree", commodity)
|
|
_click_scraping_tree_item(driver, wait, "cboProvince", SCRAPING_PROVINCE)
|
|
wait.until(
|
|
lambda browser: browser.find_elements(
|
|
By.CSS_SELECTOR, "#cboRegency tr.dx-data-row"
|
|
)
|
|
)
|
|
_click_scraping_tree_item(driver, wait, "cboRegency", SCRAPING_REGENCY)
|
|
_set_scraping_date(driver, "dboDateMulai", start_date)
|
|
_set_scraping_date(driver, "dboDateSelesai", end_date)
|
|
driver.find_element(By.ID, "btnReport").click()
|
|
wait.until(
|
|
lambda browser: browser.find_elements(
|
|
By.CSS_SELECTOR,
|
|
".dx-datagrid-rowsview table tbody tr.dx-data-row",
|
|
)
|
|
)
|
|
time.sleep(1)
|
|
return _read_scraped_prices(driver)
|
|
finally:
|
|
driver.quit()
|
|
|
|
|
|
def merge_scraped_prices(existing: pd.DataFrame, scraped: pd.DataFrame) -> pd.DataFrame:
|
|
expected_columns = ["Tanggal", "Harga Medium 1", "Harga Medium 2", "rata2"]
|
|
current = existing[expected_columns].copy()
|
|
current["Tanggal"] = pd.to_datetime(current["Tanggal"], errors="coerce")
|
|
current = current.dropna(subset=["Tanggal"]).set_index("Tanggal")
|
|
if not scraped.empty:
|
|
incoming = scraped.copy()
|
|
incoming["Tanggal"] = pd.to_datetime(
|
|
incoming["tanggal"].astype(str).str.replace(" ", "", regex=False),
|
|
format="%d/%m/%Y",
|
|
errors="coerce",
|
|
)
|
|
incoming["harga"] = pd.to_numeric(
|
|
incoming["harga"].astype(str)
|
|
.str.replace(",", "", regex=False)
|
|
.str.replace("Rp", "", regex=False)
|
|
.str.strip(),
|
|
errors="coerce",
|
|
)
|
|
incoming = incoming.dropna(subset=["Tanggal", "harga"])
|
|
new_values = incoming.pivot_table(
|
|
index="Tanggal",
|
|
columns="komoditas",
|
|
values="harga",
|
|
aggfunc="last",
|
|
).rename(columns={
|
|
"Beras Kualitas Medium I": "Harga Medium 1",
|
|
"Beras Kualitas Medium II": "Harga Medium 2",
|
|
})
|
|
new_values = new_values.reindex(columns=["Harga Medium 1", "Harga Medium 2"])
|
|
current = pd.concat([current.drop(index=new_values.index, errors="ignore"), new_values])
|
|
|
|
if current.empty:
|
|
raise HTTPException(status_code=422, detail="Tidak ada data tanggal yang valid.")
|
|
current = current.sort_index()
|
|
full_dates = pd.date_range(current.index.min(), current.index.max(), freq="D")
|
|
current = current.reindex(full_dates)
|
|
current.index.name = "Tanggal"
|
|
price_columns = ["Harga Medium 1", "Harga Medium 2"]
|
|
current[price_columns] = current[price_columns].interpolate(
|
|
method="linear", limit_direction="both"
|
|
)
|
|
current["rata2"] = current[price_columns].mean(axis=1)
|
|
return current.reset_index()[expected_columns]
|
|
|
|
|
|
def update_database_from_scraping() -> dict[str, Any]:
|
|
existing = read_all_data()
|
|
if existing.empty:
|
|
raise HTTPException(status_code=422, detail="Database tidak memiliki data awal.")
|
|
existing["Tanggal"] = pd.to_datetime(existing["Tanggal"], errors="coerce")
|
|
existing = existing.dropna(subset=["Tanggal"])
|
|
last_date = existing["Tanggal"].max().date()
|
|
today = date.today()
|
|
allowed_date = today if datetime.now().hour >= 13 else today - timedelta(days=1)
|
|
if last_date >= allowed_date:
|
|
return {
|
|
"status": "up_to_date",
|
|
"message": "Data sudah diperbarui sampai batas waktu yang diizinkan.",
|
|
"last_date": last_date.isoformat(),
|
|
"allowed_date": allowed_date.isoformat(),
|
|
"updated_rows": 0,
|
|
}
|
|
|
|
start_date = last_date + timedelta(days=1)
|
|
scraped = scrape_missing_prices(start_date, allowed_date)
|
|
if scraped.empty:
|
|
raise HTTPException(status_code=502, detail="Situs sumber tidak mengembalikan data.")
|
|
merged = merge_scraped_prices(existing, scraped)
|
|
with sqlite3.connect(DB_PATH) as connection:
|
|
merged.to_sql(TABLE_NAME, connection, if_exists="replace", index=False)
|
|
return {
|
|
"status": "updated",
|
|
"message": "Data berhasil diperbarui dari situs sumber.",
|
|
"start_date": start_date.isoformat(),
|
|
"last_date": last_date.isoformat(),
|
|
"allowed_date": allowed_date.isoformat(),
|
|
"updated_rows": len(scraped),
|
|
"total_rows": len(merged),
|
|
}
|
|
|
|
|
|
def dataset(values: np.ndarray, input_size: int) -> tuple[np.ndarray, np.ndarray]:
|
|
# Convert the time series into sliding windows and next-step targets.
|
|
if len(values) <= input_size:
|
|
raise ValueError(f"Minimal {input_size + 1} data diperlukan.")
|
|
inputs = np.array([values[i:i + input_size] for i in range(len(values) - input_size)])
|
|
targets = np.array([values[i + input_size] for i in range(len(values) - input_size)])[:, None]
|
|
return inputs, targets
|
|
|
|
|
|
class ForecastRequest(BaseModel):
|
|
horizon: int = Field(default=7, ge=1, le=90)
|
|
value_column: Literal["rata2", "Harga Medium 1", "Harga Medium 2"] = "rata2"
|
|
|
|
|
|
class RetrainRequest(BaseModel):
|
|
train_ratio: float = Field(default=0.8, gt=0.5, lt=1.0)
|
|
epochs: int = Field(default=20, ge=1, le=1000)
|
|
batch_size: int = Field(default=32, ge=1, le=1024)
|
|
learning_rate: float = Field(default=0.0001, gt=0, le=1)
|
|
value_column: Literal["rata2", "Harga Medium 1", "Harga Medium 2"] = "rata2"
|
|
|
|
|
|
app = FastAPI(title="N-BEATS Forecast API", version="1.0.0")
|
|
HYPERPARAMETERS = load_hyperparameters()
|
|
MODEL = build_model(HYPERPARAMETERS)
|
|
CHECKPOINT: dict[str, Any] | None = None
|
|
|
|
|
|
def load_saved_model() -> None:
|
|
global CHECKPOINT
|
|
if not MODEL_PATH.exists():
|
|
return
|
|
# Restore model weights and preprocessing scaler from the saved checkpoint.
|
|
checkpoint = torch.load(MODEL_PATH, map_location=DEVICE, weights_only=False)
|
|
MODEL.load_state_dict(checkpoint["model_state_dict"])
|
|
MODEL.eval()
|
|
CHECKPOINT = checkpoint
|
|
|
|
|
|
try:
|
|
load_saved_model()
|
|
except Exception as error:
|
|
raise RuntimeError(f"Checkpoint tidak kompatibel dengan arsitektur model: {error}") from error
|
|
|
|
|
|
def scaler_from_checkpoint(data: pd.DataFrame, value_column: str) -> MinMaxScaler:
|
|
# Reuse the training scaler when it matches the requested value column.
|
|
scaler = MinMaxScaler()
|
|
saved = CHECKPOINT.get("scaler") if CHECKPOINT else None
|
|
if saved and saved.get("value_column") == value_column:
|
|
scaler.min_ = np.asarray(saved["min_"], dtype=float)
|
|
scaler.scale_ = np.asarray(saved["scale_"], dtype=float)
|
|
scaler.data_min_ = np.asarray(saved["data_min_"], dtype=float)
|
|
scaler.data_max_ = np.asarray(saved["data_max_"], dtype=float)
|
|
scaler.data_range_ = np.asarray(saved["data_range_"], dtype=float)
|
|
scaler.n_features_in_ = 1
|
|
scaler.n_samples_seen_ = int(saved.get("n_samples_seen_", len(data)))
|
|
return scaler
|
|
scaler.fit(data[[value_column]].to_numpy())
|
|
return scaler
|
|
|
|
|
|
# Endpoint GET /data: mengambil seluruh data mentah dari SQLite.
|
|
@app.get("/data")
|
|
def all_data() -> dict[str, Any]:
|
|
"""Return all rows and columns exactly as stored in SQLite."""
|
|
# Serialize the raw dataframe into JSON-safe records for the website.
|
|
data = read_all_data()
|
|
return {"total_rows": len(data), "columns": list(data.columns), "rows": records_from_dataframe(data)}
|
|
|
|
|
|
# Endpoint GET /data/preprocessed: mengambil seluruh data setelah preprocessing.
|
|
@app.get("/data/preprocessed")
|
|
def preprocess_data() -> dict[str, Any]:
|
|
"""Return the complete dataset after date, missing-value, and outlier handling."""
|
|
# Apply the complete cleaning pipeline before returning website data.
|
|
data = preprocess_all_data(read_all_data())
|
|
return {
|
|
"total_rows": len(data),
|
|
"columns": list(data.columns),
|
|
"rows": records_from_dataframe(data),
|
|
}
|
|
|
|
|
|
# Endpoint POST /data/update: mengambil data terbaru dan memperbarui SQLite.
|
|
@app.post("/data/update")
|
|
def update_data() -> dict[str, Any]:
|
|
# Serialize updates so concurrent requests cannot overwrite each other.
|
|
if not DB_PATH.exists():
|
|
raise HTTPException(status_code=404, detail=f"Database tidak ditemukan: {DB_PATH}")
|
|
if not UPDATE_LOCK.acquire(blocking=False):
|
|
raise HTTPException(status_code=409, detail="Proses update data sedang berjalan.")
|
|
try:
|
|
try:
|
|
return update_database_from_scraping()
|
|
except HTTPException:
|
|
raise
|
|
except Exception as error:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"Gagal mengambil atau menyimpan data scraping: {error}",
|
|
) from error
|
|
finally:
|
|
UPDATE_LOCK.release()
|
|
|
|
|
|
# Endpoint POST /forecast: menghasilkan prediksi harga menggunakan model N-BEATS.
|
|
@app.post("/forecast")
|
|
def forecast(request: ForecastRequest) -> dict[str, Any]:
|
|
# Normalize the latest 90 calendar days, predict one step repeatedly, and inverse-transform results.
|
|
data = read_data(request.value_column)
|
|
last_available_date = data["Tanggal"].iloc[-1]
|
|
first_forecast_date = last_available_date - timedelta(days=FORECAST_HISTORY_DAYS - 1)
|
|
data = data[data["Tanggal"] >= first_forecast_date].reset_index(drop=True)
|
|
input_size = int(HYPERPARAMETERS["input_size"])
|
|
if len(data) < input_size:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=(
|
|
f"Minimal {input_size} data valid dari {FORECAST_HISTORY_DAYS} hari terakhir "
|
|
"diperlukan."
|
|
),
|
|
)
|
|
scaler = scaler_from_checkpoint(data, request.value_column)
|
|
history = scaler.transform(data[[request.value_column]].to_numpy()).ravel().tolist()
|
|
predictions: list[float] = []
|
|
with MODEL_LOCK, torch.no_grad():
|
|
MODEL.eval()
|
|
for _ in range(request.horizon):
|
|
# Use the latest input window so each prediction feeds the next one.
|
|
values = torch.tensor([history[-input_size:]], dtype=torch.float32, device=DEVICE)
|
|
predicted = float(MODEL(values)[0][0, 0].cpu())
|
|
history.append(predicted)
|
|
predictions.append(float(scaler.inverse_transform([[predicted]])[0, 0]))
|
|
last_date = data["Tanggal"].iloc[-1].date()
|
|
return {"value_column": request.value_column, "horizon": request.horizon,
|
|
"forecast": [{"date": (last_date + timedelta(days=i)).isoformat(), "value": value}
|
|
for i, value in enumerate(predictions, start=1)]}
|
|
|
|
|
|
# Endpoint POST /retrain: melatih ulang model dan menyimpan checkpoint terbaru.
|
|
@app.post("/retrain")
|
|
def retrain(request: RetrainRequest) -> dict[str, Any]:
|
|
# Prepare sliding-window training and validation data from the selected series.
|
|
data = read_data(request.value_column)
|
|
input_size = int(HYPERPARAMETERS["input_size"])
|
|
scaler = MinMaxScaler().fit(data[[request.value_column]].iloc[: int(len(data) * request.train_ratio)])
|
|
scaled = scaler.transform(data[[request.value_column]]).ravel()
|
|
inputs, targets = dataset(scaled, input_size)
|
|
split_index = max(1, int(len(inputs) * request.train_ratio))
|
|
if split_index >= len(inputs):
|
|
raise HTTPException(status_code=422, detail="Split menghasilkan data validasi kosong.")
|
|
train_inputs, val_inputs = inputs[:split_index], inputs[split_index:]
|
|
train_targets, val_targets = targets[:split_index], targets[split_index:]
|
|
train_loader = DataLoader(TensorDataset(torch.tensor(train_inputs, dtype=torch.float32),
|
|
torch.tensor(train_targets, dtype=torch.float32)),
|
|
batch_size=request.batch_size, shuffle=False)
|
|
model = build_model(HYPERPARAMETERS)
|
|
optimizer = torch.optim.AdamW(model.parameters(), lr=request.learning_rate, weight_decay=1e-4)
|
|
loss_fn = nn.MSELoss()
|
|
history: list[dict[str, float]] = []
|
|
for epoch in range(request.epochs):
|
|
# Optimize model weights on training batches, then measure validation loss.
|
|
model.train()
|
|
train_loss = 0.0
|
|
for batch_inputs, batch_targets in train_loader:
|
|
batch_inputs, batch_targets = batch_inputs.to(DEVICE), batch_targets.to(DEVICE)
|
|
prediction, backcasts, block_inputs = model(batch_inputs)
|
|
loss = loss_fn(prediction, batch_targets)
|
|
loss = loss + float(HYPERPARAMETERS.get("alpha", 0.1)) * sum(
|
|
loss_fn(backcast, block_input) for backcast, block_input in zip(backcasts, block_inputs)
|
|
) / len(backcasts)
|
|
optimizer.zero_grad()
|
|
loss.backward()
|
|
optimizer.step()
|
|
train_loss += loss.item() * len(batch_inputs)
|
|
model.eval()
|
|
with torch.no_grad():
|
|
validation_prediction = model(torch.tensor(val_inputs, dtype=torch.float32, device=DEVICE))[0]
|
|
validation_loss = loss_fn(validation_prediction, torch.tensor(val_targets, dtype=torch.float32, device=DEVICE)).item()
|
|
history.append({"epoch": epoch + 1, "train_loss": train_loss / len(train_inputs), "validation_loss": validation_loss})
|
|
checkpoint = {"model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(),
|
|
"epoch": request.epochs, "best_val_loss": history[-1]["validation_loss"],
|
|
"hyperparameters": HYPERPARAMETERS, "scaler": {"value_column": request.value_column,
|
|
**{key: getattr(scaler, key).tolist() for key in ("min_", "scale_", "data_min_", "data_max_", "data_range_")},
|
|
"n_samples_seen_": int(scaler.n_samples_seen_)}}
|
|
temporary_path = MODEL_PATH.with_suffix(".tmp")
|
|
with MODEL_LOCK:
|
|
# Atomically replace the checkpoint and activate the newly trained model.
|
|
torch.save(checkpoint, temporary_path)
|
|
temporary_path.replace(MODEL_PATH)
|
|
MODEL.load_state_dict(model.state_dict())
|
|
MODEL.eval()
|
|
globals()["CHECKPOINT"] = checkpoint
|
|
return {"message": "Model berhasil diretrain.", "train_rows": len(train_inputs),
|
|
"validation_rows": len(val_inputs), "train_ratio": request.train_ratio,
|
|
"epochs": request.epochs, "final_validation_loss": history[-1]["validation_loss"]}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
uvicorn.run(app, host="0.0.0.0", port=8000, reload=False)
|