# ============================================================ # REMATCH - HUGGING FACE SPACE APP # Keep generation.py and the assets folder beside this file. # ============================================================ import html import itertools import json import re from functools import lru_cache from pathlib import Path from urllib.parse import quote import gradio as gr import joblib import numpy as np import pandas as pd import sklearn import torch from huggingface_hub import hf_hub_download from sentence_transformers import SentenceTransformer from torch import nn from generation import generate_property_explanation # Put the generated illustrative images in an `assets` folder beside this file. # In a Hugging Face Space, upload the same folder to the repository root. ASSET_DIR = Path("assets") TEXT_CLASSIFIER_PATH = Path( "rematch_text_profile_classifiers.joblib" ) PROPERTY_IMAGES = { "single_family": "illustrative_single_family_home.png", "single_family_home": "illustrative_single_family_home.png", "singlefamily": "illustrative_single_family_home.png", "townhouse": "illustrative_townhouse.png", "condo": "illustrative_condo.png", "multi_family": "illustrative_multi_family.png", "multifamily": "illustrative_multi_family.png", "luxury_home": "illustrative_luxury_home.png", } DEFAULT_PROPERTY_IMAGE = "illustrative_default_property.png" REMATCH_LOGO_URL = "/gradio_api/file=" + quote( str((ASSET_DIR / "rematch_logo.png").resolve()) ) def property_image_url(property_type): """Return a Gradio-served URL for a generic illustrative property image.""" normalized_type = ( str(property_type or "") .strip() .lower() .replace(" ", "_") .replace("-", "_") ) image_name = PROPERTY_IMAGES.get(normalized_type, DEFAULT_PROPERTY_IMAGE) image_path = (ASSET_DIR / image_name).resolve() return "/gradio_api/file=" + quote(str(image_path)) # Part 3 intentionally stays CPU-only. DEVICE = torch.device("cpu") print("Runtime device:", DEVICE) # ============================================================ # LOAD APPROVED MODEL ARTIFACTS # ============================================================ MODEL_REPO_ID = "omershahar/REmatch-DCN-v2" MODEL_CONFIG_PATH = hf_hub_download( repo_id=MODEL_REPO_ID, filename="model_config.json", ) VOCABULARY_PATH = hf_hub_download( repo_id=MODEL_REPO_ID, filename="preprocessor_vocabulary.json", ) CHECKPOINT_PATH = hf_hub_download( repo_id=MODEL_REPO_ID, filename="dcn_v2_checkpoint.pt", ) with open(MODEL_CONFIG_PATH, "r", encoding="utf-8") as file: model_config = json.load(file) with open(VOCABULARY_PATH, "r", encoding="utf-8") as file: vocabulary_payload = json.load(file) I = model_config["investor_cols"] P = model_config["property_cols"] F = model_config["feature_cols"] assert F == I + P assert vocabulary_payload["feature_cols"] == F # ============================================================ # LOAD DATASET B DIRECTLY FROM HUGGING FACE DATASET REPO # ============================================================ DATASET_REPO_ID = "omershahar/REmatch-Investment-Matching-Dataset" DATASET_FILENAME = "rematch_properties.csv" DATASET_REVISION = "c486db5f4a37f32be96f1f986f8d3e06f022bc17" DATASET_B_PATH = hf_hub_download( repo_id=DATASET_REPO_ID, repo_type="dataset", filename=DATASET_FILENAME, revision=DATASET_REVISION, ) B = pd.read_csv(DATASET_B_PATH) required_property_columns = set( P + [ "id", "formattedAddress", "city_rentcast", "state", "propertyType", "price", "monthly_rent", "gross_rental_yield_percent", "value_forecast_12months", "price_volatility_percent", "property_description", ] ) missing_property_columns = required_property_columns - set(B.columns) assert not missing_property_columns, ( "Dataset B is missing required columns: " + str(sorted(missing_property_columns)) ) for column in P: B[column] = ( B[column] .fillna("__MISSING__") .astype(str) .str.strip() ) print("Loaded Dataset B from Hugging Face") print("Property inventory rows:", len(B)) # ============================================================ # LOAD SAVED DCN-v2 PROPERTY EMBEDDINGS FROM THE SPACE # ============================================================ EMBEDDINGS_PATH = Path( "rematch_dataset_b_dcn_v2_embeddings.parquet" ) if not EMBEDDINGS_PATH.exists(): raise FileNotFoundError( "Missing rematch_dataset_b_dcn_v2_embeddings.parquet. " "Upload it to the root of this Hugging Face Space." ) EMBEDDINGS = pd.read_parquet(EMBEDDINGS_PATH) EMBEDDING_COLS = sorted( [ column for column in EMBEDDINGS.columns if column.startswith("dcn_v2_property_embedding_") ], key=lambda column: int(column.rsplit("_", 1)[1]), ) assert len(EMBEDDING_COLS) == 60, ( f"Expected 60 DCN-v2 embedding columns, found {len(EMBEDDING_COLS)}." ) assert "id" in EMBEDDINGS.columns, ( "The embedding Parquet file must contain an id column." ) # IDs are the bridge between the vector index and Dataset B. B["id"] = B["id"].astype(str) EMBEDDINGS["id"] = EMBEDDINGS["id"].astype(str) EMBEDDINGS = EMBEDDINGS[ ["id"] + EMBEDDING_COLS ].drop_duplicates("id") missing_embedding_ids = set(B["id"]) - set(EMBEDDINGS["id"]) assert not missing_embedding_ids, ( "Some Dataset B properties have no saved embedding. " f"Missing count: {len(missing_embedding_ids)}" ) print("Loaded DCN-v2 property embedding index from Parquet") print("Embedding rows:", len(EMBEDDINGS)) print("Embedding dimensions:", len(EMBEDDING_COLS)) # ============================================================ # LOAD DATASET A FOR REPRESENTATIVE INVESTOR DESCRIPTIONS # ============================================================ DATASET_A_PATH = hf_hub_download( repo_id=DATASET_REPO_ID, repo_type="dataset", filename="rematch_investor_profiles.csv", revision=DATASET_REVISION, ) A = pd.read_csv(DATASET_A_PATH) required_investor_columns = { "investor_id", "budget_level", "max_budget_usd", "financing_willingness", "liquidity_importance", "risk_profile", "primary_goal", "investor_description", } missing_investor_columns = required_investor_columns - set(A.columns) assert not missing_investor_columns, ( "Dataset A is missing required columns: " + str(sorted(missing_investor_columns)) ) for column in [ "budget_level", "financing_willingness", "liquidity_importance", "risk_profile", "primary_goal", ]: A[column] = A[column].astype(str).str.strip() A["max_budget_usd"] = pd.to_numeric( A["max_budget_usd"], errors="coerce", ) print("Loaded Dataset A from Hugging Face") print("Investor profile rows:", len(A)) # ============================================================ # LOAD THE TRAINED TEXT-TO-PROFILE CLASSIFIERS # ============================================================ if not TEXT_CLASSIFIER_PATH.exists(): raise FileNotFoundError( "Missing rematch_text_profile_classifiers.joblib. " "Upload it to the root of this Hugging Face Space." ) text_bundle = joblib.load(TEXT_CLASSIFIER_PATH) required_text_artifacts = { "classifiers", "embedding_model", "embedding_dimension", "sklearn_version", } missing_text_artifacts = ( required_text_artifacts - set(text_bundle) ) if missing_text_artifacts: raise ValueError( "Text classifier artifact is missing: " f"{sorted(missing_text_artifacts)}" ) trained_sklearn_version = str( text_bundle["sklearn_version"] ) if sklearn.__version__ != trained_sklearn_version: raise RuntimeError( "The text classifiers were trained with scikit-learn " f"{trained_sklearn_version}, but the Space loaded " f"{sklearn.__version__}. Pin the training version in " "requirements.txt." ) TEXT_CLASSIFIERS = text_bundle["classifiers"] missing_profile_classifiers = set(I) - set(TEXT_CLASSIFIERS) if missing_profile_classifiers: raise ValueError( "Text classifier artifact is missing profile fields: " f"{sorted(missing_profile_classifiers)}" ) # Text encoding is deliberately CPU-only. ZeroGPU is reserved for Qwen, # while one MPNet sentence is fast enough on CPU and avoids GPU hand-offs. TEXT_ENCODER = SentenceTransformer( text_bundle["embedding_model"], revision=text_bundle.get("embedding_model_commit"), device="cpu", ) if ( TEXT_ENCODER.get_sentence_embedding_dimension() != int(text_bundle["embedding_dimension"]) ): raise ValueError( "Text encoder dimension does not match the classifier artifact." ) print( "Loaded text profile classifiers:", text_bundle["embedding_model"], ) # ============================================================ # APPROVED DCN-v2 ARCHITECTURE # ============================================================ class Prep: def __init__(self, vocabularies): self.v = vocabularies def transform(self, dataframe): encoded = np.zeros( (len(dataframe), len(F)), dtype=np.int64, ) for index, column in enumerate(F): encoded[:, index] = ( dataframe[column] .astype(str) .map(self.v[column]) .fillna(0) .astype(int) ) return encoded @property def sizes(self): return [len(self.v[column]) for column in F] class Emb(nn.Module): def __init__(self, sizes, dim=12): super().__init__() self.t = nn.ModuleList( [nn.Embedding(size, dim) for size in sizes] ) def forward(self, inputs): return torch.stack( [ embedding(inputs[:, index]) for index, embedding in enumerate(self.t) ], dim=1, ) class Cross(nn.Module): def __init__(self, input_size): super().__init__() self.w = nn.Parameter(torch.randn(input_size) * 0.01) self.b = nn.Parameter(torch.zeros(input_size)) def forward(self, initial_input, current_input): return ( initial_input * (current_input * self.w).sum(1, keepdim=True) + self.b + current_input ) class DCNv2(nn.Module): def __init__(self, sizes, dim=12): super().__init__() self.e = Emb(sizes, dim) flattened_size = len(sizes) * dim self.c = nn.ModuleList( [ Cross(flattened_size), Cross(flattened_size), ] ) self.d = nn.Sequential( nn.Linear(flattened_size, 96), nn.ReLU(), nn.Dropout(0.15), nn.Linear(96, 48), nn.ReLU(), ) self.o = nn.Linear(flattened_size + 48, 1) def forward(self, inputs): initial_input = self.e(inputs).flatten(1) crossed_input = initial_input for cross_layer in self.c: crossed_input = cross_layer( initial_input, crossed_input, ) return self.o( torch.cat( [ crossed_input, self.d(initial_input), ], dim=1, ) ).squeeze(1) # ============================================================ # LOAD TRAINED MODEL # ============================================================ prep = Prep(vocabulary_payload["vocabularies"]) embedding_dimension = int(model_config["embedding_dim"]) model = DCNv2( sizes=prep.sizes, dim=embedding_dimension, ).to(DEVICE) checkpoint = torch.load( CHECKPOINT_PATH, map_location=DEVICE, weights_only=True, ) assert checkpoint["model_name"] == "DCN-v2" assert checkpoint["feature_cols"] == F assert checkpoint["investor_cols"] == I assert checkpoint["property_cols"] == P model.load_state_dict(checkpoint["state_dict"]) model.eval() print("Loaded trained model: DCN-v2") # ============================================================ # PART 3 - RECOMMENDATION ENGINE # ============================================================ RECOMMENDATION_FIELDS = [ "id", "formattedAddress", "city_rentcast", "state", "propertyType", "price", "monthly_rent", "gross_rental_yield_percent", "value_forecast_12months", "price_volatility_percent", "property_description", ] def predict(model, encoded_features, batch_size=8192): model.eval() outputs = [] with torch.inference_mode(): for start in range(0, len(encoded_features), batch_size): batch = torch.tensor( encoded_features[start:start + batch_size], dtype=torch.long, device=DEVICE, ) prediction = torch.sigmoid(model(batch)) outputs.append(prediction.cpu().numpy()) return np.concatenate(outputs) def percentile_rank(series, ascending=True): ranks = ( pd.to_numeric(series, errors="coerce") .rank(pct=True) .fillna(0.5) ) return ranks if ascending else 1 - ranks def cosine_similarity(vector_a, vector_b): """Cosine similarity for two already numeric property vectors.""" denominator = np.linalg.norm(vector_a) * np.linalg.norm(vector_b) if denominator == 0: return 0.0 return float(np.dot(vector_a, vector_b) / denominator) def select_diverse_top_3(ranked_candidates, diversity_weight=0.015): """ Select three strong recommendations while using the saved DCN-v2 property embeddings to avoid returning three almost identical assets. DCN-v2 fit score remains the main ranking signal. The embedding similarity penalty is intentionally small. """ # Greedy selection preserves fit while applying a small similarity penalty. chosen = [] chosen_vectors = [] remaining = ranked_candidates.head(100).copy() while len(chosen) < 3 and not remaining.empty: selection_scores = [] for index, row in remaining.iterrows(): candidate_vector = row[EMBEDDING_COLS].to_numpy( dtype=np.float32 ) if chosen_vectors: max_similarity = max( cosine_similarity( candidate_vector, selected_vector, ) for selected_vector in chosen_vectors ) else: max_similarity = 0.0 selection_score = ( float(row["final_score"]) - diversity_weight * max_similarity ) selection_scores.append( (index, selection_score, max_similarity) ) best_index, best_selection_score, best_similarity = max( selection_scores, key=lambda item: item[1], ) selected_row = remaining.loc[best_index].copy() selected_row["embedding_similarity_penalty"] = best_similarity selected_row["selection_score"] = best_selection_score chosen.append(selected_row) chosen_vectors.append( selected_row[EMBEDDING_COLS].to_numpy( dtype=np.float32 ) ) remaining = remaining.drop(index=best_index) return pd.DataFrame(chosen) def recommend_top_3( budget_level, max_budget_usd, financing_willingness, liquidity_importance, risk_profile, primary_goal, enforce_budget=True, ): answers = dict( zip( I, [ budget_level, financing_willingness, liquidity_importance, risk_profile, primary_goal, ], ) ) for column, value in answers.items(): if value not in prep.v[column]: raise ValueError(f"Unsupported {column}: {value}") max_budget_usd = float(max_budget_usd) if not np.isfinite(max_budget_usd) or max_budget_usd <= 0: raise ValueError( "max_budget_usd must be greater than zero." ) # Dataset B remains the source of truth for every property. candidates = B.copy() candidates["price"] = pd.to_numeric( candidates["price"], errors="coerce", ) # Strict eligibility rules run before any recommendation scoring. candidates = candidates[ candidates["price"].notna() & (candidates["price"] > 0) ] if enforce_budget: candidates = candidates[ candidates["price"] <= max_budget_usd ] if risk_profile == "conservative": candidates = candidates[ (candidates["volatility_band"] != "high_volatility") & (candidates["forecast_band"] != "negative_forecast") ] if candidates.empty: return pd.DataFrame( columns=RECOMMENDATION_FIELDS + [ "predicted_match_fit", "final_score", "selection_score", ] ) # Join each live Dataset B property to its saved DCN-v2 embedding. # Only the embedding vectors come from the Parquet file. candidates = candidates.merge( EMBEDDINGS, on="id", how="inner", validate="one_to_one", ) if candidates.empty: raise RuntimeError( "No eligible Dataset B properties could be matched " "to the saved embedding index." ) # DCN-v2 computes personalized match quality from the five answers. model_features = candidates[P].copy() for column, value in answers.items(): model_features[column] = value candidates["predicted_match_fit"] = predict( model, prep.transform(model_features[F]), ) # Deterministic property-quality tie-breaker. tie_breaker = ( 0.35 * percentile_rank( candidates["gross_rental_yield_percent"] ) + 0.30 * percentile_rank( candidates["value_forecast_12months"] ) + 0.20 * percentile_rank( candidates["price_volatility_percent"], ascending=False, ) ) if enforce_budget: tie_breaker += ( 0.15 * ( 1 - candidates["price"] / max_budget_usd ).clip(0, 1) ) else: # No budget headroom exists in fallback mode. # Rescale the remaining 85% of the tie-breaker to 100%. tie_breaker = tie_breaker / 0.85 candidates["final_score"] = ( 0.85 * candidates["predicted_match_fit"] + 0.15 * tie_breaker ) ranked_candidates = candidates.sort_values( [ "final_score", "predicted_match_fit", "price", "id", ], ascending=[ False, False, True, True, ], kind="mergesort", ) # The saved Parquet vectors now actively influence the final three: # selected results remain high-fit, but are less repetitive. result = select_diverse_top_3(ranked_candidates) assert len(result) <= 3 if enforce_budget: assert (result["price"] <= max_budget_usd).all(), ( "Exact budget guard failed" ) if risk_profile == "conservative": assert not ( result["volatility_band"] == "high_volatility" ).any(), "Conservative volatility guard failed" assert not ( result["forecast_band"] == "negative_forecast" ).any(), "Conservative forecast guard failed" return result[ RECOMMENDATION_FIELDS + [ "predicted_match_fit", "final_score", "selection_score", ] ] # ============================================================ # PART 5A - REGRESSION TEST # ============================================================ regression_results = recommend_top_3( budget_level="medium", max_budget_usd=450000, financing_willingness="no", liquidity_importance="medium", risk_profile="conservative", primary_goal="income", ).reset_index(drop=True) expected_address_starts = [ "435 Canberra Dr", "3508 Dance Ave", "400 Dublin Dr", ] assert len(regression_results) == 3 for actual, expected in zip( regression_results["formattedAddress"].astype(str).tolist(), expected_address_starts, ): assert actual.startswith(expected), ( f"Expected {expected}, got {actual}" ) assert (regression_results["price"] <= 450000).all() regression_details = regression_results.merge( B[ [ "id", "volatility_band", "forecast_band", ] ], on="id", how="left", ) assert not ( regression_details["volatility_band"] == "high_volatility" ).any() assert not ( regression_details["forecast_band"] == "negative_forecast" ).any() print("Part 5A regression test passed.") # ============================================================ # FREE-TEXT PROFILE INFERENCE AND FAST EXPECTED DCN SCORING # ============================================================ PROFILE_COMBINATIONS = [ tuple(str(value) for value in combination) for combination in itertools.product( *[ TEXT_CLASSIFIERS[field].classes_ for field in I ] ) ] for field_index, field in enumerate(I): unsupported_labels = { combination[field_index] for combination in PROFILE_COMBINATIONS } - set(prep.v[field]) if unsupported_labels: raise ValueError( f"Unsupported classifier labels for {field}: " f"{sorted(unsupported_labels)}" ) B = B.reset_index(drop=True) B["price"] = pd.to_numeric(B["price"], errors="coerce") B["_property_row"] = np.arange(len(B), dtype=np.int32) # Join property metadata and embeddings once. Requests then use cheap row masks # instead of repeatedly copying and merging the complete inventory. PROPERTY_INDEX = B.merge( EMBEDDINGS, on="id", how="inner", validate="one_to_one", sort=False, ) if len(PROPERTY_INDEX) != len(B): raise RuntimeError( "The property embedding index is not aligned with Dataset B." ) PROPERTY_INDEX = PROPERTY_INDEX.sort_values( "_property_row", kind="mergesort", ).reset_index(drop=True) PROPERTY_EMBEDDING_MATRIX = PROPERTY_INDEX[ EMBEDDING_COLS ].to_numpy(dtype=np.float32) embedding_norms = np.linalg.norm( PROPERTY_EMBEDDING_MATRIX, axis=1, keepdims=True, ) PROPERTY_EMBEDDING_MATRIX = np.divide( PROPERTY_EMBEDDING_MATRIX, embedding_norms, out=np.zeros_like(PROPERTY_EMBEDDING_MATRIX), where=embedding_norms > 0, ) def _encode_known_categories(dataframe, columns): """Encode validated categorical columns without constructing F-sized frames.""" encoded_columns = [] for column in columns: encoded = dataframe[column].astype(str).map(prep.v[column]) if encoded.isna().any(): unknown = sorted( dataframe.loc[encoded.isna(), column] .astype(str) .unique() .tolist() ) raise ValueError( f"Unknown values in {column}: {unknown[:5]}" ) encoded_columns.append(encoded.to_numpy(dtype=np.int64)) return np.column_stack(encoded_columns) def _precompute_profile_scores(profile_batch_size=16): """ Precompute exact DCN scores for every profile/property pair. The resulting float32 matrix is only about 10 MB. A request can therefore integrate the classifier's complete probability distribution with one matrix multiplication instead of running DCN inference interactively. """ investor_encoded = np.asarray( [ [ prep.v[field][value] for field, value in zip(I, combination) ] for combination in PROFILE_COMBINATIONS ], dtype=np.int64, ) property_encoded = _encode_known_categories(B, P) score_matrix = np.empty( (len(PROFILE_COMBINATIONS), len(B)), dtype=np.float32, ) for start in range( 0, len(PROFILE_COMBINATIONS), profile_batch_size, ): stop = min( start + profile_batch_size, len(PROFILE_COMBINATIONS), ) profile_block = investor_encoded[start:stop] block_size = len(profile_block) encoded_features = np.concatenate( [ np.repeat(profile_block, len(B), axis=0), np.tile(property_encoded, (block_size, 1)), ], axis=1, ) score_matrix[start:stop] = predict( model, encoded_features, ).reshape(block_size, len(B)) return score_matrix print( "Precomputing expected-score index for", len(PROFILE_COMBINATIONS), "investor profiles...", ) PROFILE_SCORE_MATRIX = _precompute_profile_scores() print( "Expected-score index ready:", PROFILE_SCORE_MATRIX.shape, ) MONEY_PATTERN = re.compile( r"(?i)(?:\$\s*|usd\s*)" r"([0-9][0-9,]*(?:\.[0-9]+)?)\s*" r"(k|m|million|thousand)?\b" ) BUDGET_PATTERN = re.compile( r"(?i)(?:budget|up\s+to|maximum|max|spend|afford|" r"purchase\s+price)[^0-9$]{0,30}\$?\s*" r"([0-9][0-9,]*(?:\.[0-9]+)?)\s*" r"(k|m|million|thousand)?\b" ) TRAILING_MONEY_PATTERN = re.compile( r"(?i)\b([0-9][0-9,]*(?:\.[0-9]+)?)\s*" r"(k|m|million|thousand)?\s*(?:usd|dollars?)\b" ) def _money_value(number, suffix): value = float(str(number).replace(",", "")) suffix = str(suffix or "").lower() if suffix in {"k", "thousand"}: value *= 1_000 elif suffix in {"m", "million"}: value *= 1_000_000 return value def extract_budget(description): """Return a positive USD amount from explicit money/budget language.""" for pattern in ( MONEY_PATTERN, BUDGET_PATTERN, TRAILING_MONEY_PATTERN, ): match = pattern.search(str(description or "")) if match: value = _money_value( match.group(1), match.group(2), ) if np.isfinite(value) and value > 0: return value return None def _normalize_description(description): raw_text = str(description or "").strip() if not raw_text: raise ValueError( "Please describe yourself as an investor first." ) if len(raw_text.splitlines()) > 3: raise ValueError( "Please keep the description to three lines or fewer." ) return " ".join(raw_text.split()) @lru_cache(maxsize=256) def _classify_description(normalized_description): """Cache immutable probability outputs for repeated examples/requests.""" vector = TEXT_ENCODER.encode( [normalized_description], convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False, ) outputs = [] for field in I: # Exact purchase price is authoritative for budget level, so the # budget classifier is not evaluated during live inference. if field == "budget_level": continue classifier = TEXT_CLASSIFIERS[field] probabilities = classifier.predict_proba(vector)[0] outputs.append( ( field, tuple(str(label) for label in classifier.classes_), tuple(float(value) for value in probabilities), ) ) return tuple(outputs) def infer_profile(description, manual_budget=None): """Infer field distributions and produce DCN profile weights.""" normalized_description = _normalize_description(description) classifier_outputs = _classify_description( normalized_description ) distributions = { field: dict(zip(labels, probabilities)) for field, labels, probabilities in classifier_outputs } profile = { field: max(distribution, key=distribution.get) for field, distribution in distributions.items() } budget = extract_budget(normalized_description) if budget is None and manual_budget not in (None, ""): budget = float(manual_budget) if budget is None: return None, profile, None if not np.isfinite(budget) or budget <= 0: raise ValueError( "The maximum property price must be greater than zero." ) budget_level = derive_budget_level(budget) distributions["budget_level"] = { str(label): float(str(label) == budget_level) for label in TEXT_CLASSIFIERS[ "budget_level" ].classes_ } profile["budget_level"] = budget_level weights = np.ones( len(PROFILE_COMBINATIONS), dtype=np.float64, ) for field_index, field in enumerate(I): distribution = distributions[field] weights *= np.fromiter( ( distribution.get( combination[field_index], 0.0, ) for combination in PROFILE_COMBINATIONS ), dtype=np.float64, count=len(PROFILE_COMBINATIONS), ) weight_sum = weights.sum() if not np.isfinite(weights).all() or weight_sum <= 0: raise RuntimeError( "The inferred profile probabilities are invalid." ) return ( float(budget), profile, (weights / weight_sum).astype(np.float32), ) def _select_diverse_expected_top_3( ranked_candidates, diversity_weight=0.015, ): """Vectorized greedy diversity selection from the 100 best fits.""" pool = ranked_candidates.head(100).reset_index(drop=True) remaining = np.arange(len(pool), dtype=np.int32) selected_pool_rows = [] selected_records = [] while len(selected_records) < 3 and len(remaining): property_rows = pool.iloc[remaining][ "_property_row" ].to_numpy(dtype=np.int64) if selected_pool_rows: selected_property_rows = pool.iloc[ selected_pool_rows ]["_property_row"].to_numpy(dtype=np.int64) similarities = ( PROPERTY_EMBEDDING_MATRIX[property_rows] @ PROPERTY_EMBEDDING_MATRIX[ selected_property_rows ].T ) max_similarities = similarities.max(axis=1) else: max_similarities = np.zeros( len(remaining), dtype=np.float32, ) selection_scores = ( pool.iloc[remaining]["final_score"].to_numpy( dtype=np.float64 ) - diversity_weight * max_similarities ) best_remaining_position = int( np.argmax(selection_scores) ) best_pool_row = int( remaining[best_remaining_position] ) selected_row = pool.iloc[best_pool_row].copy() selected_row["embedding_similarity_penalty"] = float( max_similarities[best_remaining_position] ) selected_row["selection_score"] = float( selection_scores[best_remaining_position] ) selected_records.append(selected_row) selected_pool_rows.append(best_pool_row) remaining = np.delete( remaining, best_remaining_position, ) return pd.DataFrame(selected_records) def recommend_from_distribution( profile_weights, max_budget_usd, profile, enforce_budget=True, ): """Rank properties using the classifier's full profile distribution.""" budget = float(max_budget_usd) valid_price = ( PROPERTY_INDEX["price"].notna() & (PROPERTY_INDEX["price"] > 0) ) eligible = valid_price.copy() if enforce_budget: eligible &= PROPERTY_INDEX["price"] <= budget if profile["risk_profile"] == "conservative": eligible &= ( PROPERTY_INDEX["volatility_band"] != "high_volatility" ) eligible &= ( PROPERTY_INDEX["forecast_band"] != "negative_forecast" ) property_rows = np.flatnonzero( eligible.to_numpy(dtype=bool) ) if not len(property_rows): return pd.DataFrame() candidates = PROPERTY_INDEX.iloc[property_rows].copy() candidates["predicted_match_fit"] = ( profile_weights @ PROFILE_SCORE_MATRIX[:, property_rows] ) tie_breaker = ( 0.35 * percentile_rank( candidates["gross_rental_yield_percent"] ) + 0.30 * percentile_rank( candidates["value_forecast_12months"] ) + 0.20 * percentile_rank( candidates["price_volatility_percent"], ascending=False, ) ) if enforce_budget: tie_breaker += 0.15 * ( 1 - candidates["price"] / budget ).clip(0, 1) else: tie_breaker /= 0.85 candidates["final_score"] = ( 0.85 * candidates["predicted_match_fit"] + 0.15 * tie_breaker ) ranked = candidates.sort_values( [ "final_score", "predicted_match_fit", "price", "id", ], ascending=[False, False, True, True], kind="mergesort", ) result = _select_diverse_expected_top_3(ranked) if enforce_budget and not result.empty: assert (result["price"] <= budget).all(), ( "Exact budget guard failed" ) if profile["risk_profile"] == "conservative": assert not ( result["volatility_band"] == "high_volatility" ).any(), "Conservative volatility guard failed" assert not ( result["forecast_band"] == "negative_forecast" ).any(), "Conservative forecast guard failed" return result[ RECOMMENDATION_FIELDS + [ "predicted_match_fit", "final_score", "selection_score", ] ] # ============================================================ # PART 4 - GROUNDED EXPLANATIONS # ============================================================ def derive_budget_level(max_budget_usd): amount = float(max_budget_usd) if amount < 150000: return "low" if amount < 300000: return "lower_mid" if amount < 500000: return "medium" return "high" def render_representative_investor_profile( max_budget_usd, financing_willingness, liquidity_importance, risk_profile, primary_goal, ): """Return one deterministic Dataset A description matching the user.""" budget_level = derive_budget_level(max_budget_usd) budget = float(max_budget_usd) matches = A[ (A["budget_level"] == budget_level) & (A["financing_willingness"] == financing_willingness) & (A["liquidity_importance"] == liquidity_importance) & (A["risk_profile"] == risk_profile) & (A["primary_goal"] == primary_goal) & A["max_budget_usd"].notna() & A["investor_description"].notna() ].copy() if matches.empty: return "" matches["budget_distance"] = ( matches["max_budget_usd"] - budget ).abs() selected = matches.sort_values( ["budget_distance", "investor_id"], ascending=[True, True], kind="mergesort", ).iloc[0] description = html.escape( str(selected["investor_description"]).strip() ) return f"""
YOUR INVESTOR PROFILE

