Kogann commited on
Commit
5c792a0
·
verified ·
1 Parent(s): a99248e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +55 -63
app.py CHANGED
@@ -8,8 +8,9 @@ Data sources, per the assignment constraints:
8
  * Embedding model -> read from the HF MODEL repo (BAAI/bge-small-en-v1.5)
9
  * Embeddings -> stored in THIS Space repo, with a dataset-repo fallback
10
 
11
- Runs on ZeroGPU: the generation model is placed on CUDA at module level and the
12
- single GPU-dependent function is decorated with @spaces.GPU.
 
13
  """
14
 
15
  import os, json, re
@@ -35,6 +36,7 @@ EMB_MODEL_ID = cfg["model_id"]
35
  QUERY_PREFIX = cfg.get("query_prefix") or ""
36
  EMB_FILE = f"stock_embeddings_{cfg['model_short']}.npy"
37
 
 
38
  if os.path.exists(EMB_FILE):
39
  embeddings = np.load(EMB_FILE)
40
  print(f"Embeddings loaded from Space repo: {EMB_FILE}")
@@ -53,21 +55,25 @@ assert len(embeddings) == len(df), "embeddings and metadata are misaligned"
53
  index = faiss.IndexFlatIP(embeddings.shape[1])
54
  index.add(embeddings.astype("float32"))
55
 
56
- # Embedding model from the HF model repo. Kept on CPU: it runs outside the
57
- # @spaces.GPU function and encoding one short query takes milliseconds.
58
  encoder = SentenceTransformer(EMB_MODEL_ID, device="cpu")
59
  print(f"Ready: {index.ntotal:,} stocks | encoder {EMB_MODEL_ID}")
60
 
61
- # ZeroGPU requires models on CUDA at MODULE level.
 
62
  GEN_MODEL_ID = "HuggingFaceTB/SmolLM2-1.7B-Instruct"
63
- gen_tok = AutoTokenizer.from_pretrained(GEN_MODEL_ID)
64
- gen_tok.padding_side = "left"
65
- if gen_tok.pad_token is None:
66
- gen_tok.pad_token = gen_tok.eos_token
67
- gen_model = AutoModelForCausalLM.from_pretrained(
68
- GEN_MODEL_ID, dtype=torch.float16).to("cuda")
69
- gen_model.eval()
70
- print(f"Generation model ready: {GEN_MODEL_ID}")
 
 
 
 
 
71
 
72
  # ---------------------------------------------------------------------------
73
  # QUESTIONNAIRE -> QUERY
@@ -87,8 +93,12 @@ STYLE_TEXT = {
87
  CAP_TEXT = {"Small": "small-cap company", "Medium": "mid-cap company",
88
  "Large": "large-cap company, mega-cap company"}
89
 
 
 
90
  RISK_BANDS = {"Low": (-0.5, 0.85), "Medium": (0.85, 1.30), "High": (1.30, 3.00)}
91
 
 
 
92
  COUNTRY_TO_REGION = {
93
  "USA": "US", "UK": "Europe", "Switzerland": "Europe", "Germany": "Europe",
94
  "France": "Europe", "Europe": "Europe", "Israel": "Israel",
@@ -100,6 +110,7 @@ AMOUNTS = ["$1,000", "$5,000", "$10,000", "$25,000", "$50,000",
100
  "$100,000", "$250,000", "$500,000"]
101
 
102
  def _as_list(x):
 
103
  if x is None:
104
  return []
105
  return list(x) if isinstance(x, (list, tuple)) else [x]
@@ -226,6 +237,7 @@ _DANGLING = {"a","an","the","and","or","but","though","with","for","in","to","of
226
  "its","their","may","can","will","while","as","that","this","from","is"}
227
 
228
  def _tidy(s, max_words=30):
 
229
  s = " ".join(s.strip().split()).split(". ")[0].rstrip(". ")
230
  w = s.split()
231
  if len(w) > max_words:
@@ -236,7 +248,8 @@ def _tidy(s, max_words=30):
236
 
237
  def _stock_prompt(risks, style, r):
238
  """Qualitative descriptors only — the model never sees a raw figure, so it
