Spaces:
Running
Running
Visualize: starter questions carry their plans (no planning call), hedged model calls so one slow provider can't stall a chart, sturdier label discovery, spread samples, whole-number histograms
Browse files- app/visualize_api.py +24 -9
- datainventor/visualize.py +82 -26
app/visualize_api.py
CHANGED
|
@@ -45,6 +45,7 @@ class VisSet:
|
|
| 45 |
created: float = field(default_factory=time.time)
|
| 46 |
lock: threading.Lock = field(default_factory=threading.Lock)
|
| 47 |
busy: bool = False
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
@dataclass
|
|
@@ -86,7 +87,7 @@ def _hub_rows(dataset: str, n: int) -> list[dict]:
|
|
| 86 |
except Exception as e:
|
| 87 |
raise HTTPException(400, f"Could not open {dataset}: {str(e)[:160]}")
|
| 88 |
total = info.get("num_rows") or 0
|
| 89 |
-
chunks = 1 if not total or total <= n else min(4, math.ceil(n / 25))
|
| 90 |
per = math.ceil(n / chunks)
|
| 91 |
starts = [0] if chunks == 1 else [max(0, int(total * (i + 0.5) / chunks) - per // 2) for i in range(chunks)]
|
| 92 |
out, seen = [], set()
|
|
@@ -192,13 +193,13 @@ def load(body: LoadIn):
|
|
| 192 |
if not cols:
|
| 193 |
raise HTTPException(400, "None of the columns hold text, categories or numbers this can chart.")
|
| 194 |
sid = uuid.uuid4().hex[:10]
|
| 195 |
-
vs = VisSet(id=sid, name=name, source=src, rows=rows, cols=cols)
|
| 196 |
with GUARD:
|
| 197 |
SETS[sid] = vs
|
| 198 |
for old in sorted(SETS, key=lambda k: SETS[k].created)[:-30]:
|
| 199 |
SETS.pop(old, None)
|
| 200 |
return {"id": sid, "name": name, "source": src, "n": len(rows), "note": note, "columns": V.describe(rows, cols),
|
| 201 |
-
"glance": V.glance(rows, cols), "suggestions":
|
| 202 |
|
| 203 |
|
| 204 |
# ------------------------------------------------------------------ asking
|
|
@@ -228,14 +229,28 @@ def run_ask(a: Ask, vs: VisSet, ep):
|
|
| 228 |
rows = [r for r in vs.rows if r["_id"] in ids] if ids else list(vs.rows)
|
| 229 |
scope = f" (this question is about a selection of {len(rows)} of {len(vs.rows)} rows: {a.scope_label or 'chosen on a chart'})" if ids else ""
|
| 230 |
plan, err = None, ""
|
| 231 |
-
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
try:
|
| 234 |
-
|
| 235 |
-
break
|
| 236 |
except V.PlanError as e:
|
| 237 |
-
|
| 238 |
-
print(f"[visualize] plan rejected: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
if not plan:
|
| 240 |
plan = V.fallback_plan(a.question, rows, vs.cols)
|
| 241 |
# new columns, plus earlier model-made columns this chart needs that do not cover these rows yet
|
|
|
|
| 45 |
created: float = field(default_factory=time.time)
|
| 46 |
lock: threading.Lock = field(default_factory=threading.Lock)
|
| 47 |
busy: bool = False
|
| 48 |
+
presets: dict = field(default_factory=dict)
|
| 49 |
|
| 50 |
|
| 51 |
@dataclass
|
|
|
|
| 87 |
except Exception as e:
|
| 88 |
raise HTTPException(400, f"Could not open {dataset}: {str(e)[:160]}")
|
| 89 |
total = info.get("num_rows") or 0
|
| 90 |
+
chunks = 1 if not total or total <= n else min(4, max(3, math.ceil(n / 25))) # sorted datasets need several slices
|
| 91 |
per = math.ceil(n / chunks)
|
| 92 |
starts = [0] if chunks == 1 else [max(0, int(total * (i + 0.5) / chunks) - per // 2) for i in range(chunks)]
|
| 93 |
out, seen = [], set()
|
|
|
|
| 193 |
if not cols:
|
| 194 |
raise HTTPException(400, "None of the columns hold text, categories or numbers this can chart.")
|
| 195 |
sid = uuid.uuid4().hex[:10]
|
| 196 |
+
vs = VisSet(id=sid, name=name, source=src, rows=rows, cols=cols, presets=V.presets(rows, cols))
|
| 197 |
with GUARD:
|
| 198 |
SETS[sid] = vs
|
| 199 |
for old in sorted(SETS, key=lambda k: SETS[k].created)[:-30]:
|
| 200 |
SETS.pop(old, None)
|
| 201 |
return {"id": sid, "name": name, "source": src, "n": len(rows), "note": note, "columns": V.describe(rows, cols),
|
| 202 |
+
"glance": V.glance(rows, cols), "suggestions": list(vs.presets), "rows": rows}
|
| 203 |
|
| 204 |
|
| 205 |
# ------------------------------------------------------------------ asking
|
|
|
|
| 229 |
rows = [r for r in vs.rows if r["_id"] in ids] if ids else list(vs.rows)
|
| 230 |
scope = f" (this question is about a selection of {len(rows)} of {len(vs.rows)} rows: {a.scope_label or 'chosen on a chart'})" if ids else ""
|
| 231 |
plan, err = None, ""
|
| 232 |
+
pre = vs.presets.get(a.question)
|
| 233 |
+
if pre: # a starter question: its plan is already written, and labels found before are reused
|
| 234 |
+
plan = {**pre, "derive": [], "chart": dict(pre["chart"])}
|
| 235 |
+
for d in pre["derive"]:
|
| 236 |
+
c = vs.cols.get(d["name"])
|
| 237 |
+
if not (c and c.get("derived") == d["kind"] and c.get("from") == d["from"]):
|
| 238 |
+
plan["derive"].append(dict(d))
|
| 239 |
+
def plan_once(temp):
|
| 240 |
+
text = chat(V.plan_prompt(a.question, vs.name, rows, vs.cols, scope, err), max_tokens=700, temperature=temp)
|
| 241 |
try:
|
| 242 |
+
return V.parse_plan(text, vs.cols)
|
|
|
|
| 243 |
except V.PlanError as e:
|
| 244 |
+
errs.append(str(e))
|
| 245 |
+
print(f"[visualize] plan rejected: {e} | reply: {text[:600]!r}", flush=True)
|
| 246 |
+
return None
|
| 247 |
+
|
| 248 |
+
errs: list = []
|
| 249 |
+
for _ in range(0 if plan else 2):
|
| 250 |
+
plan = V.hedged(plan_once, tries=1)
|
| 251 |
+
if plan:
|
| 252 |
+
break
|
| 253 |
+
err = errs[-1] if errs else ""
|
| 254 |
if not plan:
|
| 255 |
plan = V.fallback_plan(a.question, rows, vs.cols)
|
| 256 |
# new columns, plus earlier model-made columns this chart needs that do not cover these rows yet
|
datainventor/visualize.py
CHANGED
|
@@ -11,7 +11,8 @@ import json
|
|
| 11 |
import math
|
| 12 |
import re
|
| 13 |
import statistics
|
| 14 |
-
|
|
|
|
| 15 |
from typing import Callable, Optional
|
| 16 |
|
| 17 |
ID_NAMES = {"id", "_id", "idx", "index", "row_idx", "uuid", "row_id"}
|
|
@@ -23,6 +24,8 @@ LABEL_BATCH = 8
|
|
| 23 |
MAX_GROUPS = 12 # categories on one axis before the rest fold into "Other"
|
| 24 |
MAX_COLORS = 6
|
| 25 |
KINDS = ("label", "score", "pattern", "count")
|
|
|
|
|
|
|
| 26 |
CHARTS = ("bar", "stack", "box", "hist", "scatter")
|
| 27 |
|
| 28 |
|
|
@@ -404,30 +407,61 @@ def _pl(name: str) -> str:
|
|
| 404 |
return name if name.endswith("s") else name + "s"
|
| 405 |
|
| 406 |
|
| 407 |
-
def
|
|
|
|
| 408 |
cat, ln = _main_cat(cols, rows), _main_len(cols)
|
| 409 |
texts = [k for k, c in cols.items() if c["type"] == "text"]
|
| 410 |
reply = next((k for k in ("completion", "chosen", "response", "output", "answer", "messages", "conversations") if k in texts), None)
|
| 411 |
ask = next((k for k in ("prompt", "instruction", "question", "input", "query") if k in texts), None)
|
| 412 |
other = next((k for k in texts if k not in (reply, ask)), None)
|
| 413 |
-
out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
if reply:
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
if ask:
|
| 417 |
-
|
|
|
|
| 418 |
if not ask and not reply and other:
|
| 419 |
-
|
| 420 |
-
|
|
|
|
|
|
|
| 421 |
if ln and cat:
|
| 422 |
-
|
|
|
|
| 423 |
elif ln:
|
| 424 |
-
|
| 425 |
if reply:
|
| 426 |
-
|
|
|
|
|
|
|
|
|
|
| 427 |
seconds = [k for k, c in cols.items() if c["type"] == "cat" and k != cat and 1 < len({r.get(k) for r in rows}) <= 8]
|
| 428 |
if cat and seconds:
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
|
| 432 |
|
| 433 |
# ------------------------------------------------------------------ the model's part
|
|
@@ -632,6 +666,27 @@ def _read_labels(text: str, d: dict, k: int) -> Optional[dict]:
|
|
| 632 |
return out if len(out) >= max(1, int(0.8 * k)) else None
|
| 633 |
|
| 634 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
def fill(d: dict, rows: list[dict], chat: Callable[..., str], question: str = "",
|
| 636 |
progress: Optional[Callable[[int], None]] = None, workers: int = 6) -> dict:
|
| 637 |
"""Compute one derived column for `rows`. Returns {row id: value} and may set d["labels"]."""
|
|
@@ -645,25 +700,26 @@ def fill(d: dict, rows: list[dict], chat: Callable[..., str], question: str = ""
|
|
| 645 |
progress(len(rows))
|
| 646 |
return vals
|
| 647 |
if d["kind"] == "label" and len(d.get("labels") or []) < 2:
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
batches = [rows[i:i + LABEL_BATCH] for i in range(0, len(rows), LABEL_BATCH)]
|
| 657 |
vals: dict[int, object] = {}
|
| 658 |
done = [0]
|
| 659 |
|
| 660 |
def run(batch):
|
| 661 |
-
got =
|
| 662 |
-
for attempt in range(3):
|
| 663 |
-
text = chat(label_prompt(d, batch), max_tokens=12 * len(batch) + 40, temperature=0.1 if attempt == 0 else 0.4)
|
| 664 |
-
got = _read_labels(text, d, len(batch))
|
| 665 |
-
if got:
|
| 666 |
-
break
|
| 667 |
res = {}
|
| 668 |
for k, r in enumerate(batch, 1):
|
| 669 |
res[r["_id"]] = (got or {}).get(k, "unclear" if d["kind"] == "label" else None)
|
|
|
|
| 11 |
import math
|
| 12 |
import re
|
| 13 |
import statistics
|
| 14 |
+
import time
|
| 15 |
+
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
| 16 |
from typing import Callable, Optional
|
| 17 |
|
| 18 |
ID_NAMES = {"id", "_id", "idx", "index", "row_idx", "uuid", "row_id"}
|
|
|
|
| 24 |
MAX_GROUPS = 12 # categories on one axis before the rest fold into "Other"
|
| 25 |
MAX_COLORS = 6
|
| 26 |
KINDS = ("label", "score", "pattern", "count")
|
| 27 |
+
HEDGE_AFTER = 8.0 # seconds before a slow call gets a twin; the first good answer wins
|
| 28 |
+
_HEDGE = ThreadPoolExecutor(max_workers=24)
|
| 29 |
CHARTS = ("bar", "stack", "box", "hist", "scatter")
|
| 30 |
|
| 31 |
|
|
|
|
| 407 |
return name if name.endswith("s") else name + "s"
|
| 408 |
|
| 409 |
|
| 410 |
+
def presets(rows: list[dict], cols: dict) -> dict[str, dict]:
|
| 411 |
+
"""Starter questions with their plans already written, so clicking one skips the planning call."""
|
| 412 |
cat, ln = _main_cat(cols, rows), _main_len(cols)
|
| 413 |
texts = [k for k, c in cols.items() if c["type"] == "text"]
|
| 414 |
reply = next((k for k in ("completion", "chosen", "response", "output", "answer", "messages", "conversations") if k in texts), None)
|
| 415 |
ask = next((k for k in ("prompt", "instruction", "question", "input", "query") if k in texts), None)
|
| 416 |
other = next((k for k in texts if k not in (reply, ask)), None)
|
| 417 |
+
out: dict[str, dict] = {}
|
| 418 |
+
|
| 419 |
+
def add(q, title, spec, derive=(), ups=()):
|
| 420 |
+
known = dict(cols)
|
| 421 |
+
for d in derive:
|
| 422 |
+
known[d["name"]] = {"type": "num" if d["kind"] in ("score", "count") else "cat"}
|
| 423 |
+
try:
|
| 424 |
+
out[q] = {"title": title, "derive": [dict(d) for d in derive], "chart": check_spec(spec, known), "follow_ups": list(ups)}
|
| 425 |
+
except PlanError:
|
| 426 |
+
pass
|
| 427 |
+
|
| 428 |
if reply:
|
| 429 |
+
tone = {"name": "tone", "kind": "label", "from": reply, "guide": f"the overall tone of the {reply}",
|
| 430 |
+
"labels": ["friendly", "formal", "empathetic", "technical", "blunt"]}
|
| 431 |
+
add(f"What tone do the {_pl(reply)} take" + (f" in each {cat}?" if cat else "?"), f"Tone of the {_pl(reply)}" + (f" by {cat}" if cat else ""),
|
| 432 |
+
{"type": "stack", "x": cat, "color": "tone", "agg": "share"} if cat else {"type": "bar", "x": "tone"}, [tone],
|
| 433 |
+
[f"How long are the {_pl(reply)} for each tone?"] if ln else [])
|
| 434 |
if ask:
|
| 435 |
+
add(f"What do the {_pl(ask)} ask for? Group them into a few themes.", f"What the {_pl(ask)} ask for",
|
| 436 |
+
{"type": "bar", "x": "request"}, [{"name": "request", "kind": "label", "from": ask, "guide": f"what the {ask} asks for", "labels": []}])
|
| 437 |
if not ask and not reply and other:
|
| 438 |
+
add(f"What themes come up in the {_pl(other)}?", f"Themes in the {_pl(other)}", {"type": "bar", "x": "theme"},
|
| 439 |
+
[{"name": "theme", "kind": "label", "from": other, "guide": f"the main theme of the {other}", "labels": []}])
|
| 440 |
+
add(f"How positive or negative is each {other}?", f"Sentiment of the {_pl(other)}", {"type": "hist", "x": "sentiment"},
|
| 441 |
+
[{"name": "sentiment", "kind": "score", "from": other, "guide": "1 = very negative, 3 = neutral or mixed, 5 = very positive"}])
|
| 442 |
if ln and cat:
|
| 443 |
+
add(f"Which {cat} gets the longest {_pl(ln.replace(' words', ''))}?", f"{ln.replace(' words', '').capitalize()} length by {cat}",
|
| 444 |
+
{"type": "box", "x": cat, "y": ln})
|
| 445 |
elif ln:
|
| 446 |
+
add(f"How long are the {_pl(ln.replace(' words', ''))}?", f"{ln.capitalize()}", {"type": "hist", "x": ln})
|
| 447 |
if reply:
|
| 448 |
+
add(f"How many {_pl(reply)} give step-by-step instructions?", f"{_pl(reply).capitalize()} with step-by-step instructions",
|
| 449 |
+
{"type": "bar", "x": "gives steps"},
|
| 450 |
+
[{"name": "gives steps", "kind": "label", "from": reply, "guide": "whether it walks the reader through numbered or ordered steps",
|
| 451 |
+
"labels": ["yes", "no"]}])
|
| 452 |
seconds = [k for k, c in cols.items() if c["type"] == "cat" and k != cat and 1 < len({r.get(k) for r in rows}) <= 8]
|
| 453 |
if cat and seconds:
|
| 454 |
+
add(f"How does {seconds[0]} vary across {cat}?", f"{seconds[0].capitalize()} within each {cat}",
|
| 455 |
+
{"type": "stack", "x": cat, "color": seconds[0], "agg": "share"})
|
| 456 |
+
return dict(list(out.items())[:4])
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def _pl(name: str) -> str:
|
| 460 |
+
return name if name.endswith("s") else name + "s"
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def suggestions(rows: list[dict], cols: dict) -> list[str]:
|
| 464 |
+
return list(presets(rows, cols))
|
| 465 |
|
| 466 |
|
| 467 |
# ------------------------------------------------------------------ the model's part
|
|
|
|
| 666 |
return out if len(out) >= max(1, int(0.8 * k)) else None
|
| 667 |
|
| 668 |
|
| 669 |
+
def hedged(fn: Callable[[float], object], after: float = HEDGE_AFTER, tries: int = 2):
|
| 670 |
+
"""Call fn(temperature) and return its first truthy result. A call still running after `after`
|
| 671 |
+
seconds gets a twin, so one slow provider does not hold up the chart. None if every try fails."""
|
| 672 |
+
for attempt in range(tries):
|
| 673 |
+
futs = {_HEDGE.submit(fn, 0.1 if attempt == 0 else 0.4)}
|
| 674 |
+
start, twin = time.time(), False
|
| 675 |
+
while futs:
|
| 676 |
+
done, futs = wait(futs, timeout=None if twin else max(0.05, after - (time.time() - start)), return_when=FIRST_COMPLETED)
|
| 677 |
+
for f in done:
|
| 678 |
+
try:
|
| 679 |
+
got = f.result()
|
| 680 |
+
except Exception:
|
| 681 |
+
got = None
|
| 682 |
+
if got:
|
| 683 |
+
return got
|
| 684 |
+
if not twin and (time.time() - start >= after or not futs):
|
| 685 |
+
futs = set(futs) | {_HEDGE.submit(fn, 0.5)}
|
| 686 |
+
twin = True
|
| 687 |
+
return None
|
| 688 |
+
|
| 689 |
+
|
| 690 |
def fill(d: dict, rows: list[dict], chat: Callable[..., str], question: str = "",
|
| 691 |
progress: Optional[Callable[[int], None]] = None, workers: int = 6) -> dict:
|
| 692 |
"""Compute one derived column for `rows`. Returns {row id: value} and may set d["labels"]."""
|
|
|
|
| 700 |
progress(len(rows))
|
| 701 |
return vals
|
| 702 |
if d["kind"] == "label" and len(d.get("labels") or []) < 2:
|
| 703 |
+
def discover(temp):
|
| 704 |
+
try:
|
| 705 |
+
obj = first_json(chat(discover_prompt(question, d, rows), max_tokens=200, temperature=temp + 0.2))
|
| 706 |
+
except ValueError:
|
| 707 |
+
return None
|
| 708 |
+
if isinstance(obj, dict): # {"labels": [...]} and similar
|
| 709 |
+
obj = next((v for v in obj.values() if isinstance(v, list)), [])
|
| 710 |
+
labels = [str(x).strip().lower()[:40] for x in obj if isinstance(x, (str, int, float)) and str(x).strip()]
|
| 711 |
+
labels = list(dict.fromkeys(labels))[:7]
|
| 712 |
+
return labels if len(labels) >= 2 else None
|
| 713 |
+
labels = hedged(discover)
|
| 714 |
+
if not labels:
|
| 715 |
+
raise PlanError(f'Could not find labels for "{d["name"]}". Try asking again, or name the groups you expect.')
|
| 716 |
+
d["labels"] = labels
|
| 717 |
batches = [rows[i:i + LABEL_BATCH] for i in range(0, len(rows), LABEL_BATCH)]
|
| 718 |
vals: dict[int, object] = {}
|
| 719 |
done = [0]
|
| 720 |
|
| 721 |
def run(batch):
|
| 722 |
+
got = hedged(lambda temp: _read_labels(chat(label_prompt(d, batch), max_tokens=12 * len(batch) + 40, temperature=temp), d, len(batch)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
res = {}
|
| 724 |
for k, r in enumerate(batch, 1):
|
| 725 |
res[r["_id"]] = (got or {}).get(k, "unclear" if d["kind"] == "label" else None)
|