Spaces:
Running on Zero
Running on Zero
File size: 12,711 Bytes
4c464e3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 | """Gradio demo for the vineyard plotting-code LoRA adapters.
Pick an adapter from the dropdown, pick one of the 12 synthetic vineyard
DataFrames, ask for a chart. The Space generates the plotting code, then runs it
in a subprocess sandbox and shows you the chart it actually produced -- the same
execution check `pipeline/evaluate.py` scores offline, so a demo can't pass off
plausible-looking code that doesn't run.
Runs on ZeroGPU when the Space has it, and falls back to CPU otherwise.
"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import gradio as gr
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from execute import clean_code_string
from schemas import GENERATORS, df_preview
# ZeroGPU: only the decorated function gets a real GPU. Outside it, PyTorch runs
# in a CUDA emulation mode -- which is why weights are placed on `cuda` in the
# parent process (see _load) and the decorated function only does the forward
# pass. The decorated call inherits the parent's state, so the model cache below
# survives; loading inside the decorator instead would re-download and re-place
# the weights on every single request and burn the daily GPU quota doing it.
#
# On a CPU Space the import fails and everything below runs unchanged on CPU.
def _duration(label, *_args, **_kwargs):
"""Ask for only as much GPU time as the chosen model plausibly needs."""
return 45 if "0.5B" in label else 90
try:
import spaces
GPU = spaces.GPU(duration=_duration)
ZERO_GPU = True
except Exception: # noqa: BLE001
def GPU(fn):
return fn
ZERO_GPU = False
HERE = Path(__file__).resolve().parent
GEN_BY_NAME = {g.__name__: g for g in GENERATORS}
SCHEMAS = sorted(GEN_BY_NAME)
# MUST match pipeline/build_dataset.py SYSTEM_PROMPT exactly. Every training
# example carried this system turn; changing a word moves the prompt off the
# distribution the adapters were tuned on.
SYSTEM_PROMPT = (
"You write Python plotting code for vineyard / viticulture data. "
"A pandas DataFrame `df` is already loaded. Return only a ```python "
"code block using matplotlib or seaborn."
)
# label -> (base repo on the Hub, adapter dir in this repo, blurb for the UI)
MODELS = {
"Qwen2.5-Coder-0.5B · bf16": (
"Qwen/Qwen2.5-Coder-0.5B-Instruct",
"models/qwen2.5-coder-0.5b-plotter-lora",
"Trained in bf16. Best scorecard of the set: 6/10 semantically correct, "
"0/10 invented columns, 10/10 `tight_layout()`. Fastest to load.",
),
"Qwen2.5-Coder-1.5B · bf16 (best checkpoint)": (
"unsloth/Qwen2.5-Coder-1.5B-Instruct",
"models/qwen2.5-coder-1.5b-plotter-lora-bf16-best",
"The checkpoint selected on eval loss rather than the final step. "
"Start here if you want the 1.5B.",
),
"Qwen2.5-Coder-1.5B · bf16 (final step)": (
"unsloth/Qwen2.5-Coder-1.5B-Instruct",
"models/qwen2.5-coder-1.5b-plotter-lora-bf16",
"Same run, saved at the last step instead of the best one.",
),
"Qwen2.5-Coder-1.5B · 4-bit NF4 run": (
"Qwen/Qwen2.5-Coder-1.5B-Instruct",
"models/qwen2.5-coder-1.5b-plotter-lora",
"The original 1.5B, trained under 4-bit NF4 because bf16 would not fit "
"the training card. Overfit at step 186 (eval loss 0.599 vs 0.480 at "
"step 100) — kept for comparison. Served here in bf16.",
),
"Phi-3.5-mini-instruct · 3.8B": (
"microsoft/Phi-3.5-mini-instruct",
"models/phi35-mini-instruct-lora",
"Largest base in the set. Slowest to load on a cold Space.",
),
"LFM2-2.6B": (
"LiquidAI/LFM2-2.6B",
"models/lfm-2.6b-lora",
"Liquid AI's hybrid-conv base — a different architecture family from the "
"others.",
),
}
DEFAULT_MODEL = "Qwen2.5-Coder-0.5B · bf16"
# One model resident at a time: the big bases will not co-exist in Space RAM.
_LOADED: dict = {"key": None, "model": None, "tokenizer": None}
def _load(label: str, use_adapter: bool):
"""Base model + (optionally) the LoRA adapter, cached across calls."""
key = (label, use_adapter)
if _LOADED["key"] == key:
return _LOADED["model"], _LOADED["tokenizer"]
repo, adapter_rel, _ = MODELS[label]
adapter = HERE / adapter_rel
# Evict first -- loading the new weights before freeing the old ones is what
# actually OOMs the box.
_LOADED.update(key=None, model=None, tokenizer=None)
try:
torch.cuda.empty_cache()
except Exception: # noqa: BLE001 -- no-op under ZeroGPU's CUDA emulation
pass
# Prefer the tokenizer saved beside the adapter: it carries the exact chat
# template training used, so prompts here are formatted identically.
tok_src = str(adapter) if (adapter / "tokenizer_config.json").exists() else repo
tokenizer = AutoTokenizer.from_pretrained(tok_src)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
# bf16 on any GPU we can land on; CPU has no usable bf16 matmul path here.
dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
model = AutoModelForCausalLM.from_pretrained(repo, dtype=dtype)
if use_adapter:
from peft import PeftModel
model = PeftModel.from_pretrained(model, str(adapter))
model.to("cuda" if torch.cuda.is_available() else "cpu")
model.eval()
_LOADED.update(key=key, model=model, tokenizer=tokenizer)
return model, tokenizer
@GPU
def _generate(label, preview, request, max_new_tokens, temperature):
"""Forward pass only. _load() must already have run in the parent process."""
model, tokenizer = _LOADED["model"], _LOADED["tokenizer"]
# Same three-part shape build_dataset.py wrote, minus the assistant answer.
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": f"### DataFrame Preview:\n{preview}\n\n"
f"### Visualization Request:\n{request}",
},
]
prompt = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(
**inputs,
max_new_tokens=int(max_new_tokens),
do_sample=temperature > 0,
temperature=temperature if temperature > 0 else None,
pad_token_id=tokenizer.pad_token_id,
)
return tokenizer.decode(
out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
).strip()
def _render(schema: str, code: str, seed: int):
"""Execute the generated code out-of-process; return (ok, error, png path)."""
png = Path(tempfile.mkdtemp(prefix="chart_")) / "chart.png"
payload = json.dumps(
{"schema": schema, "code": code, "seed": int(seed), "png": str(png)}
)
try:
proc = subprocess.run(
[sys.executable, str(HERE / "sandbox_runner.py")],
input=payload,
capture_output=True,
text=True,
timeout=45,
cwd=str(HERE),
env={**os.environ, "MPLBACKEND": "Agg"},
)
except subprocess.TimeoutExpired:
return False, "timed out after 45s (likely an unbounded loop)", None
line = (proc.stdout or "").strip().splitlines()
if not line:
return False, f"sandbox produced no result. stderr: {(proc.stderr or '')[-400:]}", None
try:
res = json.loads(line[-1])
except json.JSONDecodeError:
return False, f"unreadable sandbox output: {line[-1][:300]}", None
return res["ok"], res["error"], res["png"]
def preview_for(schema: str, seed: int) -> str:
return df_preview(GEN_BY_NAME[schema](seed=int(seed)))
def model_blurb(label: str) -> str:
repo, adapter, note = MODELS[label]
return f"**Base:** `{repo}` · **Adapter:** `{adapter}`\n\n{note}"
def run(schema, seed, request, label, use_adapter, max_new_tokens, temperature,
progress=gr.Progress()):
request = (request or "").strip()
if not request:
return "", "Type a visualization request first.", None
preview = preview_for(schema, seed)
progress(0.1, desc=f"Loading {label}…")
try:
_load(label, use_adapter)
except Exception as e: # noqa: BLE001
return "", f"❌ Could not load {label} — {type(e).__name__}: {e}", None
progress(0.4, desc="Generating…")
try:
reply = _generate(label, preview, request, max_new_tokens, temperature)
except Exception as e: # noqa: BLE001
return "", f"❌ Generation failed — {type(e).__name__}: {e}", None
code = clean_code_string(reply) or reply
progress(0.8, desc="Executing in the sandbox…")
ok, err, png = _render(schema, code, seed)
status = "✅ Code executed and produced a chart." if ok else f"❌ Did not run — {err}"
return code, status, png
EXAMPLES = [
["block_vintage_df", "Show average yield per acre by block as a bar chart."],
["barrel_aging_df", "Vanillin concentration vs months in barrel, coloured by barrel type."],
["cellar_ferment_df", "Plot fermentation temperature over time for each tank."],
["irrigation_sensor_df", "Distribution of soil moisture readings by sensor depth."],
["wine_sales_df", "Monthly revenue trend broken down by channel."],
["berry_chem_df", "Relationship between brix and titratable acidity."],
]
CSS = """
.small-note { font-size: 0.85rem; opacity: 0.75; }
"""
with gr.Blocks(title="Vineyard Plotting-Code Models") as demo:
gr.Markdown(
"# 🍇 Vineyard plotting-code models\n"
"Six LoRA adapters fine-tuned to write matplotlib/seaborn code against "
"synthetic vineyard DataFrames. Pick one, pick a DataFrame, ask for a "
"chart. **The generated code is then executed in a sandbox** and the "
"chart below is what it actually produced — not a mock-up."
)
with gr.Row():
with gr.Column(scale=1):
model = gr.Dropdown(
sorted(MODELS), value=DEFAULT_MODEL, label="Model"
)
blurb = gr.Markdown(model_blurb(DEFAULT_MODEL), elem_classes="small-note")
use_adapter = gr.Checkbox(
value=True,
label="Use the fine-tuned adapter",
info="Uncheck to run the untuned base model for comparison.",
)
schema = gr.Dropdown(
SCHEMAS, value="block_vintage_df", label="DataFrame"
)
seed = gr.Number(value=0, precision=0, label="Seed")
request = gr.Textbox(
label="Visualization request",
placeholder="Show average yield per acre by block as a bar chart.",
lines=3,
)
with gr.Accordion("Decoding", open=False):
max_new_tokens = gr.Slider(64, 768, value=512, step=32,
label="Max new tokens")
temperature = gr.Slider(0.0, 1.0, value=0.2, step=0.05,
label="Temperature (0 = greedy)")
go = gr.Button("Generate chart", variant="primary")
with gr.Column(scale=1):
status = gr.Markdown("")
chart = gr.Image(label="Rendered chart", type="filepath", height=380)
code_out = gr.Code(label="Generated code", language="python")
with gr.Accordion("DataFrame the model is shown", open=False):
preview = gr.Textbox(
value=preview_for("block_vintage_df", 0),
label="Preview (exactly the string passed to the model)",
lines=10, max_lines=14,
)
gr.Examples(EXAMPLES, inputs=[schema, request], label="Try one")
gr.Markdown(
f"Running on **{'ZeroGPU' if ZERO_GPU else 'CPU'}**. The first request for "
"a given model downloads its base weights and is slower than the rest.\n\n"
"Generated code runs with restricted builtins (no `os`, `open`, `eval`, "
"`subprocess`) in a separate short-lived process — a proportionate guard "
"against hallucinated code, not a hardened sandbox.",
elem_classes="small-note",
)
model.change(model_blurb, model, blurb)
for comp in (schema, seed):
comp.change(preview_for, [schema, seed], preview)
go.click(
run,
[schema, seed, request, model, use_adapter, max_new_tokens, temperature],
[code_out, status, chart],
)
if __name__ == "__main__":
demo.queue(max_size=12).launch(css=CSS)
|