239
- cannot perform arithmetic on one."""
 
240
  vol = "low" if r["beta"] < 0.85 else ("moderate" if r["beta"] <= 1.30 else "high")
241
  inc = ("no" if r[DIV_COL] == 0 else "low" if r[DIV_COL] < 1.5
242
  else "moderate" if r[DIV_COL] < 3.0 else "high")
@@ -250,43 +263,20 @@ def _stock_prompt(risks, style, r):
250
  f"In one sentence of at most 20 words, explain what that means for this "
251
  f"investor and note one caveat.")
252
 
253
- @spaces.GPU(duration=30)
254
  def _generate_sentences(prompts):
255
- """The only function needing a real GPU, so the only one decorated.
256
-
257
- Errors are caught HERE and returned as data rather than raised. ZeroGPU
258
- executes this in a separate worker process, and an exception crossing that
259
- boundary arrives as a bare RuntimeError with its message stripped — which
260
- makes diagnosis impossible. Returning the traceback preserves it.
261
- """
262
- import traceback
263
- try:
264
- enc = gen_tok(prompts, return_tensors="pt", padding=True).to("cuda")
265
- with torch.no_grad():
266
- out = gen_model.generate(**enc, max_new_tokens=110, do_sample=True,
267
- temperature=0.4, top_p=0.9,
268
- repetition_penalty=1.1,
269
- pad_token_id=gen_tok.pad_token_id)
270
- sents = gen_tok.batch_decode(out[:, enc["input_ids"].shape[1]:],
271
- skip_special_tokens=True)
272
- return {"ok": True, "sentences": sents}
273
- except Exception:
274
- return {"ok": False, "traceback": traceback.format_exc()}
275
-
276
-
277
- def _generate_sentences(prompts):
278
- """Runs on CPU. Errors are returned as data rather than raised so the
279
- recommendation table survives a generation failure."""
280
  import traceback
281
  try:
282
- enc = gen_tok(prompts, return_tensors="pt", padding=True)
 
283
  with torch.no_grad():
284
- out = gen_model.generate(**enc, max_new_tokens=110, do_sample=True,
285
- temperature=0.4, top_p=0.9,
286
- repetition_penalty=1.1,
287
- pad_token_id=gen_tok.pad_token_id)
288
- sents = gen_tok.batch_decode(out[:, enc["input_ids"].shape[1]:],
289
- skip_special_tokens=True)
290
  return {"ok": True, "sentences": sents}
291
  except Exception:
292
  return {"ok": False, "traceback": traceback.format_exc()}
@@ -295,7 +285,8 @@ def generate_rationale(risks, amount, sectors, markets, style, recs):
295
  """One sentence per stock, generated in a single batched pass. All factual
296
  content — tickers and figures — is printed from the DataFrame, so it cannot
