Kogann commited on
Commit
d681725
·
verified ·
1 Parent(s): e297c71

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +61 -40
app.py CHANGED
@@ -4,13 +4,13 @@ StockMatch AI — Gradio application (Part 5)
4
  QUESTIONNAIRE -> EMBEDDING -> FAISS RETRIEVAL -> FILTERS -> GENERATED RATIONALE
5
 
6
  Data sources, per the assignment constraints:
7
- * Dataset -> read directly from the HF DATASET repo (Kogann/stockmatch-synthetic)
8
- * Embedding model -> read directly 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
- The language model loads LAZILY, only when a custom questionnaire is submitted.
12
- The three Quick Starters are served from a pre-generated cache, so the common
13
- path never pays the model-load cost on free-tier CPU.
14
  """
15
 
16
  import os, json, re
@@ -18,8 +18,11 @@ import numpy as np
18
  import pandas as pd
19
  import gradio as gr
20
  import faiss
 
 
21
  from huggingface_hub import hf_hub_download
22
  from sentence_transformers import SentenceTransformer
 
23
 
24
  DATASET_REPO = "Kogann/stockmatch-synthetic"
25
  DIV_COL = "dividend_yield_10_year_pct"
@@ -29,7 +32,7 @@ DIV_COL = "dividend_yield_10_year_pct"
29
  # ---------------------------------------------------------------------------
30
  print("Loading artifacts...")
31
 
32
- # Config records which model built the index, so the query encoder can never
33
  # drift out of sync with the vectors it searches.
34
  cfg = json.load(open(hf_hub_download(DATASET_REPO, "embedding_config.json",
35
  repo_type="dataset")))
@@ -58,27 +61,32 @@ assert len(embeddings) == len(df), "embeddings and metadata are misaligned"
58
  index = faiss.IndexFlatIP(embeddings.shape[1])
59
  index.add(embeddings.astype("float32"))
60
 
61
- # Embedding model pulled directly from the HF model repo.
62
- encoder = SentenceTransformer(EMB_MODEL_ID)
 
63
  print(f"Ready: {index.ntotal:,} stocks | encoder {EMB_MODEL_ID}")
64
 
65
- # Generation model is heavy on CPU, so it is loaded on first use only.
66
- _gen = {}
67
-
68
- def _load_generator():
69
- if "model" not in _gen:
70
- import torch
71
- from transformers import AutoTokenizer, AutoModelForCausalLM
72
- gid = "HuggingFaceTB/SmolLM2-1.7B-Instruct"
73
- print(f"Lazy-loading {gid}...")
74
- tok = AutoTokenizer.from_pretrained(gid)
75
- tok.padding_side = "left"
76
- if tok.pad_token is None:
77
- tok.pad_token = tok.eos_token
78
- m = AutoModelForCausalLM.from_pretrained(gid, dtype=torch.float32).to("cpu")
79
- m.eval()
80
- _gen.update(tok=tok, model=m, torch=torch)
81
- return _gen["tok"], _gen["model"], _gen["torch"]
 
 
 
 
82
 
83
  # ---------------------------------------------------------------------------
84
  # QUESTIONNAIRE -> QUERY (identical logic to the notebook)
@@ -142,7 +150,8 @@ def recommend(risk_level, investment_amount, sector, target_market,
142
  """
143
  FILTER-THEN-RANK. Explicit constraints are enforced in pandas; semantic
144
  similarity ranks the eligible candidates. Risk is a hard filter because
145
- ablation showed embedding similarity could not separate Low from Medium.
 
146
  """
147
  query = build_query(risk_level, sector, target_market, stock_type, market_cap_pref)
148
  regions = parse_markets(target_market)
@@ -203,6 +212,7 @@ _DANGLING = {"a","an","the","and","or","but","though","with","for","in","to","of
203
  "its","their","may","can","will","while","as","that","this","from","is"}
204
 
205
  def _tidy(s, max_words=30):
 
206
  s = " ".join(s.strip().split()).split(". ")[0].rstrip(". ")
207
  w = s.split()
208
  if len(w) > max_words:
@@ -213,7 +223,8 @@ def _tidy(s, max_words=30):
213
 
214
  def _stock_prompt(risk, style, r):
215
  """Qualitative descriptors only — the model never sees a raw figure, so it
