# ============================================================ # FlipFinder AI — app.py (HF Spaces deployment) # Dan & Rotem · Final Project # ============================================================ # Pipeline: user filters → two-stage recommender (hard filter + # ideal-profile embedding ranking) → Gradient Boosting price # prediction → GenAI summary (Qwen 1.5B) # ============================================================ import spaces # required: this Space runs on ZeroGPU hardware import os import re import warnings warnings.filterwarnings("ignore") import numpy as np import pandas as pd import faiss import torch import gradio as gr from sentence_transformers import SentenceTransformer from transformers import pipeline, GenerationConfig from sklearn.ensemble import GradientBoostingRegressor # ============================================================ # BLOCK 1 — CONFIG # ============================================================ APP_VERSION = "v2-html-tables" # printed at startup so the deployed # build can be identified in the Logs tab print(f"🏠 FlipFinder starting - {APP_VERSION}") USE_HF_REPO = True # True on HF Spaces HF_DATASET_REPO = "rotemvahava/flipfinder-dataset" # dataset repo HF_DATASET_FILE = "flipfinder_final.csv" # the uploaded CSV LOCAL_CSV_PATH = "flipfinder_final.csv" # local fallback for testing EMBEDDINGS_PATH = "embeddings_bge.npy" # uploaded to the Space repo SEED = 42 # ============================================================ # BLOCK 2 — LOAD DATASET (HF dataset repo constraint) # ============================================================ print("⏳ Loading dataset ...") if USE_HF_REPO: from huggingface_hub import hf_hub_download csv_path = hf_hub_download( repo_id=HF_DATASET_REPO, filename=HF_DATASET_FILE, repo_type="dataset", ) df = pd.read_csv(csv_path) else: df = pd.read_csv(LOCAL_CSV_PATH) df = df.reset_index(drop=True) print(f"✅ Dataset loaded: {df.shape[0]:,} properties x {df.shape[1]} columns") # ============================================================ # BLOCK 3 — FEATURE ENGINEERING SAFETY NET # (labeled CSV already contains everything; recompute only if missing — # identical logic to the EDA notebook, vectorized for fast startup) # ============================================================ GRADE_GPA = {"A+": 4.3, "A": 4.0, "A-": 3.7, "B+": 3.3, "B": 3.0, "B-": 2.7} if "investment_label" not in df.columns: print("⏳ Engineered columns missing - recomputing (EDA logic) ...") df["zip3"] = df["zipcode"].astype(str).str[:3] # Comps: 3-level fallback medians (zipcode+bed+type → zipcode+bed → zip3+bed → zip3) g1 = df.groupby(["zipcode", "bedrooms", "property_type"])["price_per_sqft"] g2 = df.groupby(["zipcode", "bedrooms"])["price_per_sqft"] g3 = df.groupby(["zip3", "bedrooms"])["price_per_sqft"] g4 = df.groupby("zip3")["price_per_sqft"] med = np.where(g1.transform("count") >= 5, g1.transform("median"), np.where(g2.transform("count") >= 5, g2.transform("median"), np.where(g3.transform("count") >= 5, g3.transform("median"), g4.transform("median")))) df["local_median_ppsq"] = med df["arv"] = df["local_median_ppsq"] * df["sqft"] r1 = df.groupby(["zipcode", "bedrooms", "property_type"])["rent_estimate"] r2 = df.groupby(["zipcode", "bedrooms"])["rent_estimate"] r3 = df.groupby(["zip3", "bedrooms"])["rent_estimate"] r4 = df.groupby("zip3")["rent_estimate"] df["fair_rent"] = np.where(r1.transform("count") >= 5, r1.transform("median"), np.where(r2.transform("count") >= 5, r2.transform("median"), np.where(r3.transform("count") >= 5, r3.transform("median"), r4.transform("median")))) df["price_vs_market"] = (df["listed_price"] - df["arv"]) / df["arv"] df["gross_yield"] = (df["fair_rent"] * 12) / df["listed_price"] df["cap_rate"] = (df["fair_rent"] * 12 * 0.6) / df["listed_price"] df["price_to_rent_ratio"] = df["listed_price"] / (df["fair_rent"] * 12) df["property_age"] = 2024 - df["year_built"] df["ppsq_deviation"] = (df["price_per_sqft"] - df["local_median_ppsq"]) / df["local_median_ppsq"] * 100 for col, score in [("niche_overall_grade", "niche_overall_score"), ("school_rating", "school_score"), ("crime_safety_rating", "crime_safety_score"), ("housing_rating", "housing_score")]: df[score] = df[col].map(GRADE_GPA) VALID = list(GRADE_GPA.keys()) gidx = {g: i for i, g in enumerate(VALID)} # 0 = best (A+) def _label(row): g = row["niche_overall_grade"] if (row["listed_price"] > row["arv"] or row["gross_yield"] < 0.04 or row["days_on_market"] > 120 or g not in gidx or gidx[g] > gidx["B-"]): return "Bad Investment" if (row["price_vs_market"] <= -0.15 and row["year_built"] < 2020 and row["days_on_market"] <= 120 and row["property_type"] != "Condo"): return "Flip" if (row["price_vs_market"] <= -0.10 and row["gross_yield"] >= 0.08 and row["price_to_rent_ratio"] < 15 and row["property_type"] != "Condo" and g in gidx and gidx[g] <= gidx["B"]): return "BRRRR" good_hood = g in gidx and gidx[g] <= gidx["B"] strong = all(row[c] in gidx and gidx[row[c]] <= gidx["B+"] for c in ["school_rating", "housing_rating", "crime_safety_rating"]) if (0.05 <= row["gross_yield"] <= 0.08 and 20 <= row["days_on_market"] <= 120 and row["year_built"] >= 1990 and good_hood and strong): return "Buy and Hold" return "Uncategorized" df["investment_label"] = df.apply(_label, axis=1) print(f"✅ Investment labels: {df['investment_label'].value_counts().to_dict()}") # ============================================================ # BLOCK 4 — PROPERTY TEXT SERIALIZATION (identical to Part 3) # ============================================================ def property_to_text(row): return ( f"Investment profile: price vs market {row['price_vs_market']*100:.1f}%, " f"gross yield {row['gross_yield']*100:.1f}%, " f"cap rate {row['cap_rate']*100:.1f}%, " f"price to rent ratio {row['price_to_rent_ratio']:.1f}, " f"local price deviation {row['ppsq_deviation']:.1f}%. " f"Property: {int(row['bedrooms'])} bedrooms, {int(row['bathrooms'])} bathrooms, " f"{int(row['sqft']):,} sqft, {int(row['property_age'])} years old, " f"{int(row['days_on_market'])} days on market. " f"Neighborhood: overall score {row['niche_overall_score']:.1f}, " f"school {row['school_score']:.1f}, " f"crime safety {row['crime_safety_score']:.1f}, " f"housing {row['housing_score']:.1f}." ) # ============================================================ # BLOCK 5 — EMBEDDING MODEL (HF model repo constraint) + FAISS # ============================================================ EMBEDDING_MODEL_ID = "BAAI/bge-small-en-v1.5" # Part 3 winner print(f"⏳ Loading embedding model: {EMBEDDING_MODEL_ID} ...") embed_model = SentenceTransformer(EMBEDDING_MODEL_ID) print("✅ Embedding model ready") if os.path.exists(EMBEDDINGS_PATH): embeddings_bge = np.load(EMBEDDINGS_PATH).astype(np.float32) print(f"✅ Embeddings loaded: {embeddings_bge.shape}") else: print("⏳ Embeddings file not found - generating once ...") texts = df.apply(property_to_text, axis=1).tolist() embeddings_bge = embed_model.encode( texts, batch_size=64, show_progress_bar=True, convert_to_numpy=True ).astype(np.float32) np.save(EMBEDDINGS_PATH, embeddings_bge) print(f"✅ Embeddings generated: {embeddings_bge.shape}") # ============================================================ # BLOCK 6 — PRICE PREDICTION MODEL (Gradient Boosting, trained at startup) # ============================================================ print("⏳ Training price prediction model ...") df["log_listed_price"] = np.log1p(df["listed_price"]) df["log_sqft"] = np.log1p(df["sqft"]) PRICE_FEATURES = ["log_sqft", "bedrooms", "bathrooms", "lot_area_acres", "property_age", "days_on_market", "niche_overall_score", "school_score", "crime_safety_score", "housing_score", "fair_rent"] Xp = df[PRICE_FEATURES + ["property_type", "city"]].copy() Xp = pd.get_dummies(Xp, columns=["property_type"], drop_first=True) CITY_MEANS = df.groupby("city")["log_listed_price"].mean() GLOBAL_MEAN = df["log_listed_price"].mean() Xp["city"] = Xp["city"].map(CITY_MEANS).fillna(GLOBAL_MEAN) PRICE_COLUMNS = list(Xp.columns) # locked column order price_model = GradientBoostingRegressor( n_estimators=200, max_depth=4, learning_rate=0.1, random_state=SEED ) price_model.fit(Xp, df["log_listed_price"]) print("✅ Price model trained (Gradient Boosting)") def predict_price(prop_row): """Predict listing price (dollars) for one property row.""" x = {f: prop_row[f] for f in PRICE_FEATURES} x["city"] = CITY_MEANS.get(prop_row["city"], GLOBAL_MEAN) for col in PRICE_COLUMNS: if col.startswith("property_type_"): x[col] = 1 if col == f"property_type_{prop_row['property_type']}" else 0 xdf = pd.DataFrame([x])[PRICE_COLUMNS] return float(np.expm1(price_model.predict(xdf)[0])) # ============================================================ # BLOCK 7 — GENERATION MODEL (two-tier: HF Inference API + local fallback) # ============================================================ API_GEN_MODEL_ID = "Qwen/Qwen2.5-7B-Instruct" # served by the Inference API LOCAL_GEN_MODEL_ID = "Qwen/Qwen2.5-1.5B-Instruct" # small enough to run on this CPU # ── Generation strategy ─────────────────────────────────────── # Tier 1: the HF Inference API. The model executes on HuggingFace's servers, so a # summary takes ~1-2s regardless of this Space's (weak) CPU. The 7B variant # is used because small models like the 1.5B are not served by any provider. # Tier 2: the 1.5B model loaded locally on CPU. Slower (~15s) but always available, # so the app never breaks if the API is unreachable. HF_TOKEN = os.environ.get("HF_TOKEN") _inf_client = None if HF_TOKEN: try: from huggingface_hub import InferenceClient _inf_client = InferenceClient(token=HF_TOKEN) print(f"✅ Generation: HF Inference API ready ({API_GEN_MODEL_ID})") except Exception as e: print(f"⚠️ Inference API unavailable ({e}) - will use local CPU model") else: print("⚠️ HF_TOKEN not set - using local CPU model") print(f"⏳ Loading local fallback model: {LOCAL_GEN_MODEL_ID} ...") generator = pipeline( "text-generation", model=LOCAL_GEN_MODEL_ID, dtype=torch.float32, device="cpu", ) generator.tokenizer.clean_up_tokenization_spaces = False torch.set_num_threads(max(1, os.cpu_count() or 2)) GEN_CONFIG = GenerationConfig( do_sample=False, # greedy: faster on CPU and more disciplined max_new_tokens=110, repetition_penalty=1.05, pad_token_id=generator.tokenizer.eos_token_id, ) print("✅ Local fallback model ready (CPU)") GRADE_ORDER = {"A+": 12, "A": 11, "A-": 10, "B+": 9, "B": 8, "B-": 7, "C+": 6, "C": 5, "C-": 4, "D+": 3, "D": 2, "D-": 1} def _grade_rank(g): return GRADE_ORDER.get(str(g).strip(), 0) def _format_property(p): # Compact single-line format - fewer prompt tokens means faster inference pvm = p["price_vs_market"] pricing = (f"discount to market {abs(pvm):.1f}%" if pvm < 0 else f"premium to market {pvm:.1f}%") return ( f"Property {p['rank']}: {p['city']}, {p['state']} | {p['property_type']} | " f"${p['listed_price']:,} | {pricing} | " f"gross yield {p['gross_yield']}% | cap rate {p['cap_rate']}% | " f"neighborhood grade {p['niche_overall_grade']} | {p['days_on_market']} days on market" ) def _quick_facts(props): ranks = [p["rank"] for p in props] pvm = [p["price_vs_market"] for p in props] yields = [p["gross_yield"] for p in props] grades = [_grade_rank(p["niche_overall_grade"]) for p in props] return ("Pre-computed facts (treat as ground truth):\n" f" - Largest discount to market: Property {ranks[pvm.index(min(pvm))]}\n" f" - Highest gross yield: Property {ranks[yields.index(max(yields))]}\n" f" - Best neighborhood grade: Property {ranks[grades.index(max(grades))]}") def _build_messages(props): blocks = "\n".join(_format_property(p) for p in props) system = ( "You are FlipFinder, an expert real estate investment analysis engine. " "You write short, precise, factual property briefs for investors.\n" "Rules you must follow:\n" "- Neighborhood grades rank best to worst: A+ > A > A- > B+ > B > B- > C+ > C > C-.\n" "- 'Discount to market' is a percentage below comparable sales; larger discount = " "better flip upside.\n" "- 'Gross yield' and 'cap rate' are percentages; higher means better cash flow.\n" "- Only ever use these metric names: discount to market, gross yield, cap rate, " "neighborhood grade, days on market. Never invent other metrics.\n" "- You describe each property's strengths; you never advise buying or rank one " "property above another. No purchase recommendations.\n" "- Write flowing prose. Never use markdown, bold, bullets, headings, or labels." ) user = ( f"Here are three candidate properties.\n\n{blocks}\n\n{_quick_facts(props)}\n\n" "Write exactly three sentences of plain prose - one sentence per property, " "in order (Property 1, then 2, then 3).\n" "Each sentence names that property's one or two strongest points, using only " "the allowed metric names. Stay factual and neutral: describe strengths, but " "never tell the reader to buy, pick, or choose any property, and do not rank " "them against each other.\n\n" "Follow this style exactly:\n" "\"Property 1 stands out for its 22.4% discount to market, paired with a solid B+ " "neighborhood grade. Property 2 offers the strongest cash flow of the group, with a " "9.1% gross yield and a 5.5% cap rate. Property 3 combines an A- neighborhood grade " "with just 30 days on market, suggesting strong local demand.\"\n\n" "Now write your three sentences:" ) return [{"role": "system", "content": system}, {"role": "user", "content": user}] def _clean(text, max_sentences=3): # Strip lead-in filler ("Sure, here's...") text = re.sub(r"^\s*(sure[,!.]?|here('|)s[^:]*:?|certainly[,!.]?)\s*", "", text, flags=re.I) # Strip any "Sentence 1:" / "**Sentence 2:**" style labels the model may echo text = re.sub(r"\*{0,2}sentence\s*\d+\s*:?\*{0,2}\s*", "", text, flags=re.I) # Strip markdown emphasis and headings text = re.sub(r"[*_#`]+", "", text) text = re.sub(r"\s+", " ", text).strip().strip('"') sentences = re.split(r"(?<=[.!?])\s+", text) sentences = [s.strip() for s in sentences if s.strip()] # Drop a trailing sentence that was cut off mid-thought (no end punctuation) if sentences and not sentences[-1].endswith((".", "!", "?")): sentences = sentences[:-1] return " ".join(sentences[:max_sentences]).strip() # ZeroGPU hardware refuses to start a Space unless at least one @spaces.GPU # function is registered. This Space's ZeroGPU worker cannot actually attach a # device (torch.init fails with "No CUDA GPUs are available"), so real generation # runs on CPU. This stub exists solely to satisfy that startup check. @spaces.GPU(duration=1) def _zerogpu_startup_stub(): return "ok" def _generate_local(messages): """Fallback: run the model on this Space's CPU (~15s).""" out = generator(messages, generation_config=GEN_CONFIG, return_full_text=False) raw = out[0]["generated_text"] if isinstance(raw, list): raw = raw[-1]["content"] return raw def generate_investment_summary(top_3): """ Generate the 3-sentence analyst summary. Tries the HF Inference API first (~1-2s); falls back to the local CPU model if the API is unavailable, so the app always produces an analysis. """ messages = _build_messages(top_3) if _inf_client is not None: # Try the preferred model, then known-good alternatives if a provider # does not serve it. for model_id in (API_GEN_MODEL_ID, "meta-llama/Llama-3.1-8B-Instruct", "mistralai/Mistral-7B-Instruct-v0.3"): try: r = _inf_client.chat_completion( messages=messages, model=model_id, max_tokens=110, temperature=0.3, ) cleaned = _clean(r.choices[0].message.content) if cleaned: print(f"[gen] served by Inference API ({model_id})") return cleaned except Exception as e: print(f"[gen] API model {model_id} unavailable ({type(e).__name__}) - trying next") print("[gen] served by local CPU model") return _clean(_generate_local(messages)) # ============================================================ # BLOCK 8 — TWO-STAGE RECOMMENDER WITH FALLBACK (from Part 3 Block 11) # ============================================================ STRATEGY_MAP = { "Fix & Flip (High Discount)": "Flip", "Long-term Rental (High Cash Flow)": "Buy and Hold", "BRRRR Strategy": "BRRRR", } CITIES = set(df["city"].str.lower().unique()) STATES = set(df["state"].str.lower().unique()) def _apply_filters(base, state, city, max_price, property_type, label): """Hard filtering - Stage 1.""" d = base if state: d = d[d["state"].str.lower() == state.strip().lower()] if city: d = d[d["city"].str.lower() == city.strip().lower()] if property_type: d = d[d["property_type"] == property_type] if max_price: d = d[d["listed_price"] <= max_price] if label: d = d[d["investment_label"] == label] else: d = d[~d["investment_label"].isin(["Bad Investment", "Uncategorized"])] return d def build_ideal_property_text(pool): """The 'perfect deal' profile within the pool - Stage 2 query.""" return ( f"Investment profile: price vs market {pool['price_vs_market'].min()*100:.1f}%, " f"gross yield {pool['gross_yield'].max()*100:.1f}%, " f"cap rate {pool['cap_rate'].max()*100:.1f}%, " f"price to rent ratio {pool['price_to_rent_ratio'].min():.1f}, " f"local price deviation {pool['ppsq_deviation'].min():.1f}%. " f"Property: {int(pool['bedrooms'].median())} bedrooms, " f"{pool['bathrooms'].median():.1f} bathrooms, " f"{int(pool['sqft'].median()):,} sqft, " f"{int(pool['property_age'].median())} years old, 30 days on market. " f"Neighborhood: overall score 4.0, school 4.0, crime safety 4.0, housing 4.0." ) def recommend(state, city, max_price, property_type, strategy): """ Two-stage recommendation with graceful fallback. Returns (top_3: list[dict], notice: str). """ # Treat "Any" / empty as no filter if state and str(state).strip().lower() in ("any", ""): state = None if city and str(city).strip().lower() in ("any", ""): city = None if property_type and str(property_type).strip().lower() in ("any", ""): property_type = None if strategy and str(strategy).strip().lower() in ("any", ""): strategy = None if not max_price or float(max_price) <= 0: max_price = None label = STRATEGY_MAP.get(strategy) FALLBACK_SUFFIX = "here are the best 3 options that fit your description:" # Progressive relaxation - first combination with >= 3 results wins attempts = [ (dict(state=state, city=city, max_price=max_price, property_type=property_type, label=label), ""), (dict(state=state, city=city, max_price=(max_price * 1.5 if max_price else None), property_type=property_type, label=label), f"We could not find exact matches within your budget, so we widened it slightly - {FALLBACK_SUFFIX}"), (dict(state=state, city=city, max_price=None, property_type=property_type, label=label), f"We could not find exact matches within your budget in this location - {FALLBACK_SUFFIX}"), (dict(state=state, city=city, max_price=max_price, property_type=None, label=label), f"We could not find exact matches for that property type - {FALLBACK_SUFFIX}"), (dict(state=state, city=None, max_price=max_price, property_type=property_type, label=label), f"We could not find exact matches in that city, so we searched across the state - {FALLBACK_SUFFIX}"), (dict(state=None, city=None, max_price=max_price, property_type=property_type, label=label), f"We could not find exact matches in that location, so we searched other markets - {FALLBACK_SUFFIX}"), (dict(state=None, city=None, max_price=None, property_type=None, label=label), f"We could not find exact matches for your filters - {FALLBACK_SUFFIX}"), (dict(state=None, city=None, max_price=None, property_type=None, label=None), f"We could not find exact matches - {FALLBACK_SUFFIX}"), ] pool, notice = None, "" for filt, msg in attempts: cand = _apply_filters(df, **filt) if len(cand) >= 3: pool, notice = cand, msg break if pool is None: return [], "❌ No properties available." # Stage 2 - mini FAISS on filtered pool, ranked vs ideal profile idxs = pool.index.tolist() emb = embeddings_bge[idxs].copy() faiss.normalize_L2(emb) mini = faiss.IndexFlatIP(emb.shape[1]) mini.add(emb) q = embed_model.encode([build_ideal_property_text(pool)], convert_to_numpy=True).astype(np.float32) faiss.normalize_L2(q) scores, top = mini.search(q, 3) results = [] for rank, (score, i) in enumerate(zip(scores[0], top[0]), start=1): p = pool.iloc[i] results.append({ "rank": rank, "street": p["street"], "city": p["city"], "state": p["state"], "zipcode": str(p["zipcode"]), "property_type": p["property_type"], "investment_label": p["investment_label"], "listed_price": int(p["listed_price"]), "sqft": int(p["sqft"]), "bedrooms": int(p["bedrooms"]), "bathrooms": float(p["bathrooms"]), "year_built": int(p["year_built"]), "gross_yield": round(float(p["gross_yield"]) * 100, 1), "cap_rate": round(float(p["cap_rate"]) * 100, 1), "price_vs_market": round(float(p["price_vs_market"]) * 100, 1), "niche_overall_grade": p["niche_overall_grade"], "days_on_market": int(p["days_on_market"]), "similarity_score": round(float(score), 4), "_row": p, # for price prediction }) return results, notice # ============================================================ # BLOCK 9 — GRADIO WRAPPER (progressive output: table → prices → summary) # ============================================================ TABLE_COLS = ["rank", "street", "city", "state", "sqft", "property_type", "investment_label", "listed_price", "gross_yield", "cap_rate", "niche_overall_grade", "days_on_market"] def _parse_budget(budget): """Accept 'Any', '300,000', '$300000', 300000 -> float or None.""" if budget is None: return None if isinstance(budget, (int, float)): return float(budget) if budget > 0 else None s = str(budget).strip().lower().replace(",", "").replace("$", "") if s in ("", "any"): return None try: v = float(s) # reject nan, inf and absurd magnitudes if v != v or v in (float("inf"), float("-inf")) or v > 1e9: return None return v if v > 0 else None except ValueError: return None def _is_set(v): return v not in (None, "") and str(v).strip().lower() != "any" def _df_to_html(df, title): """ Render a DataFrame as a self-contained horizontally-scrollable HTML table. Gradio's dataframe component cannot be reliably made to scroll sideways on narrow screens, so the results tables are emitted as HTML with the scroll container and styling defined inline - inline styles are not overridden by Gradio's own stylesheet. """ if df is None or len(df) == 0: return "" head = "".join( f'
AI-Powered Real Estate Investment Advisor