297
  be hallucinated."""
298
- prompts = [gen_tok.apply_chat_template(
 
299
  [{"role": "system", "content": STOCK_SYSTEM_PROMPT},
300
  {"role": "user", "content": _stock_prompt(risks, style, r)}],
301
  tokenize=False, add_generation_prompt=True) for _, r in recs.iterrows()]
@@ -327,8 +318,8 @@ def generate_rationale(risks, amount, sectors, markets, style, recs):
327
 
328
  # ---------------------------------------------------------------------------
329
  # RESULTS TABLE FORMATTING
330
- # Database column names are renamed to plain English and numbers are rounded,
331
- # so the table reads as a product rather than a database dump.
332
  # ---------------------------------------------------------------------------
333
  DISPLAY_COLS = ["ticker", "sector", "market_region", "cluster_name", "risk_level",
334
  "beta", DIV_COL, "cagr_10yr_pct", "price_usd", "similarity"]
@@ -348,27 +339,33 @@ COLUMN_LABELS = {
348
 
349
  def _prettify(frame):
350
  out = frame[[c for c in DISPLAY_COLS if c in frame.columns]].copy()
 
 
351
  if "similarity" in out:
352
- out["similarity"] = (out["similarity"] * 100).round(1) # 0.804 -> 80.4
353
- for c in ["beta", DIV_COL, "cagr_10yr_pct"]:
354
  if c in out:
355
- out[c] = out[c].round(2)
356
- if "price_usd" in out:
357
- out["price_usd"] = out["price_usd"].round(2)
358
  return out.rename(columns=COLUMN_LABELS)
359
 
360
  # ---------------------------------------------------------------------------
361
  # GRADIO CALLBACKS
362
  # ---------------------------------------------------------------------------
363
  def run_custom(risks, amount, sectors, markets, style, caps):
 
 
364
  recs, note, _query = recommend(risks, amount, sectors, markets, style, caps)
365
  if recs.empty:
366
- return pd.DataFrame(), note
 
 
 
 
367
  text = generate_rationale(risks, amount, sectors, markets, style, recs)
368
- return _prettify(recs), (note + "\n" + text).strip()
369
 
370
  def run_quickstart(name):
371
- """Served from the pre-generated cache — instant, uses no GPU quota."""
372
  entry = QUICKSTART_CACHE[name]
373
  tbl = pd.DataFrame(entry["table"])
374
  for c in ["beta", DIV_COL, "cagr_10yr_pct", "price_usd", "similarity",
@@ -418,10 +415,6 @@ with gr.Blocks(title="StockMatch AI", theme=gr.themes.Soft()) as demo:
418
  label="6. Company size")
419
  go = gr.Button("🔍 Find my stocks", variant="primary", size="lg")
420
 
421
- # Diagnostic only — remove before submitting.
422
- gpu_btn = gr.Button("🔧 Test GPU", variant="secondary", size="sm")
423
- gpu_out = gr.Markdown()
424
-
425
  gr.Markdown("### Results")
426
  table = gr.Dataframe(label="Your matches", interactive=False, wrap=True)
427
  text = gr.Markdown()
@@ -430,6 +423,5 @@ with gr.Blocks(title="StockMatch AI", theme=gr.themes.Soft()) as demo:
430
  b1.click(lambda: run_quickstart("Cautious Retiree"), None, [table, text])
431
  b2.click(lambda: run_quickstart("Balanced Professional"), None, [table, text])
432
  b3.click(lambda: run_quickstart("Young Growth Seeker"), None, [table, text])
433
- gpu_btn.click(_gpu_selftest, None, gpu_out)
434
 
435
  demo.launch()
 
8
  * Embedding model -> read from the HF MODEL repo (BAAI/bge-small-en-v1.5)
9
  * Embeddings -> stored in THIS Space repo, with a dataset-repo fallback
10
 
11
+ Runs on free-tier CPU. The three Quick Starters are served from a pre-generated
12
+ cache so the common path is instant; the language model is loaded lazily and
13
+ only when a custom questionnaire is submitted.
14
  """
15
 
16
  import os, json, re
 
36
  QUERY_PREFIX = cfg.get("query_prefix") or ""
37
  EMB_FILE = f"stock_embeddings_{cfg['model_short']}.npy"
38
 
39
+ # Prefer the copy stored in this Space repo; fall back to the dataset repo.
40
  if os.path.exists(EMB_FILE):
41
  embeddings = np.load(EMB_FILE)
42
  print(f"Embeddings loaded from Space repo: {EMB_FILE}")
 
55
  index = faiss.IndexFlatIP(embeddings.shape[1])
56
  index.add(embeddings.astype("float32"))
57
 
 
 
58
  encoder = SentenceTransformer(EMB_MODEL_ID, device="cpu")