A profile that matches your description

“{description}”

""" PART4_PROPERTY_FIELDS = [ "id", "formattedAddress", "city_rentcast", "propertyType", "yield_band", "liquidity_band", "volatility_band", "forecast_band", ] PROPERTY_RECORD_BY_ID = { str(row["id"]): { field: row[field] for field in PART4_PROPERTY_FIELDS if field in B.columns } for _, row in B.iterrows() } def python_value(value): if isinstance(value, np.generic): return value.item() if pd.isna(value): return None return value def property_record_for_generation(property_id): record = PROPERTY_RECORD_BY_ID.get(str(property_id)) if record is None: raise ValueError( f"No property found for ID {property_id}." ) return { field: python_value(value) for field, value in record.items() } def generate_top_3_explanations( investor_profile, property_records, ): generation_results = [] for property_record in property_records: explanation = generate_property_explanation( investor_profile=investor_profile, property_record=property_record, ) generation_results.append(explanation) return generation_results def prepare_text_match(description, manual_budget=None): """Convert free text into a weighted profile and prepare three matches.""" budget, profile, profile_weights = infer_profile( description, manual_budget, ) if budget is None: return None, profile, [], {}, [] results = recommend_from_distribution( profile_weights=profile_weights, max_budget_usd=budget, profile=profile, enforce_budget=True, ).reset_index(drop=True) budget_fallback = False if results.empty: results = recommend_from_distribution( profile_weights=profile_weights, max_budget_usd=budget, profile=profile, enforce_budget=False, ).reset_index(drop=True) budget_fallback = not results.empty investor_profile = { field: profile[field] for field in I } ranked_records = [] for record in results.to_dict(orient="records"): clean_record = { key: python_value(value) for key, value in record.items() } clean_record["budget_fallback"] = budget_fallback clean_record["requested_budget_usd"] = budget ranked_records.append(clean_record) property_records = [ property_record_for_generation(record["id"]) for record in ranked_records ] return ( budget, profile, ranked_records, investor_profile, property_records, ) def format_explanation_html(explanation): """ Removes the duplicate opening heading and converts important explanation section titles into bold visual headings. """ text = str(explanation or "").strip() lines = text.splitlines() # Remove blank lines at the beginning. while lines and not lines[0].strip(): lines.pop(0) # Remove duplicate first line shown beneath the card heading. if lines: first_line = lines[0].strip().strip("*").lower() if first_line in { "why this property may fit:", "why this property may fit", "why this property matches you:", "why this property matches you", }: lines.pop(0) headings = { "key consideration:", "key property indicators:", "what to verify:", } formatted_lines = [] for line in lines: clean_line = line.strip() if not clean_line: formatted_lines.append("
") continue escaped_line = html.escape(clean_line) normalized_line = clean_line.strip("*").lower() if normalized_line in headings: formatted_lines.append( f"
{escaped_line}
" ) else: formatted_lines.append( f"
{escaped_line}
" ) return "".join(formatted_lines) # ============================================================ # RESULT CARDS # ============================================================ def render_part_4_results( ranked_records, generation_results, ): if not ranked_records: return """

