# ============================================================ # 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"""
“{description}”
Try adjusting the budget or investor preferences.
Three properties selected for your investment profile.
{html.escape(details['bio'])}
Describe yourself as an investor, then select Match Me Up!
Add a dollar amount so REmatch can enforce your price limit.
Find investment properties that fit the way you invest.
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())], )