59
  print(f"Ready: {index.ntotal:,} stocks | encoder {EMB_MODEL_ID}")
60
 
61
+ # The generation model is heavy on CPU, so it is loaded on FIRST USE only.
62
+ # Quick Starters never trigger it — they are served from the cache.
63
  GEN_MODEL_ID = "HuggingFaceTB/SmolLM2-1.7B-Instruct"
64
+ _gen = {}
65
+
66
+ def _load_generator():
67
+ if "model" not in _gen:
68
+ print(f"Lazy-loading {GEN_MODEL_ID}...")
69
+ tok = AutoTokenizer.from_pretrained(GEN_MODEL_ID)
70
+ tok.padding_side = "left" # required for batched generation
71
+ if tok.pad_token is None:
72
+ tok.pad_token = tok.eos_token
73
+ m = AutoModelForCausalLM.from_pretrained(GEN_MODEL_ID, dtype=torch.float32)
74
+ m.eval()
75
+ _gen.update(tok=tok, model=m)
76
+ return _gen["tok"], _gen["model"]
77
 
78
  # ---------------------------------------------------------------------------
79
  # QUESTIONNAIRE -> QUERY
 
93
  CAP_TEXT = {"Small": "small-cap company", "Medium": "mid-cap company",
94
  "Large": "large-cap company, mega-cap company"}
95
 
96
+ # Risk bands apply to BETA rather than the model's own risk_level label, which
97
+ # the EDA showed sometimes contradicts the beta in the same record.
98
  RISK_BANDS = {"Low": (-0.5, 0.85), "Medium": (0.85, 1.30), "High": (1.30, 3.00)}
99
 
100
+ # The dataset covers four regions built from seven exchanges, so country choices
101
+ # map upward: Japan and China -> Asia, Switzerland and the UK -> Europe.
102
  COUNTRY_TO_REGION = {
103
  "USA": "US", "UK": "Europe", "Switzerland": "Europe", "Germany": "Europe",
104
  "France": "Europe", "Europe": "Europe", "Israel": "Israel",
 
110
  "$100,000", "$250,000", "$500,000"]
111
 
112
  def _as_list(x):
113
+ """Gradio multi-select returns a list; tolerate a bare string or None."""
114
  if x is None:
115
  return []
116
  return list(x) if isinstance(x, (list, tuple)) else [x]
 
237
  "its","their","may","can","will","while","as","that","this","from","is"}
238
 
239
  def _tidy(s, max_words=30):
240
+ """Keep the first sentence, cap its length, never end mid-clause."""
241
  s = " ".join(s.strip().split()).split(". ")[0].rstrip(". ")
242
  w = s.split()
243
  if len(w) > max_words:
 
248
 
249
  def _stock_prompt(risks, style, r):
250
  """Qualitative descriptors only — the model never sees a raw figure, so it
251
+ cannot perform arithmetic on one. Given raw numbers during benchmarking, a
252
+ model fabricated 9 figures across 5 runs."""
253
  vol = "low" if r["beta"] < 0.85 else ("moderate" if r["beta"] <= 1.30 else "high")
254
  inc = ("no" if r[DIV_COL] == 0 else "low" if r[DIV_COL] < 1.5
255
  else "moderate" if r[DIV_COL] < 3.0 else "high")
 
263
  f"In one sentence of at most 20 words, explain what that means for this "
264
  f"investor and note one caveat.")
265
 
 
266
  def _generate_sentences(prompts):