No eligible properties found

Try adjusting the budget or investor preferences.

""" if len(ranked_records) != len(generation_results): raise ValueError( "Recommendation and generation result counts do not match." ) cards = [] budget_fallback = bool( ranked_records[0].get("budget_fallback", False) ) fallback_message = "" if budget_fallback: requested_budget = float( ranked_records[0]["requested_budget_usd"] ) lowest_displayed_price = min( float(record["price"]) for record in ranked_records ) gap = lowest_displayed_price - requested_budget fallback_message = f"""
No eligible property was found within your ${requested_budget:,.0f} budget. We’re showing the strongest matching alternatives outside your current budget. The lowest-priced option shown is ${lowest_displayed_price:,.0f}, which is ${gap:,.0f} above your entered budget.
""" for rank, (property_record, explanation) in enumerate( zip(ranked_records, generation_results), start=1, ): address = html.escape( str(property_record.get("formattedAddress", "Address unavailable")) ) property_type = html.escape( str(property_record.get("propertyType", "Property")) ) image_url = property_image_url( property_record.get("propertyType", "") ) try: price = f"${float(property_record.get('price')):,.0f}" except (TypeError, ValueError): price = "Price unavailable" try: price_value = float(property_record.get("price")) except (TypeError, ValueError): price_value = None try: monthly_rent = float( property_record.get("monthly_rent") ) except (TypeError, ValueError): monthly_rent = None try: rental_yield = float( property_record.get("gross_rental_yield_percent") ) except (TypeError, ValueError): rental_yield = None try: value_forecast = float( property_record.get("value_forecast_12months") ) except (TypeError, ValueError): value_forecast = None annual_rent = ( monthly_rent * 12 if monthly_rent is not None else None ) projected_value_change = ( price_value * value_forecast / 100 if price_value is not None and value_forecast is not None else None ) estimated_gross_return = ( rental_yield + value_forecast if rental_yield is not None and value_forecast is not None else None ) rental_yield_text = ( f"{rental_yield:.1f}%" if rental_yield is not None else "N/A" ) annual_rent_text = ( f"~${annual_rent:,.0f}/yr" if annual_rent is not None else "N/A" ) forecast_text = ( f"{value_forecast:+.1f}%" if value_forecast is not None else "N/A" ) value_change_text = ( f"~${projected_value_change:+,.0f}" if projected_value_change is not None else "N/A" ) gross_return_text = ( f"{estimated_gross_return:.1f}%" if estimated_gross_return is not None else "N/A" ) explanation_text = format_explanation_html(explanation) cards.append( f"""
Illustrative {property_type} exterior
MATCH #{rank}

