Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| Clockwork - Zero-Shot Temporal Foundation Model | |
| A 2.5M parameter model that forecasts any time series without retraining. | |
| Upload a CSV, pick columns, get predictions. No training required. | |
| """ | |
| import os | |
| import glob | |
| import math | |
| import re | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| import gradio as gr | |
| import plotly.graph_objects as go | |
| from plotly.subplots import make_subplots | |
| import warnings | |
| warnings.filterwarnings("ignore") | |
| # =================================================================== | |
| # CONFIG | |
| # =================================================================== | |
| class Config: | |
| def __init__(self): | |
| self.input_length = 512 | |
| self.output_length = 96 | |
| self.max_output_length = 720 | |
| self.patch_size = 16 | |
| self.d_model = 160 | |
| self.n_heads = 8 | |
| self.n_layers = 10 | |
| self.d_ff = 224 | |
| self.dropout = 0.1 | |
| self.device = "cuda" if torch.cuda.is_available() else "cpu" | |
| # =================================================================== | |
| # MODEL (identical to training script) | |
| # =================================================================== | |
| class RevIN(nn.Module): | |
| def __init__(self, num_features, eps=1e-5, affine=False): | |
| super().__init__() | |
| self.eps = eps | |
| self.affine = affine | |
| if affine: | |
| self.affine_weight = nn.Parameter(torch.ones(num_features)) | |
| self.affine_bias = nn.Parameter(torch.zeros(num_features)) | |
| def forward(self, x, mode="norm"): | |
| if mode == "norm": | |
| dim = tuple(range(1, x.ndim - 1)) | |
| self.mean = x.mean(dim=dim, keepdim=True).detach() | |
| self.stdev = torch.sqrt(torch.var(x, dim=dim, keepdim=True, unbiased=False) + self.eps).detach() | |
| x = (x - self.mean) / self.stdev | |
| if self.affine: | |
| x = x * self.affine_weight + self.affine_bias | |
| return x | |
| else: | |
| if self.affine: | |
| x = (x - self.affine_bias) / (self.affine_weight + self.eps) | |
| return x * self.stdev + self.mean | |
| class RMSNorm(nn.Module): | |
| def __init__(self, dim, eps=1e-6): | |
| super().__init__() | |
| self.eps = eps | |
| self.weight = nn.Parameter(torch.ones(dim)) | |
| def forward(self, x): | |
| rms = x.norm(2, dim=-1, keepdim=True) * (x.size(-1) ** -0.5) | |
| return self.weight * x / (rms + self.eps) | |
| class RoPE(nn.Module): | |
| def __init__(self, dim, max_len=2048, base=10000.0): | |
| super().__init__() | |
| inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim)) | |
| self.register_buffer("inv_freq", inv_freq) | |
| t = torch.arange(max_len, dtype=torch.float32) | |
| freqs = torch.einsum("i,j->ij", t, inv_freq) | |
| emb = torch.cat((freqs, freqs), dim=-1) | |
| self.register_buffer("cos_cached", emb.cos()[None, None, :, :]) | |
| self.register_buffer("sin_cached", emb.sin()[None, None, :, :]) | |
| def rotate_half(x): | |
| x1, x2 = x[..., : x.shape[-1] // 2], x[..., x.shape[-1] // 2 :] | |
| return torch.cat((-x2, x1), dim=-1) | |
| def forward(self, q, k, seq_len): | |
| cos = self.cos_cached[:, :, :seq_len, :] | |
| sin = self.sin_cached[:, :, :seq_len, :] | |
| return (q * cos) + (self.rotate_half(q) * sin), (k * cos) + (self.rotate_half(k) * sin) | |
| class MHA(nn.Module): | |
| def __init__(self, d_model, n_heads, dropout, max_len=2048): | |
| super().__init__() | |
| assert d_model % n_heads == 0 | |
| self.n_heads = n_heads | |
| self.d_head = d_model // n_heads | |
| self.scale = self.d_head ** -0.5 | |
| self.qkv = nn.Linear(d_model, 3 * d_model, bias=False) | |
| self.proj = nn.Linear(d_model, d_model, bias=False) | |
| self.dropout = nn.Dropout(dropout) | |
| self.rope = RoPE(self.d_head, max_len) | |
| def forward(self, x, mask=None): | |
| B, N, _ = x.shape | |
| qkv = self.qkv(x).reshape(B, N, 3, self.n_heads, self.d_head).permute(2, 0, 3, 1, 4) | |
| q, k, v = qkv[0], qkv[1], qkv[2] | |
| q, k = self.rope(q, k, N) | |
| attn = (q @ k.transpose(-2, -1)) * self.scale | |
| if mask is not None: | |
| attn = attn.masked_fill(mask == 0, float("-inf")) | |
| attn = F.softmax(attn, dim=-1, dtype=torch.float32) | |
| attn = self.dropout(attn).to(q.dtype) | |
| out = (attn @ v).transpose(1, 2).reshape(B, N, -1) | |
| return self.dropout(self.proj(out)) | |
| class SwiGLU(nn.Module): | |
| def __init__(self, dim, d_ff, dropout=0.0): | |
| super().__init__() | |
| hidden = int((2 * d_ff) / 3) | |
| self.w1 = nn.Linear(dim, hidden, bias=False) | |
| self.w2 = nn.Linear(hidden, dim, bias=False) | |
| self.w3 = nn.Linear(dim, hidden, bias=False) | |
| self.dropout = nn.Dropout(dropout) | |
| def forward(self, x): | |
| return self.dropout(self.w2(F.silu(self.w1(x)) * self.w3(x))) | |
| class GatedResidual(nn.Module): | |
| def __init__(self, dim): | |
| super().__init__() | |
| self.gate = nn.Linear(dim, dim, bias=False) | |
| def forward(self, x, res): | |
| g = torch.sigmoid(self.gate(x)) | |
| return g * x + (1 - g) * res | |
| class LayerScale(nn.Module): | |
| def __init__(self, dim, init_value=1e-5): | |
| super().__init__() | |
| self.gamma = nn.Parameter(init_value * torch.ones(dim)) | |
| def forward(self, x): | |
| return self.gamma * x | |
| class EncoderLayer(nn.Module): | |
| def __init__(self, d_model, n_heads, d_ff, dropout, max_len=2048): | |
| super().__init__() | |
| self.norm1 = RMSNorm(d_model) | |
| self.attn = MHA(d_model, n_heads, dropout, max_len) | |
| self.norm2 = RMSNorm(d_model) | |
| self.ff = SwiGLU(d_model, d_ff, dropout) | |
| self.gate1 = GatedResidual(d_model) | |
| self.gate2 = GatedResidual(d_model) | |
| self.ls1 = LayerScale(d_model) | |
| self.ls2 = LayerScale(d_model) | |
| def forward(self, x): | |
| x = self.gate1(x, self.ls1(self.attn(self.norm1(x)))) | |
| x = self.gate2(x, self.ls2(self.ff(self.norm2(x)))) | |
| return x | |
| class PatchEmbed(nn.Module): | |
| def __init__(self, patch_size, d_model, dropout): | |
| super().__init__() | |
| self.patch_size = patch_size | |
| self.proj = nn.Linear(patch_size, d_model, bias=False) | |
| self.norm = RMSNorm(d_model) | |
| self.drop = nn.Dropout(dropout) | |
| def forward(self, x): | |
| B, C, L = x.shape | |
| if L % self.patch_size != 0: | |
| pad = self.patch_size - (L % self.patch_size) | |
| x = F.pad(x, (0, pad), value=0.0) | |
| L = L + pad | |
| n_p = L // self.patch_size | |
| x = x.reshape(B, C, n_p, self.patch_size) | |
| x = self.proj(x) | |
| x = self.drop(self.norm(x)) | |
| return x.reshape(B * C, n_p, -1), n_p | |
| class AttentionPool(nn.Module): | |
| def __init__(self, dim): | |
| super().__init__() | |
| self.query = nn.Linear(dim, 1) | |
| def forward(self, x): | |
| scores = self.query(x).squeeze(-1) | |
| weights = F.softmax(scores, dim=-1) | |
| return torch.bmm(weights.unsqueeze(1), x).squeeze(1) | |
| class ChronoZeroModel(nn.Module): | |
| def __init__(self, cfg, n_channels): | |
| super().__init__() | |
| self.cfg = cfg | |
| self.n_channels = n_channels | |
| self.revin = RevIN(n_channels, affine=False) | |
| self.patch_embed = PatchEmbed(cfg.patch_size, cfg.d_model, cfg.dropout) | |
| self.layers = nn.ModuleList([ | |
| EncoderLayer(cfg.d_model, cfg.n_heads, cfg.d_ff, cfg.dropout, cfg.input_length + 50) | |
| for _ in range(cfg.n_layers) | |
| ]) | |
| self.norm = RMSNorm(cfg.d_model) | |
| self.pool = AttentionPool(cfg.d_model) | |
| self.head_mean = nn.Linear(cfg.d_model, cfg.max_output_length, bias=False) | |
| self.head_logvar = nn.Linear(cfg.d_model, cfg.max_output_length, bias=False) | |
| self._init_weights() | |
| self.n_params = sum(p.numel() for p in self.parameters()) | |
| print(f" [Model] {self.n_params:,} params ({self.n_params/1e6:.2f}M)") | |
| def _init_weights(self): | |
| for m in self.modules(): | |
| if isinstance(m, nn.Linear): | |
| nn.init.kaiming_uniform_(m.weight, a=math.sqrt(5)) | |
| if m.bias is not None: nn.init.zeros_(m.bias) | |
| elif isinstance(m, RMSNorm): | |
| nn.init.ones_(m.weight) | |
| def forward(self, x, output_length=None, return_uncertainty=False): | |
| if output_length is None: | |
| output_length = self.cfg.output_length | |
| B, L, C = x.shape | |
| x = self.revin(x, "norm") | |
| x = x.permute(0, 2, 1).reshape(B * C, 1, L) | |
| x, n_p = self.patch_embed(x) | |
| for layer in self.layers: | |
| x = layer(x) | |
| x = self.norm(x) | |
| x = self.pool(x) | |
| mean = self.head_mean(x)[:, :output_length] | |
| logvar = self.head_logvar(x)[:, :output_length] | |
| mean = mean.reshape(B, C, output_length).permute(0, 2, 1) | |
| logvar = logvar.reshape(B, C, output_length).permute(0, 2, 1) | |
| logvar = torch.clamp(logvar, -10, 10) | |
| mean = self.revin(mean, "denorm") | |
| if return_uncertainty: | |
| return torch.stack([mean, logvar], dim=-1) | |
| return mean | |
| def predict(self, x, output_length=None): | |
| self.eval() | |
| return self.forward(x, output_length=output_length, return_uncertainty=False) | |
| def predict_mc(self, x, output_length=None, n_samples=100): | |
| self.train() | |
| samples = [self.forward(x, output_length=output_length, return_uncertainty=False).cpu() for _ in range(n_samples)] | |
| self.eval() | |
| return torch.stack(samples, dim=0) | |
| # =================================================================== | |
| # ZERO-SHOT PREDICTOR (fixed: no double normalization) | |
| # =================================================================== | |
| class ZeroShotPredictor: | |
| def __init__(self, checkpoint_path, device=None): | |
| self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") | |
| self.ckpt = torch.load(checkpoint_path, map_location=self.device) | |
| self.cfg = Config() | |
| saved = self.ckpt.get("config", {}) | |
| for k, v in saved.items(): | |
| if hasattr(self.cfg, k): | |
| setattr(self.cfg, k, v) | |
| def _load_data(self, data): | |
| if isinstance(data, str): | |
| df = pd.read_csv(data) | |
| for col in df.columns: | |
| if col.lower() in ("index", "unnamed: 0", "id"): | |
| if df[col].dtype.kind in 'iuf' or df[col].is_monotonic_increasing: | |
| df = df.drop(columns=[col]) | |
| break | |
| for col in df.columns: | |
| if pd.api.types.is_datetime64_any_dtype(df[col]): | |
| df = df.drop(columns=[col]) | |
| break | |
| data = df.select_dtypes(include=[np.number]).fillna(0).values | |
| elif isinstance(data, pd.DataFrame): | |
| data = data.select_dtypes(include=[np.number]).fillna(0).values | |
| data = np.asarray(data, dtype=np.float32) | |
| if data.ndim == 1: | |
| data = data.reshape(-1, 1) | |
| return data | |
| def predict(self, data, output_length=96, history_length=None, n_mc=0): | |
| data = self._load_data(data) | |
| n_channels = data.shape[1] | |
| history_length = history_length or self.cfg.input_length | |
| model = ChronoZeroModel(self.cfg, n_channels).to(self.device) | |
| model.load_state_dict(self.ckpt["model"]) | |
| model.eval() | |
| if len(data) < history_length: | |
| pad = history_length - len(data) | |
| ctx = np.pad(data, ((pad, 0), (0, 0)), mode="edge") | |
| else: | |
| ctx = data[-history_length:] | |
| # Pass raw data to model. RevIN handles normalization internally. | |
| x = torch.from_numpy(ctx).unsqueeze(0).to(self.device) | |
| max_h = self.cfg.max_output_length | |
| preds_mean, preds_std = [], [] | |
| # Direct prediction for anything within model capacity. No recursion. | |
| if output_length <= max_h: | |
| with torch.no_grad(): | |
| if n_mc > 0: | |
| samples = model.predict_mc(x, output_length=output_length, n_samples=n_mc) | |
| pred_mean = samples.mean(dim=0) | |
| pred_std = samples.std(dim=0) | |
| else: | |
| pred_mean = model.predict(x, output_length=output_length) | |
| pred_std = None | |
| preds_mean.append(pred_mean.cpu().numpy()) | |
| if pred_std is not None: | |
| preds_std.append(pred_std.cpu().numpy()) | |
| else: | |
| # Only recurse if user asks for more than the head can output in one shot | |
| current = x | |
| remaining = output_length | |
| while remaining > 0: | |
| h = min(remaining, max_h) | |
| with torch.no_grad(): | |
| if n_mc > 0: | |
| samples = model.predict_mc(current, output_length=h, n_samples=n_mc) | |
| p = samples.mean(dim=0) | |
| s = samples.std(dim=0) | |
| else: | |
| p = model.predict(current, output_length=h) | |
| s = None | |
| preds_mean.append(p.cpu().numpy()) | |
| if s is not None: | |
| preds_std.append(s.cpu().numpy()) | |
| current = torch.cat([current[:, -history_length + h:, :], p.to(self.device)], dim=1) | |
| remaining -= h | |
| mean_arr = np.concatenate(preds_mean, axis=1)[0] | |
| std_arr = np.concatenate(preds_std, axis=1)[0] if preds_std else None | |
| # No manual denorm needed. RevIN already denormed in model.forward. | |
| return {"mean": mean_arr, "std": std_arr, "history": data} | |
| # =================================================================== | |
| # BASELINE FORECASTS (always useful, especially for demos) | |
| # =================================================================== | |
| def naive_baseline(history, horizon): | |
| """Repeat the last observed value.""" | |
| last = history[-1, :] | |
| return np.tile(last, (horizon, 1)) | |
| def trend_baseline(history, horizon): | |
| """Linear trend fitted to history, extrapolated.""" | |
| T, C = history.shape | |
| x = np.arange(T) | |
| pred = np.zeros((horizon, C)) | |
| for c in range(C): | |
| y = history[:, c] | |
| # Simple least squares | |
| x_mean = x.mean() | |
| y_mean = y.mean() | |
| slope = ((x - x_mean) * (y - y_mean)).sum() / ((x - x_mean) ** 2).sum() + 1e-12 | |
| intercept = y_mean - slope * x_mean | |
| future_x = np.arange(T, T + horizon) | |
| pred[:, c] = slope * future_x + intercept | |
| return pred | |
| def seasonal_naive(history, horizon, season=12): | |
| """Repeat the last season's values.""" | |
| T, C = history.shape | |
| if T < season: | |
| return naive_baseline(history, horizon) | |
| last_season = history[-season:, :] | |
| repeats = int(np.ceil(horizon / season)) | |
| full = np.tile(last_season, (repeats, 1))[:horizon, :] | |
| return full | |
| # =================================================================== | |
| # CHECKPOINT DISCOVERY | |
| # =================================================================== | |
| def find_checkpoint(): | |
| candidates = [ | |
| "./checkpoints/best_model.pt", | |
| "/checkpoints/best_model.pt", | |
| "best_model.pt", | |
| "./best_model.pt", | |
| "/app/checkpoints/best_model.pt", | |
| "/app/best_model.pt", | |
| "/home/user/app/checkpoints/best_model.pt", | |
| ] | |
| for pattern in ["./**/*.pt", "./*.pt", "/app/**/*.pt"]: | |
| try: | |
| candidates.extend(glob.glob(pattern, recursive=True)) | |
| except Exception: | |
| pass | |
| for path in candidates: | |
| if os.path.exists(path) and os.path.getsize(path) > 1000: | |
| print(f"[Checkpoint] Found: {path} ({os.path.getsize(path)/1e6:.2f} MB)") | |
| return path | |
| print("[Checkpoint] Searched paths:") | |
| for p in candidates[:8]: | |
| print(f" {p} -> exists={os.path.exists(p)}") | |
| print("[Checkpoint] Current directory:", os.getcwd()) | |
| try: | |
| for root, dirs, files in os.walk("."): | |
| for f in files: | |
| if f.endswith(".pt") or f.endswith(".pth"): | |
| print(f" Found: {os.path.join(root, f)}") | |
| except Exception as e: | |
| print(f" Walk error: {e}") | |
| return None | |
| CHECKPOINT_PATH = find_checkpoint() | |
| def load_predictor(): | |
| if CHECKPOINT_PATH is None: | |
| return None | |
| try: | |
| return ZeroShotPredictor(CHECKPOINT_PATH) | |
| except Exception as e: | |
| print(f"Failed to load checkpoint: {e}") | |
| return None | |
| PREDICTOR = load_predictor() | |
| # =================================================================== | |
| # SMART COLUMN DETECTION | |
| # =================================================================== | |
| def _looks_like_index(col_name, series): | |
| name = col_name.lower().strip() | |
| if name in ("index", "unnamed: 0", "id", "row", "row_number", "serial", "no", "num", "idx"): | |
| return True | |
| if re.match(r"^unnamed:\s*\d+$", name): | |
| return True | |
| if series.dtype.kind in 'iuf': | |
| diffs = series.diff().dropna() | |
| if len(diffs) > 0 and (diffs == 1).all(): | |
| if series.iloc[0] in (0, 1): | |
| return True | |
| return False | |
| def _looks_like_date(series): | |
| if pd.api.types.is_datetime64_any_dtype(series): | |
| return True | |
| if series.dtype == object: | |
| sample = series.dropna().head(20) | |
| if len(sample) == 0: | |
| return False | |
| try: | |
| parsed = pd.to_datetime(sample, errors='coerce') | |
| if parsed.notna().sum() >= len(sample) * 0.8: | |
| return True | |
| except Exception: | |
| pass | |
| return False | |
| def analyze_csv(file_path): | |
| if file_path is None: | |
| return None, [], "No file uploaded.", 0 | |
| try: | |
| df = pd.read_csv(file_path) | |
| except Exception as e: | |
| return None, [], f"Could not read CSV: {e}", 0 | |
| row_count = len(df) | |
| dropped = [] | |
| cols_to_drop = [] | |
| for col in df.columns: | |
| if _looks_like_index(col, df[col]) or _looks_like_date(df[col]): | |
| cols_to_drop.append(col) | |
| dropped.append(f"'{col}' (auto-excluded)") | |
| if cols_to_drop: | |
| df = df.drop(columns=cols_to_drop) | |
| for col in df.columns: | |
| if df[col].dtype == object: | |
| try: | |
| converted = pd.to_numeric(df[col].str.replace(',', '').str.replace('$', '').str.replace('%', ''), errors='coerce') | |
| if converted.notna().sum() >= len(df) * 0.5: | |
| df[col] = converted | |
| dropped.append(f"'{col}' (cleaned from text)") | |
| except Exception: | |
| pass | |
| numeric_df = df.select_dtypes(include=[np.number]).ffill().bfill().fillna(0.0) | |
| numeric_cols = list(numeric_df.columns) | |
| drop_msg = "Auto-detected and excluded: " + ", ".join(dropped) if dropped else "No columns auto-excluded." | |
| if not numeric_cols: | |
| drop_msg += " WARNING: No numeric columns remain." | |
| preview = numeric_df.head(10) | |
| return preview, numeric_cols, drop_msg, row_count | |
| # =================================================================== | |
| # APP LOGIC | |
| # =================================================================== | |
| def process_csv(file_path, columns, history_len, pred_len, show_uncertainty, mc_samples, show_baseline): | |
| if PREDICTOR is None: | |
| msg = "No model checkpoint found." | |
| if CHECKPOINT_PATH is None: | |
| msg += " Searched all common paths. Please upload best_model.pt." | |
| return None, msg, None, None, [] | |
| if file_path is None: | |
| return None, "Upload a CSV first.", None, None, [] | |
| preview, numeric_cols, drop_msg, row_count = analyze_csv(file_path) | |
| if not numeric_cols: | |
| return None, f"No usable numeric columns. {drop_msg}", None, preview, [] | |
| if not columns or len(columns) == 0: | |
| columns = numeric_cols | |
| try: | |
| df = pd.read_csv(file_path) | |
| cols_to_drop = [] | |
| for col in df.columns: | |
| if _looks_like_index(col, df[col]) or _looks_like_date(df[col]): | |
| cols_to_drop.append(col) | |
| if cols_to_drop: | |
| df = df.drop(columns=cols_to_drop) | |
| for col in df.columns: | |
| if df[col].dtype == object: | |
| try: | |
| converted = pd.to_numeric(df[col].str.replace(',', '').str.replace('$', '').str.replace('%', ''), errors='coerce') | |
| if converted.notna().sum() >= len(df) * 0.5: | |
| df[col] = converted | |
| except Exception: | |
| pass | |
| sub_df = df[columns].select_dtypes(include=[np.number]).ffill().bfill().fillna(0.0) | |
| except KeyError: | |
| return None, f"Selected columns not found: {columns}", None, preview, [] | |
| except Exception as e: | |
| return None, f"Error: {e}", None, preview, [] | |
| data = sub_df.values.astype(np.float32) | |
| n_mc = mc_samples if show_uncertainty else 0 | |
| # Model prediction | |
| try: | |
| result = PREDICTOR.predict(data, output_length=pred_len, history_length=history_len, n_mc=n_mc) | |
| except Exception as e: | |
| return None, f"Prediction failed: {e}", None, preview, [] | |
| mean_pred = result["mean"] | |
| std_pred = result["std"] | |
| history = result["history"] | |
| # Baselines | |
| naive_pred = naive_baseline(history[-history_len:], pred_len) | |
| trend_pred = trend_baseline(history[-history_len:], pred_len) | |
| # Detect flat model output (common with untrained models or domain mismatch) | |
| warnings_list = [] | |
| for i, col in enumerate(columns): | |
| pred_var = float(np.var(mean_pred[:, i])) | |
| hist_var = float(np.var(history[:, i])) if history.shape[0] > 1 else 1.0 | |
| if pred_var < 1e-6 * hist_var: | |
| warnings_list.append(f"'{col}' forecast is nearly flat. Model may need more training or the data differs from training domain.") | |
| if pred_len > 96: | |
| warnings_list.append(f"Horizon {pred_len} exceeds training horizon (96). Predictions beyond 96 are extrapolated.") | |
| warning_text = " | ".join(warnings_list) if warnings_list else "" | |
| # Build plot | |
| fig = make_subplots(rows=1, cols=1) | |
| hist_len_display = min(history_len, history.shape[0]) | |
| hist_idx = list(range(hist_len_display)) | |
| pred_idx = list(range(hist_len_display, hist_len_display + pred_len)) | |
| colors = [ | |
| "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", | |
| "#9467bd", "#8c564b", "#e377c2", "#7f7f7f" | |
| ] | |
| for i, col in enumerate(columns): | |
| c = colors[i % len(colors)] | |
| r = int(c.lstrip("#")[0:2], 16) | |
| g = int(c.lstrip("#")[2:4], 16) | |
| b = int(c.lstrip("#")[4:6], 16) | |
| rgba_fill = f"rgba({r},{g},{b},0.15)" | |
| # History | |
| fig.add_trace(go.Scatter( | |
| x=hist_idx, y=history[-hist_len_display:, i], | |
| mode="lines", name=f"{col} (history)", | |
| line=dict(color=c, width=2) | |
| )) | |
| # Model forecast | |
| fig.add_trace(go.Scatter( | |
| x=pred_idx, y=mean_pred[:, i], | |
| mode="lines", name=f"{col} (forecast)", | |
| line=dict(color=c, width=2.5) | |
| )) | |
| # Uncertainty | |
| if std_pred is not None: | |
| upper = mean_pred[:, i] + 2 * std_pred[:, i] | |
| lower = mean_pred[:, i] - 2 * std_pred[:, i] | |
| fig.add_trace(go.Scatter( | |
| x=pred_idx + pred_idx[::-1], | |
| y=list(upper) + list(lower[::-1]), | |
| fill="toself", fillcolor=rgba_fill, | |
| line=dict(color="rgba(255,255,255,0)"), | |
| name=f"{col} 95% CI", showlegend=True | |
| )) | |
| # Baselines (optional, thin lines) | |
| if show_baseline: | |
| fig.add_trace(go.Scatter( | |
| x=pred_idx, y=naive_pred[:, i], | |
| mode="lines", name=f"{col} (naive)", | |
| line=dict(color=c, width=1, dash="dot"), | |
| opacity=0.6 | |
| )) | |
| fig.add_trace(go.Scatter( | |
| x=pred_idx, y=trend_pred[:, i], | |
| mode="lines", name=f"{col} (trend)", | |
| line=dict(color=c, width=1, dash="dash"), | |
| opacity=0.6 | |
| )) | |
| fig.update_layout( | |
| title="Clockwork Forecast", | |
| xaxis_title="Time Step", | |
| yaxis_title="Value", | |
| hovermode="x unified", | |
| template="plotly_white", | |
| height=550, | |
| legend=dict(orientation="h", yanchor="bottom", y=-0.3, xanchor="center", x=0.5) | |
| ) | |
| # Output CSV | |
| out_df = pd.DataFrame(mean_pred, columns=[f"{c}_forecast" for c in columns]) | |
| if std_pred is not None: | |
| for i, c in enumerate(columns): | |
| out_df[f"{c}_std"] = std_pred[:, i] | |
| if show_baseline: | |
| for i, c in enumerate(columns): | |
| out_df[f"{c}_naive"] = naive_pred[:, i] | |
| out_df[f"{c}_trend"] = trend_pred[:, i] | |
| out_path = "/tmp/clockwork_forecast.csv" | |
| out_df.to_csv(out_path, index=False) | |
| status_msg = f"Forecast complete. {drop_msg} Rows: {row_count}." | |
| if warning_text: | |
| status_msg += f" NOTE: {warning_text}" | |
| return fig, status_msg, out_path, preview, columns | |
| def on_file_upload(file_path): | |
| preview, numeric_cols, drop_msg, row_count = analyze_csv(file_path) | |
| status = f"{drop_msg} | {row_count} rows | {len(numeric_cols)} numeric columns ready." | |
| return gr.update(choices=numeric_cols, value=numeric_cols), preview, status | |
| def toggle_mc(visible): | |
| return gr.update(visible=visible) | |
| # =================================================================== | |
| # GRADIO UI | |
| # =================================================================== | |
| with gr.Blocks(title="Clockwork", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown( | |
| """ | |
| # Clockwork | |
| A 2.5M parameter foundation model for time series. | |
| Most forecasting tools force you to train a new model for every single dataset. Clockwork skips all that. | |
| Upload a CSV, pick columns, get a forecast. No training, no tuning, no feature engineering. | |
| One model replaces an entire ML ops pipeline. Right now companies burn months building separate forecasters | |
| for demand, energy, finance, IoT, etc. Clockwork handles all of them with the same weights. | |
| It is the foundation model approach, but for temporal data. | |
| **Use cases already getting traction:** | |
| - **Retail & Supply Chain:** Forecast SKU demand across thousands of products. One model for the entire warehouse, no retraining per category. | |
| - **Energy:** Grid load and renewable generation forecasting. Works for any region, any season, out of the box. | |
| - **Finance:** Zero-shot volatility and microstructure on any ticker. No rebuilding when market regimes shift. | |
| - **IoT & Infrastructure:** Server metrics, factory sensors, predictive maintenance. Runs on edge hardware because it is only 2.5M parameters. | |
| - **Healthcare:** ICU patient vitals forecasting for early warning. Generalizes across hospitals and sensor setups. | |
| **Try it:** | |
| 1. Upload a CSV with numeric columns | |
| 2. Select which columns to forecast | |
| 3. Set context length and prediction horizon | |
| 4. Hit Run. You get an interactive plot and a downloadable CSV. | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| file_input = gr.File(label="Upload CSV", file_types=[".csv"]) | |
| col_select = gr.Dropdown( | |
| label="Columns to forecast", | |
| choices=[], multiselect=True, interactive=True | |
| ) | |
| file_status = gr.Textbox(label="File Info", interactive=False) | |
| with gr.Group(): | |
| hist_slider = gr.Slider( | |
| minimum=64, maximum=2048, value=512, step=16, | |
| label="History length (context)" | |
| ) | |
| pred_slider = gr.Slider( | |
| minimum=1, maximum=720, value=96, step=1, | |
| label="Prediction horizon" | |
| ) | |
| with gr.Group(): | |
| uncertainty_chk = gr.Checkbox( | |
| label="Show uncertainty bands", value=False | |
| ) | |
| mc_slider = gr.Slider( | |
| minimum=10, maximum=200, value=50, step=10, | |
| label="MC samples (for uncertainty)", visible=False | |
| ) | |
| baseline_chk = gr.Checkbox( | |
| label="Show naive + trend baselines", value=True | |
| ) | |
| run_btn = gr.Button("Run Forecast", variant="primary") | |
| status = gr.Textbox(label="Status", interactive=False) | |
| with gr.Column(scale=2): | |
| preview_table = gr.Dataframe(label="Data Preview (first 10 rows)", interactive=False) | |
| plot_output = gr.Plot(label="Forecast") | |
| download_btn = gr.File(label="Download CSV") | |
| file_input.change(on_file_upload, inputs=file_input, outputs=[col_select, preview_table, file_status]) | |
| uncertainty_chk.change(toggle_mc, inputs=uncertainty_chk, outputs=mc_slider) | |
| run_btn.click( | |
| process_csv, | |
| inputs=[file_input, col_select, hist_slider, pred_slider, uncertainty_chk, mc_slider, baseline_chk], | |
| outputs=[plot_output, status, download_btn, preview_table, col_select] | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() |