267
+ """Runs on CPU. Errors are returned as data rather than raised, so a
268
+ generation failure never takes down the recommendation table."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  import traceback
270
  try:
271
+ tok, model = _load_generator()
272
+ enc = tok(prompts, return_tensors="pt", padding=True)
273
  with torch.no_grad():
274
+ out = model.generate(**enc, max_new_tokens=110, do_sample=True,
275
+ temperature=0.4, top_p=0.9,
276
+ repetition_penalty=1.1,
277
+ pad_token_id=tok.pad_token_id)
278
+ sents = tok.batch_decode(out[:, enc["input_ids"].shape[1]:],
279
+ skip_special_tokens=True)
280
  return {"ok": True, "sentences": sents}
281
  except Exception:
282
  return {"ok": False, "traceback": traceback.format_exc()}
 
285
  """One sentence per stock, generated in a single batched pass. All factual
286
  content — tickers and figures — is printed from the DataFrame, so it cannot
287
  be hallucinated."""
288
+ tok, _ = _load_generator()
289
+ prompts = [tok.apply_chat_template(
290
  [{"role": "system", "content": STOCK_SYSTEM_PROMPT},
291
  {"role": "user", "content": _stock_prompt(risks, style, r)}],
292
  tokenize=False, add_generation_prompt=True) for _, r in recs.iterrows()]
 
318
 
319
  # ---------------------------------------------------------------------------
320
  # RESULTS TABLE FORMATTING
321
+ # Database column names are renamed to plain English and numbers rounded, so the
322
+ # table reads as a product rather than a database dump.
323
  # ---------------------------------------------------------------------------
324
  DISPLAY_COLS = ["ticker", "sector", "market_region", "cluster_name", "risk_level",
325
  "beta", DIV_COL, "cagr_10yr_pct", "price_usd", "similarity"]
 
339
 
340
  def _prettify(frame):
341
  out = frame[[c for c in DISPLAY_COLS if c in frame.columns]].copy()
342
+ # FAISS returns float32; rounding without casting to float64 leaves
343
+ # artefacts such as 75.30000305175781 on screen.
344
  if "similarity" in out:
345
+ out["similarity"] = (out["similarity"].astype("float64") * 100).round(1)
346
+ for c in ["beta", DIV_COL, "cagr_10yr_pct", "price_usd"]:
347
  if c in out:
348
+ out[c] = out[c].astype("float64").round(2)
 
 
349
  return out.rename(columns=COLUMN_LABELS)
350
 
351
  # ---------------------------------------------------------------------------
352
  # GRADIO CALLBACKS
353
  # ---------------------------------------------------------------------------
354
  def run_custom(risks, amount, sectors, markets, style, caps):
355
+ """Yields twice: the table appears immediately, then the explanation, so a
356
+ CPU generation of roughly a minute does not look like a frozen page."""
357
  recs, note, _query = recommend(risks, amount, sectors, markets, style, caps)
358
  if recs.empty:
359
+ yield pd.DataFrame(), note
360
+ return
361
+ pretty = _prettify(recs)
362
+ yield pretty, (note + "\n⏳ Writing your personalised explanation… "
363
+ "(about a minute on free CPU)").strip()
364
  text = generate_rationale(risks, amount, sectors, markets, style, recs)
365
+ yield pretty, (note + "\n" + text).strip()
366
 
367
  def run_quickstart(name):
368
+ """Served from the pre-generated cache — instant, no model load."""
369
  entry = QUICKSTART_CACHE[name]
370
  tbl = pd.DataFrame(entry["table"])
371
  for c in ["beta", DIV_COL, "cagr_10yr_pct", "price_usd", "similarity",
 
415
  label="6. Company size")
416
  go = gr.Button("🔍 Find my stocks", variant="primary", size="lg")
417
 
 
 
 
 
418
  gr.Markdown("### Results")
419
  table = gr.Dataframe(label="Your matches", interactive=False, wrap=True)
420
  text = gr.Markdown()
 
423
  b1.click(lambda: run_quickstart("Cautious Retiree"), None, [table, text])
424
  b2.click(lambda: run_quickstart("Balanced Professional"), None, [table, text])
425
  b3.click(lambda: run_quickstart("Young Growth Seeker"), None, [table, text])
 
426
 
427
  demo.launch()