{address}

PROPERTY TYPE {property_type}
PRICE {price}
EST. 12-MONTH GROSS RETURN {gross_return_text}
RENTAL INCOME {rental_yield_text} {annual_rent_text}
+
VALUE FORECAST {forecast_text} {value_change_text}
Gross estimate before expenses, taxes, financing and transaction costs.
Illustrative property image

Why this property matches you

{explanation_text}
""" ) return f""" {fallback_message}

Your top matches

Three properties selected for your investment profile.

{''.join(cards)}
""" # ============================================================ # QUICK STARTER USER EXAMPLES # ============================================================ EXAMPLE_TEXT = { "Custom profile": "", "Eyal Ofer": ( "I can invest up to $1,000,000 without financing. " "I seek long-term growth, accept aggressive risk, " "and do not need much liquidity." ), "Gary Barnett": ( "My maximum purchase price is $450,000 and I will not " "use financing. I want a balanced, preservation-focused " "investment with medium liquidity." ), "Adam Neumann": ( "My budget is $125,000 and I am willing to finance. " "I prefer conservative income investments and high liquidity." ), } # Upload these three authorised image files to the assets folder. The app uses # an initials-based placeholder until the corresponding image has been added. EXAMPLE_DETAILS = { "Eyal Ofer": { "photo": "eyal_ofer.jpg", "initials": "EO", "bio": ( "Eyal Ofer founded Ofer Global, a private portfolio of international businesses. " "Its areas of activity include maritime shipping, real estate and hotels, technology, banking and energy. " "This example profile represents a high-budget, growth-oriented investment approach." ), }, "Gary Barnett": { "photo": "gary_barnett.jpg", "initials": "GB", "bio": ( "Gary Barnett is the founder and chairman of Extell Development. " "Extell develops luxury residential, commercial and hospitality properties in New York City and beyond. " "This example profile represents a balanced, preservation-oriented investment approach." ), }, "Adam Neumann": { "photo": "adam_neumann.jpg", "initials": "AN", "bio": ( "Adam Neumann co-founded WeWork and later founded Flow, a residential real-estate company. " "Flow focuses on a technology-enabled residential experience for owners, operators and residents. " "This example profile represents a lower-budget, income-oriented investment approach." ), }, } def example_image_url(example): """Use an authorised local image when present, otherwise show initials.""" photo_path = ASSET_DIR / EXAMPLE_DETAILS[example]["photo"] if photo_path.exists(): return "/gradio_api/file=" + quote(str(photo_path.resolve())) initials = EXAMPLE_DETAILS[example]["initials"] return ( "data:image/svg+xml;utf8," + quote( f"" f"" f"{initials}" f"" ) ) def render_selected_example(example_name): """Show an investor bio only when the visitor selects an example.""" if example_name == "Custom profile": return "" details = EXAMPLE_DETAILS[example_name] return f"""
{html.escape(example_name)}
INVESTOR EXAMPLE