216
- cannot perform arithmetic on one."""
 
217
  vol = "low" if r["beta"] < 0.85 else ("moderate" if r["beta"] <= 1.30 else "high")
218
  inc = ("no" if r[DIV_COL] == 0 else "low" if r[DIV_COL] < 1.5
219
  else "moderate" if r[DIV_COL] < 3.0 else "high")
@@ -225,20 +236,30 @@ def _stock_prompt(risk, style, r):
225
  f"In one sentence of at most 20 words, explain what that means for this "
226
  f"investor and note one caveat.")
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  def generate_rationale(risk, amount, sector, market, style, recs):
229
- tok, model, torch = _load_generator()
230
- prompts = [tok.apply_chat_template(
 
 
231
  [{"role": "system", "content": STOCK_SYSTEM_PROMPT},
232
  {"role": "user", "content": _stock_prompt(risk, style, r)}],
233
  tokenize=False, add_generation_prompt=True) for _, r in recs.iterrows()]
234
 
235
- enc = tok(prompts, return_tensors="pt", padding=True)
236
- with torch.no_grad():
237
- out = model.generate(**enc, max_new_tokens=110, do_sample=True,
238
- temperature=0.4, top_p=0.9, repetition_penalty=1.1,
239
- pad_token_id=tok.pad_token_id)
240
- sents = tok.batch_decode(out[:, enc["input_ids"].shape[1]:],
241
- skip_special_tokens=True)
242
 
243
  lines = [f"Based on your {risk.lower()}-risk profile and ${amount:,.0f} budget, "
244
  f"here are {len(recs)} {sector} stocks from {market} matching your "
@@ -260,19 +281,19 @@ DISPLAY_COLS = ["ticker", "sector", "market_region", "cluster_name", "risk_level
260
  "beta", DIV_COL, "cagr_10yr_pct", "price_usd", "similarity"]
261
 
262
  def run_custom(risk, amount, sector, market, style, cap):
 
263
  recs, note, query = recommend(risk, amount, sector, market, style, cap)
264
  if recs.empty:
265
  yield pd.DataFrame(), note, ""
266
  return
267
  cols = [c for c in DISPLAY_COLS if c in recs.columns]
268
- yield recs[cols], (note + "\n\n⏳ Generating your personalised explanation… "
269
- "(30–90 seconds on free CPU)").strip(), \
270
  f"*Semantic query:* `{query}`"
271
  text = generate_rationale(risk, amount, sector, market, style, recs)
272
  yield recs[cols], text, f"*Semantic query:* `{query}`"
273
 
274
  def run_quickstart(name):
275
- """Served from the pre-generated cache — instant, no model load."""
276
  entry = QUICKSTART_CACHE[name]
277
  tbl = pd.DataFrame(entry["table"])
278
  for c in ["beta", DIV_COL, "cagr_10yr_pct", "price_usd", "similarity",
 
4
  QUESTIONNAIRE -> EMBEDDING -> FAISS RETRIEVAL -> FILTERS -> GENERATED RATIONALE
5
 
6
  Data sources, per the assignment constraints:
7
+ * Dataset -> read from the HF DATASET repo (Kogann/stockmatch-synthetic)
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, so a GPU is
13
+ allocated only for the seconds it is actually needed.
14
  """
15
 
16
  import os, json, re
 
18
  import pandas as pd
19
  import gradio as gr
20
  import faiss
21
+ import spaces
22
+ import torch
23
  from huggingface_hub import hf_hub_download
24
  from sentence_transformers import SentenceTransformer
25
+ from transformers import AutoTokenizer, AutoModelForCausalLM
26
 
27
  DATASET_REPO = "Kogann/stockmatch-synthetic"
28
  DIV_COL = "dividend_yield_10_year_pct"
 
32
  # ---------------------------------------------------------------------------
33
  print("Loading artifacts...")
34
 
35
+ # The config records which model built the index, so the query encoder can never
36
  # drift out of sync with the vectors it searches.
37
  cfg = json.load(open(hf_hub_download(DATASET_REPO, "embedding_config.json",
38
  repo_type="dataset")))
 
61
  index = faiss.IndexFlatIP(embeddings.shape[1])
62
  index.add(embeddings.astype("float32"))
63
 
64
+ # Embedding model pulled from the HF model repo. Kept on CPU: it runs outside
65
+ # the @spaces.GPU function and encoding one short query takes milliseconds.
66
+ encoder = SentenceTransformer(EMB_MODEL_ID, device="cpu")
67
  print(f"Ready: {index.ntotal:,} stocks | encoder {EMB_MODEL_ID}")
68
 
69
+ # ---------------------------------------------------------------------------
70
+ # GENERATION MODEL — placed on CUDA at MODULE level, as ZeroGPU requires.
71
+ # A PyTorch CUDA emulation mode is active outside @spaces.GPU functions, so this
72
+ # works at startup; a real GPU is allocated only inside the decorated function.
73
+ # Lazy loading is explicitly discouraged — CUDA transfers are optimised for
74
+ # placement done during startup.
75
+ #
76
+ # SmolLM2-1.7B was selected on measured evidence: across 5 runs per model it was
77
+ # both the fastest and the only candidate with zero fabricated figures. Qwen-1.5B
78
+ # produced 9 fabrications, including an annual dividend income given as $800 on
79
+ # one run and $9,000 on another where the correct value was $1,050.
80
+ # ---------------------------------------------------------------------------
81
+ GEN_MODEL_ID = "HuggingFaceTB/SmolLM2-1.7B-Instruct"
82
+ gen_tok = AutoTokenizer.from_pretrained(GEN_MODEL_ID)
83
+ gen_tok.padding_side = "left" # required for batched generation
84
+ if gen_tok.pad_token is None:
85
+ gen_tok.pad_token = gen_tok.eos_token
86
+ gen_model = AutoModelForCausalLM.from_pretrained(
87
+ GEN_MODEL_ID, dtype=torch.float16).to("cuda")
88
+ gen_model.eval()
89
+ print(f"Generation model ready: {GEN_MODEL_ID}")
90
 
