interfaze-1-lite / forecasting.py
Abhinav-jigsawstack's picture
Upload folder using huggingface_hub
f1014ac verified
Raw History Blame Contribute Delete
12.3 kB
"""Forecasting: interfaze's dataset parsing and pre-processing, and its TimesFM service's.
Ported so their behaviour does not move: interfaze `helpers/forecastDataset.ts` and
`helpers/forecast/index.ts` through `series`, JigsawStack's `prediction-timesfm` service after it.
Pure, apart from pandas/numpy imported where they are used.
"""
from __future__ import annotations
import ipaddress
import json
import math
import re
from datetime import datetime, timedelta
from urllib.parse import urlparse
MIN_FORECAST_POINTS = 5
MAX_FORECAST_POINTS = 1_000
MAX_DATASET_BYTES = 25 * 1024 * 1024
# TimesFM, as JigsawStack's service compiles it.
MAX_HORIZON = 256
MAX_CONTEXT = 1024
_DATE_HEADER = re.compile(r"date|time|day|month|year|timestamp|period|\bds\b", re.I)
_GROUPED = re.compile(r"^-?\d{1,3}(,\d{3})+(\.\d+)?$")
class DatasetError(ValueError):
"""The data could not be turned into a series; the message says what to fix."""
def parse_csv(text: str) -> list[list[str]]:
"""RFC-4180-ish rows: quoted fields, "" escapes, CRLF/LF, a leading BOM. Blank lines dropped."""
if text.startswith(""):
text = text[1:]
rows: list[list[str]] = []
field, row, quoted = "", [], False
i = 0
while i < len(text):
ch = text[i]
if quoted:
if ch == '"':
if i + 1 < len(text) and text[i + 1] == '"':
field += '"'
i += 1
else:
quoted = False
else:
field += ch
elif ch == '"':
quoted = True
elif ch == ",":
row.append(field)
field = ""
elif ch == "\n":
row.append(field)
rows.append(row)
field, row = "", []
elif ch != "\r":
field += ch
i += 1
row.append(field)
rows.append(row)
return [r for r in rows if not (len(r) == 1 and r[0].strip() == "")]
def coerce_number(raw: str) -> float:
"""A cell as a number: a leading currency sign dropped, thousands commas only when
the value is unambiguously grouped. NaN when it is not a number."""
trimmed = raw.strip()
trimmed = re.sub(r"^[$€£]", "", trimmed)
if trimmed:
try:
return float(trimmed)
except ValueError:
pass
if _GROUPED.match(trimmed):
return float(trimmed.replace(",", ""))
return float("nan")
def looks_like_date(text: str) -> bool:
"""What JavaScript's Date.parse accepts, near enough: pandas parses it."""
if not text or not text.strip():
return False
try:
float(text)
return False # a bare number is not a date here, as in interfaze's header test
except ValueError:
pass
import pandas as pd
try:
return not pd.isna(pd.to_datetime(text.strip(), errors="coerce"))
except (ValueError, TypeError, OverflowError):
return False
def rows_to_dataset(
rows: list[list[str]], date_column: str | None = None, value_column: str | None = None
) -> list[dict]:
"""CSV rows as `{date, value}` points, choosing the columns the way interfaze does."""
if not rows:
return []
first = [c.strip() for c in rows[0]]
has_header = any(c and not _is_number(c) and not looks_like_date(c) for c in first)
header = first if has_header else [f"column_{i}" for i in range(len(first))]
lower = [h.lower() for h in header]
count = len(header)
def hint(name: str | None, kind: str) -> int:
if not name:
return -1
try:
return lower.index(name.strip().lower())
except ValueError:
raise DatasetError(
f'{kind} column "{name}" not found. Available columns: {", ".join(header)}.'
) from None
date_idx = hint(date_column, "Date")
value_idx = hint(value_column, "Value")
if date_idx == -1 and has_header:
date_idx = next((i for i, h in enumerate(lower) if _DATE_HEADER.search(h)), -1)
if count == 2:
if date_idx == -1 and value_idx == -1:
date_idx, value_idx = 0, 1
elif value_idx == -1:
value_idx = 1 if date_idx == 0 else 0
elif date_idx == -1:
date_idx = 1 if value_idx == 0 else 0
# More than two columns, no value hint: exactly one numeric non-date column is taken;
# several is genuinely ambiguous (date,open,close) and asks for value_column.
if value_idx == -1 and date_idx != -1:
sample = (rows[1:] if has_header else rows)[:5]
numeric = [
i
for i in range(count)
if i != date_idx
and sample
and all(i < len(r) and math.isfinite(coerce_number(r[i])) for r in sample)
]
if len(numeric) == 1:
value_idx = numeric[0]
if date_idx == -1 or value_idx == -1 or date_idx == value_idx:
raise DatasetError(
f"Could not determine date and value columns from: {', '.join(header)}. "
"Specify date_column and value_column explicitly."
)
dataset = []
for r in rows[1:] if has_header else rows:
if date_idx >= len(r) or value_idx >= len(r):
continue
date = (r[date_idx] or "").strip()
value = coerce_number(r[value_idx] or "")
if not date or not looks_like_date(date) or not math.isfinite(value):
continue
dataset.append({"date": date, "value": value})
return dataset
def json_to_dataset(parsed, date_column: str | None = None, value_column: str | None = None) -> list[dict]:
"""`[{date, value}]`, `[[date, value]]`, or either wrapped in {data|dataset|series|predictions}."""
arr = parsed
if isinstance(parsed, dict):
# JavaScript's `??`: the first key that is present, even when it holds [].
arr = next(
(parsed[k] for k in ("data", "dataset", "series", "predictions") if parsed.get(k) is not None),
parsed,
)
if not isinstance(arr, list):
return []
date_keys = [k for k in (date_column, "date", "ds", "timestamp", "time", "day", "period") if k]
value_keys = [k for k in (value_column, "value", "y", "val", "amount", "count") if k]
dataset = []
for row in arr:
date, value = None, None
if isinstance(row, list) and len(row) >= 2:
date, value = str(row[0]).strip(), coerce_number(str(row[1]))
elif isinstance(row, dict):
by_lower = {k.lower(): k for k in row}
dk = next((by_lower[k.lower()] for k in date_keys if k.lower() in by_lower), None)
vk = next((by_lower[k.lower()] for k in value_keys if k.lower() in by_lower), None)
if dk is not None:
date = str(row[dk]).strip()
if vk is not None:
value = coerce_number(str(row[vk]))
if not date or value is None or not looks_like_date(date) or not math.isfinite(value):
continue
dataset.append({"date": date, "value": value})
return dataset
def parse_dataset(
text: str,
*,
url: str = "",
content_type: str = "",
date_column: str | None = None,
value_column: str | None = None,
) -> list[dict]:
"""A dataset file's text as points, JSON or CSV by content type, extension or first byte."""
looks_json = (
"json" in content_type.lower()
or re.search(r"\.json(\?|#|$)", url, re.I)
or re.match(r"^\s*[\[{]", text)
)
if looks_json:
return json_to_dataset(json.loads(text), date_column, value_column)
return rows_to_dataset(parse_csv(text), date_column, value_column)
def dataset_from_text(text: str) -> list[dict]:
"""The series written in a message: its longest run of rows with one comma count, as CSV; empty if none.
Spares the model copying a pasted table into a tool call, which ran past the token limit (CUT_OFF_CALL).
"""
best: list[str] = []
run: list[str] = []
shape = None
for line in (text or "").splitlines():
commas = line.count(",")
if line.strip() and commas:
if commas != shape:
run, shape = [], commas
run.append(line)
if len(run) > len(best):
best = list(run)
else:
run, shape = [], None
# Drop a lead-in with the rows' comma count ("Forecast this, please:") until the rest reads as a table.
for start in range(min(3, len(best))):
if len(best) - start < MIN_FORECAST_POINTS:
break
try:
points = rows_to_dataset(parse_csv("\n".join(best[start:])))
except (DatasetError, ValueError):
continue
if len(points) >= MIN_FORECAST_POINTS:
return points
return []
def assert_fetchable_url(url: str) -> None:
"""Refuse non-http(s) URLs and addresses back into our own network, as interfaze does."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise DatasetError("Only http(s) dataset URLs are supported.")
host = (parsed.hostname or "").lower()
blocked = host in ("localhost", "0.0.0.0", "::1", "::") or host.endswith(".localhost")
try:
ip = ipaddress.ip_address(host)
blocked = blocked or ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_unspecified
except ValueError:
pass
if blocked:
raise DatasetError(f"Refusing to fetch dataset from a non-public address ({parsed.hostname}).")
def series(dataset: list[dict]) -> dict[str, float]:
"""interfaze's pre-processing: a time on every date, sorted, same-date values summed."""
points = [{**p, "date": p["date"] if ":" in p["date"] else f"{p['date']} 00:00:00"} for p in dataset]
points.sort(key=lambda p: _timestamp(p["date"]))
y: dict[str, float] = {}
for p in points:
v = float(p["value"])
y[p["date"]] = y[p["date"]] + v if p["date"] in y else v
if len(y) < MIN_FORECAST_POINTS:
raise DatasetError("At least 5 unique dates are required")
return y
# Ported from JigsawStack's prediction-timesfm service.
def parse_dates_sorted(y: dict[str, float]) -> tuple[list[datetime], list[float]]:
import pandas as pd
keys = list(y)
idx = pd.to_datetime(keys, errors="coerce")
bad = [keys[i] for i, ts in enumerate(idx) if pd.isna(ts)]
if bad:
raise DatasetError(f"Invalid date keys: {bad[:8]}{'...' if len(bad) > 8 else ''}")
if getattr(idx, "tz", None) is not None:
idx = idx.tz_convert(None)
order = idx.argsort()
return [idx[i].to_pydatetime() for i in order], [float(y[keys[i]]) for i in order]
def infer_step(dts: list[datetime]) -> timedelta:
import numpy as np
import pandas as pd
if len(dts) < 3:
return timedelta(days=1)
try:
guessed = pd.infer_freq(pd.DatetimeIndex(dts))
if guessed:
g = guessed.upper()
if g.endswith("D"):
return timedelta(days=int(g[:-1] or "1"))
if g.endswith("H"):
return timedelta(hours=int(g[:-1] or "1"))
if g.endswith(("T", "MIN")):
n = g[:-1] if g.endswith("T") else g.replace("MIN", "")
return timedelta(minutes=int(n or "1"))
if g.endswith("S"):
return timedelta(seconds=int(g[:-1] or "1"))
except Exception:
pass
diffs = np.diff(np.array(dts, dtype="datetime64[s]")).astype("timedelta64[s]").astype(int)
diffs = diffs[diffs > 0]
return timedelta(seconds=int(np.median(diffs))) if diffs.size else timedelta(days=1)
def future_timestamps(last: datetime, horizon: int, step: timedelta) -> list[str]:
start = last + step
return [(start + i * step).strftime("%Y-%m-%d %H:%M:%S") for i in range(horizon)]
def all_integers(values) -> bool:
return all(
not isinstance(v, bool) and isinstance(v, (int, float)) and abs(v - round(v)) < 1e-9 for v in values
)
def _is_number(text: str) -> bool:
try:
float(text)
return True
except ValueError:
return False
def _timestamp(text: str) -> float:
import pandas as pd
ts = pd.to_datetime(text, errors="coerce")
return float("inf") if pd.isna(ts) else ts.value