{html.escape(example_name)}

{html.escape(details['bio'])}

""" INITIAL_RESULTS = """

Ready when you are

Describe yourself as an investor, then select Match Me Up!

""" def run_match(description, manual_budget=None): """Complete profile inference, ranking, and explanation generation.""" try: ( budget, profile, ranked_records, investor_profile, property_records, ) = prepare_text_match( description, manual_budget, ) if budget is None: budget_prompt = """
One more thing: What is the maximum property price you can afford in USD?
""" waiting_results = """

Budget needed

Add a dollar amount so REmatch can enforce your price limit.

""" return ( budget_prompt, "", waiting_results, gr.update(visible=True, value=None), gr.update(visible=True), ) representative_profile = ( render_representative_investor_profile( budget, profile["financing_willingness"], profile["liquidity_importance"], profile["risk_profile"], profile["primary_goal"], ) ) explanations = generate_top_3_explanations( investor_profile, property_records, ) results = render_part_4_results( ranked_records, explanations, ) return ( "", representative_profile, results, gr.update(visible=False), gr.update(visible=False), ) except Exception as error: safe_message = html.escape(str(error)) error_message = f"""
We could not complete the match. {safe_message}
""" return ( error_message, "", "", gr.update(), gr.update(), ) def apply_example(example_name): description = EXAMPLE_TEXT[example_name] selected_example = ( render_selected_example(example_name) if example_name != "Custom profile" else "" ) if not description: return ( "", "", "", "", INITIAL_RESULTS, gr.update(visible=False), gr.update(visible=False), ) return ( description, selected_example, *run_match(description), ) # ============================================================ # GRADIO UI # ============================================================ with gr.Blocks( title="rematch | Property matching", css=""" :root { --blue:#003b95; --blue-dark:#002b6d; --yellow:#febb02; --ink:#1a1a1a; --muted:#6b6b6b; } .gradio-container { background:#f5f5f5 !important; font-family:Arial,Helvetica,sans-serif !important; } .hero { background:linear-gradient(112deg,var(--blue-dark),var(--blue)); border-radius:0 0 20px 20px; color:white; margin:-8px -8px 0; padding:42px max(24px,calc((100vw - 1120px)/2)) 72px; } .brand-row { align-items:center; display:flex; gap:14px; margin:0 0 14px; } .brand-logo { height:92px; object-fit:contain; width:92px; } .brand { color:#ffffff !important; font-size:56px; font-weight:800; letter-spacing:-2px; line-height:1; margin:0; } .hero-subtitle { color:#ffffff !important; font-size:19px; line-height:1.4; margin:0; opacity:.96; } .search-shell { max-width:1120px; margin:36px auto 0; position:relative; z-index:2; background:var(--yellow); border-radius:12px; padding:5px; box-shadow:0 6px 22px rgba(0,0,0,.18); } .search-card { background:white; border-radius:8px; padding:18px; } .search-title { color:var(--ink); font-size:21px; font-weight:700; margin:0 0 4px; } .search-subtitle { color:var(--muted); margin:0 0 18px; } .input-row { align-items:flex-end; gap:10px !important; } .field-question { align-items:flex-start; color:#262626; display:flex; font-size:14px; font-weight:700; gap:6px; line-height:1.25; margin:0 0 3px; min-height:28px; } .help-icon { align-items:center; align-self:center; background:#003b95; border-radius:50%; color:white; cursor:help; display:inline-flex; flex:0 0 18px; font-size:12px; font-weight:800; height:18px; justify-content:center; width:18px; } .search-field { min-width:0 !important; } .match-button { margin-top:44px !important; } .quick-starter { background:#eef5ff; border:1px solid #c8ddff; border-radius:8px; margin:24px auto 0; max-width:1120px; padding:8px 16px; } .selected-example { max-width:1120px; margin:16px auto 0; } .selected-example-card { align-items:center; background:white; border:1px solid #c8ddff; border-radius:10px; display:flex; gap:18px; padding:18px; } .selected-example-photo { border-radius:50%; flex:0 0 110px; height:110px; object-fit:cover; width:110px; } .selected-example-copy h2 { color:#262626; font-size:22px; margin:3px 0 8px; } .selected-example-copy p { color:#555; line-height:1.55; margin:0; } .selected-example-label { color:#003b95; font-size:11px; font-weight:800; letter-spacing:.1em; } .representative-profile { max-width:1120px; margin:18px auto 0; } .representative-profile-card { background:white; border-left:5px solid var(--blue); border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.08); padding:20px 22px; } .representative-profile-label { color:var(--blue); font-size:11px; font-weight:800; letter-spacing:.1em; } .representative-profile-card h2 { color:#262626; font-size:21px; margin:5px 0 10px; } .representative-profile-card p { color:#404040; font-size:16px; font-style:italic; line-height:1.6; margin:0; } .match-button { background:#0071c2 !important; border:1px solid #0071c2 !important; border-radius:6px !important; color:white !important; font-size:17px !important; font-weight:700 !important; min-height:50px !important; } .match-button:hover { background:#005fa3 !important; } .page-content { max-width:1120px; margin:22px auto 44px; } .results-header { margin:28px 0 16px; text-align:left; } .results-header h1 { color:var(--ink); font-size:27px; margin-bottom:6px; } .results-header p { color:var(--muted); } .property-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; align-items:stretch; margin-top:20px; } .property-card { background:white; border:1px solid #d0d0d0; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.09); overflow:hidden; } .property-image-wrap { height:190px; position:relative; overflow:hidden; background:#e8eef5; } .property-image { width:100%; height:100%; display:block; object-fit:cover; } .property-content { display:flex; flex-direction:column; padding:18px; min-height:390px; } .property-rank { background:var(--blue); border-radius:4px; color:white; font-size:11px; font-weight:800; letter-spacing:.1em; padding:6px 8px; position:absolute; top:12px; left:12px; } .property-address { color:#262626; font-size:20px; line-height:1.25; margin:0 0 18px; } .property-details { display:grid; grid-template-columns:1fr 1fr; gap:12px; background:#f5f5f5; border-radius:6px; padding:12px; margin-bottom:8px; } .detail-label { display:block; color:var(--muted); font-size:10px; font-weight:800; letter-spacing:.08em; margin-bottom:4px; } .detail-value { color:#262626; font-weight:700; } .property-price { color:#008009; font-size:19px; font-weight:800; } .illustrative-note { color:#777; font-size:11px; margin:2px 0 14px; } .return-panel { background:#f7fbff; border:1px solid #d7e7f7; border-radius:8px; margin:8px 0 12px; padding:14px; } .return-main { text-align:center; margin-bottom:12px; } .return-label { color:#6b6b6b; display:block; font-size:10px; font-weight:800; letter-spacing:.08em; } .return-value { color:#008009; display:block; font-size:27px; font-weight:800; margin-top:3px; } .return-components { align-items:center; display:grid; grid-template-columns:1fr auto 1fr; gap:8px; text-align:center; } .return-component-label { color:#6b6b6b; display:block; font-size:9px; font-weight:800; letter-spacing:.07em; } .return-component strong { color:#262626; display:block; font-size:16px; margin-top:3px; } .return-component small { color:#777; display:block; font-size:11px; margin-top:2px; } .return-plus { color:#003b95; font-size:20px; font-weight:800; } .return-note { border-top:1px solid #e1eaf3; color:#777; font-size:9px; margin-top:10px; padding-top:8px; text-align:center; } .budget-fallback { background:#fff7ed; border:1px solid #f5c38b; border-left:5px solid #f59e0b; border-radius:8px; color:#704214; margin:24px 0 8px; padding:16px 18px; } .budget-fallback strong { display:block; font-size:16px; margin-bottom:5px; } .budget-fallback span { display:block; font-size:14px; line-height:1.5; } .explanation-section { border-top:1px solid #e2e2e2; padding-top:16px; margin-top:auto; } .explanation-section h3 { color:#262626; font-size:15px; margin:0 0 8px; } .no-results { background:#fff7ed; border:1px solid #fed7aa; border-radius:8px; color:#854d0e; padding:24px; text-align:center; } .explanation-heading { color:#262626; font-size:14px; font-weight:800; margin-top:14px; margin-bottom:5px; } .explanation-text { color:#454545; font-size:14px; line-height:1.65; margin-bottom:4px; } .explanation-space { height:8px; } .text-search textarea { min-height:110px !important; font-size:17px !important; line-height:1.45 !important; } .search-card .match-button { margin-top:12px !important; } .budget-question { max-width:1120px; margin:18px auto 0; background:white; border-left:5px solid var(--yellow); border-radius:8px; padding:14px 18px; box-shadow:0 2px 10px rgba(0,0,0,.08); } .budget-followup { max-width:520px; margin:12px auto 0; } @media (max-width:900px) { .property-grid { grid-template-columns:1fr; } .hero { padding:32px 22px 48px; } .brand-logo { height:70px; width:70px; } .brand { font-size:42px; } .selected-example-card { align-items:flex-start; flex-direction:column; } } """, ) as demo: gr.HTML(f"""

Rematch

Find investment properties that fit the way you invest.

""") with gr.Group(elem_classes="search-shell"): with gr.Column(elem_classes="search-card"): gr.HTML("""

Tell us about yourself as an investor

Write naturally in up to three lines. Include your maximum property budget if you know it.

""") investor_text = gr.Textbox( label="Your investor description", lines=3, max_lines=3, placeholder=( "Example: I can invest up to $350,000. I want " "steady rental income, balanced risk, medium " "liquidity, and I can use financing." ), elem_classes="text-search", ) match_button = gr.Button( "Match Me Up!", variant="primary", elem_classes="match-button", ) with gr.Row(elem_classes="budget-followup"): budget_input = gr.Number( label="Maximum property price (USD)", minimum=1, precision=0, visible=False, ) budget_button = gr.Button( "Continue", variant="primary", visible=False, ) status_output = gr.HTML(value="") with gr.Group(elem_classes="quick-starter"): quick_starter = gr.Dropdown( choices=list(EXAMPLE_TEXT.keys()), value="Custom profile", label="Try an investor example", info=( "Choosing a named example writes its description " "and immediately loads recommendations." ), ) with gr.Column(elem_classes="selected-example"): selected_example_output = gr.HTML(value="") with gr.Column(elem_classes="representative-profile"): representative_profile_output = gr.HTML(value="") with gr.Column(elem_classes="page-content"): full_output = gr.HTML(value=INITIAL_RESULTS) match_outputs = [ status_output, representative_profile_output, full_output, budget_input, budget_button, ] match_button.click( fn=run_match, inputs=[investor_text], outputs=match_outputs, ) investor_text.submit( fn=run_match, inputs=[investor_text], outputs=match_outputs, ) budget_button.click( fn=run_match, inputs=[investor_text, budget_input], outputs=match_outputs, ) quick_starter.change( fn=apply_example, inputs=[quick_starter], outputs=[ investor_text, selected_example_output, *match_outputs, ], ) demo.queue().launch( share=False, debug=False, show_error=True, allowed_paths=[str(ASSET_DIR.resolve())], )