91
  # ---------------------------------------------------------------------------
92
  # QUESTIONNAIRE -> QUERY (identical logic to the notebook)
 
150
  """
151
  FILTER-THEN-RANK. Explicit constraints are enforced in pandas; semantic
152
  similarity ranks the eligible candidates. Risk is a hard filter because
153
+ ablation showed embedding similarity could not separate Low from Medium
154
+ (beta 1.205 vs 1.200 against a dataset mean of 1.21).
155
  """
156
  query = build_query(risk_level, sector, target_market, stock_type, market_cap_pref)
157
  regions = parse_markets(target_market)
 
212
  "its","their","may","can","will","while","as","that","this","from","is"}
213
 
214
  def _tidy(s, max_words=30):
215
+ """Keep the first sentence, cap its length, never end mid-clause."""
216
  s = " ".join(s.strip().split()).split(". ")[0].rstrip(". ")
217
  w = s.split()
218
  if len(w) > max_words:
 
223
 
224
  def _stock_prompt(risk, style, r):
225
  """Qualitative descriptors only — the model never sees a raw figure, so it
226
+ cannot perform arithmetic on one. This is a structural mitigation: given raw
227
+ numbers, the benchmark model fabricated 9 figures across 5 runs."""
228
  vol = "low" if r["beta"] < 0.85 else ("moderate" if r["beta"] <= 1.30 else "high")
229
  inc = ("no" if r[DIV_COL] == 0 else "low" if r[DIV_COL] < 1.5
230
  else "moderate" if r[DIV_COL] < 3.0 else "high")
 
236
  f"In one sentence of at most 20 words, explain what that means for this "
237
  f"investor and note one caveat.")
238
 
239
+ @spaces.GPU(duration=30)
240
+ def _generate_sentences(prompts):
241
+ """The only function needing a real GPU, so the only one decorated.
242
+ duration=30 is generous for three short generations; shorter declared
243
+ durations receive higher queue priority."""
244
+ enc = gen_tok(prompts, return_tensors="pt", padding=True).to("cuda")
245
+ with torch.no_grad():
246
+ out = gen_model.generate(**enc, max_new_tokens=110, do_sample=True,
247
+ temperature=0.4, top_p=0.9,
248
+ repetition_penalty=1.1,
249
+ pad_token_id=gen_tok.pad_token_id)
250
+ return gen_tok.batch_decode(out[:, enc["input_ids"].shape[1]:],
251
+ skip_special_tokens=True)
252
+
253
  def generate_rationale(risk, amount, sector, market, style, recs):
254
+ """One sentence per stock, generated in a single batched pass. All factual
255
+ content tickers and figures — is printed from the DataFrame, so it cannot
256
+ be hallucinated."""
257
+ prompts = [gen_tok.apply_chat_template(
258
  [{"role": "system", "content": STOCK_SYSTEM_PROMPT},
259
  {"role": "user", "content": _stock_prompt(risk, style, r)}],
260
  tokenize=False, add_generation_prompt=True) for _, r in recs.iterrows()]
261
 
262
+ sents = _generate_sentences(prompts)
 
 
 
 
 
 
263
 
264
  lines = [f"Based on your {risk.lower()}-risk profile and ${amount:,.0f} budget, "
265
  f"here are {len(recs)} {sector} stocks from {market} matching your "
 
281
  "beta", DIV_COL, "cagr_10yr_pct", "price_usd", "similarity"]
282
 
283
  def run_custom(risk, amount, sector, market, style, cap):
284
+ """Yields twice: the table appears immediately, the explanation follows."""
285
  recs, note, query = recommend(risk, amount, sector, market, style, cap)
286
  if recs.empty:
287
  yield pd.DataFrame(), note, ""
288
  return
289
  cols = [c for c in DISPLAY_COLS if c in recs.columns]
290
+ yield recs[cols], (note + "\n\n⏳ Generating your personalised explanation…").strip(), \
 
291
  f"*Semantic query:* `{query}`"
292
  text = generate_rationale(risk, amount, sector, market, style, recs)
293
  yield recs[cols], text, f"*Semantic query:* `{query}`"
294
 
295
  def run_quickstart(name):
296
+ """Served from the pre-generated cache — instant, uses no GPU quota."""
297
  entry = QUICKSTART_CACHE[name]
298
  tbl = pd.DataFrame(entry["table"])
299
  for c in ["beta", DIV_COL, "cagr_10yr_pct", "price_usd", "similarity",