ML-services / train.py
subhan971's picture
Update train.py
d16b2b4 verified
Raw
History Blame Contribute Delete
16.2 kB
"""
Robust matrimonial recommendation engine.
Pipeline
--------
1. Hard filters – eliminate incompatible candidates entirely
2. Soft scoring – rank surviving candidates by weighted similarity
3. Return top-N – with full score breakdown for transparency / debugging
Hard filter rules (all must pass):
• opposite_gender
• is_active
• not self
• religion – exact match
• marital_status – grouped match (see MARITAL_GROUPS)
Soft score dimensions (weights sum to 1.0):
• age_compatibility 0.20 – Gaussian, peaks at ideal age gap
• personality_traits 0.25 – cosine similarity on sentence embeddings
• partner_criteria 0.25 – bidirectional keyword + embedding match
• hobbies 0.15 – cosine similarity
• categorical_bonus 0.15 – maslak, country, qualification
"""
import os
import math
import logging
import httpx
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.feature_extraction.text import TfidfVectorizer
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
SUPABASE_URL = os.getenv(
"SUPABASE_URL", "https://nquhiryqtbrtpauuxmsc.supabase.co"
)
SERVICE_KEY = os.getenv(
"SUPABASE_SERVICE_KEY",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5xdWhpcnlxdGJydHBhdXV4bXNjIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NjA4MzcxMCwiZXhwIjoyMDkxNjU5NzEwfQ.q7ysbaSfMv15g7PyJRLMwnRpEyia5d_ol3iLZn0Xayo",
)
HEADERS = {"apikey": SERVICE_KEY, "Content-Type": "application/json"}
TOP_N = 20 # maximum recommendations returned
AGE_IDEAL_GAP = 3 # years – peak of the Gaussian for age compatibility
AGE_SIGMA = 7 # standard deviation in years; larger = more forgiving
# Soft-score weights (must sum to 1.0)
WEIGHTS = {
"age_compatibility": 0.20,
"personality_traits": 0.25,
"partner_criteria": 0.25,
"hobbies": 0.15,
"categorical_bonus": 0.15,
}
assert abs(sum(WEIGHTS.values()) - 1.0) < 1e-6, "Weights must sum to 1.0"
# Marital status groups: candidates in the same group may match each other.
# Adjust to your product rules.
MARITAL_GROUPS = {
"single": {"single"},
"divorced": {"divorced", "widowed"}, # divorced ↔ widowed allowed
"widowed": {"divorced", "widowed"},
"separated":{"separated"},
}
# Personality / trait keywords used to expand text matching
TRAIT_SYNONYMS = {
"caring": ["caring", "care", "compassionate", "nurturing", "kind"],
"honest": ["honest", "truthful", "sincere", "transparent"],
"religious": ["religious", "practicing", "devout", "practicing muslim",
"practicing christian", "allah", "god", "faith", "deen"],
"family-oriented": ["family", "family-oriented", "family oriented",
"home", "homemaker", "domestic"],
"ambitious": ["ambitious", "driven", "goal-oriented", "career", "successful"],
"respectful": ["respectful", "respect", "humble", "obedient"],
"educated": ["educated", "degree", "professional", "literate"],
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def safe_str(val) -> str:
"""Normalise any DB value to a lowercase string."""
if val is None:
return ""
if isinstance(val, list):
return " ".join(str(v) for v in val if v)
return str(val).strip().lower()
def expand_traits(text: str) -> str:
"""
Expand trait keywords so that 'caring' also pulls in 'compassionate',
'nurturing', etc. This improves recall when users describe traits
differently.
"""
words = text.lower().split()
extras = []
for word in words:
for canonical, synonyms in TRAIT_SYNONYMS.items():
if word in synonyms or canonical in text.lower():
extras.extend(synonyms)
break
return text + " " + " ".join(extras)
def text_similarity(text_a: str, text_b: str) -> float:
"""
TF-IDF cosine similarity between two text blobs.
Returns a value in [0, 1].
"""
if not text_a.strip() or not text_b.strip():
return 0.0
try:
vect = TfidfVectorizer(stop_words="english", min_df=1)
matrix = vect.fit_transform([text_a, text_b])
score = cosine_similarity(matrix[0:1], matrix[1:2])[0][0]
return float(np.clip(score, 0.0, 1.0))
except Exception:
return 0.0
def age_score(user_age: int, candidate_age: int, gender: str) -> float:
"""
Gaussian age-compatibility score.
Convention (adjustable):
• Male users prefer slightly younger females → ideal gap = +AGE_IDEAL_GAP
• Female users prefer slightly older males → ideal gap = -AGE_IDEAL_GAP
Gap is defined as (candidate_age – user_age).
"""
ideal_gap = AGE_IDEAL_GAP if gender == "male" else -AGE_IDEAL_GAP
gap = candidate_age - user_age - ideal_gap
return float(math.exp(-(gap ** 2) / (2 * AGE_SIGMA ** 2)))
def marital_status_compatible(user_status: str, candidate_status: str) -> bool:
"""
Returns True if the two marital statuses are compatible according to
MARITAL_GROUPS. Falls back to exact match for unrecognised values.
"""
us = user_status.strip().lower()
cs = candidate_status.strip().lower()
if not us or not cs:
return False # missing data → reject (safe default)
allowed = MARITAL_GROUPS.get(us)
if allowed is None:
return us == cs # unknown status: require exact match
return cs in allowed
def religion_compatible(user_religion: str, candidate_religion: str) -> bool:
"""Strict exact match on religion (case-insensitive)."""
ur = user_religion.strip().lower()
cr = candidate_religion.strip().lower()
if not ur or not cr:
return False # missing religion → reject
return ur == cr
def categorical_bonus(user: dict, candidate: dict) -> float:
"""
Small score boost for shared optional attributes:
maslak, country, qualification level.
Score is in [0, 1].
"""
matches = 0
total = 0
for key in ["maslak", "country", "qualification"]:
u = safe_str(user.get(key))
c = safe_str(candidate.get(key))
if u and c:
total += 1
if u == c:
matches += 1
return matches / total if total > 0 else 0.5 # neutral when no data
def bidirectional_criteria_score(user: dict, candidate: dict) -> float:
"""
Score how well the user meets the candidate's criteria AND
how well the candidate meets the user's criteria.
Returns the average of both directions so a one-sided mismatch
still reduces the score.
"""
user_criteria = expand_traits(safe_str(user.get("preferred_partner_criteria")))
cand_criteria = expand_traits(safe_str(candidate.get("preferred_partner_criteria")))
# Build text descriptions of each person for comparison
user_desc = expand_traits(
safe_str(user.get("personality_traits")) + " " +
safe_str(user.get("hobbies"))
)
cand_desc = expand_traits(
safe_str(candidate.get("personality_traits")) + " " +
safe_str(candidate.get("hobbies"))
)
# user → candidate: does candidate match user's criteria?
u2c = text_similarity(user_criteria, cand_desc) if user_criteria else 0.5
# candidate → user: does user match candidate's criteria?
c2u = text_similarity(cand_criteria, user_desc) if cand_criteria else 0.5
return (u2c + c2u) / 2.0
# ---------------------------------------------------------------------------
# Supabase helpers
# ---------------------------------------------------------------------------
def fetch_all_active_profiles() -> list[dict]:
"""Page through all active profiles (100 per request)."""
profiles = []
offset = 0
while True:
resp = httpx.get(
f"{SUPABASE_URL}/rest/v1/profiles",
headers=HEADERS,
params={
"select": "*",
"is_active": "eq.true",
"limit": 100,
"offset": offset,
},
timeout=15,
)
resp.raise_for_status()
batch = resp.json()
if not batch:
break
profiles.extend(batch)
offset += 100
if len(batch) < 100:
break
logger.info("Fetched %d active profiles", len(profiles))
return profiles
def fetch_profile(user_id: str) -> dict | None:
"""Return a single profile or None."""
resp = httpx.get(
f"{SUPABASE_URL}/rest/v1/profiles",
headers=HEADERS,
params={"id": f"eq.{user_id}", "select": "*"},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
return data[0] if data else None
# ---------------------------------------------------------------------------
# Core recommendation logic
# ---------------------------------------------------------------------------
def score_candidate(user: dict, candidate: dict) -> dict:
"""
Compute a detailed score breakdown for one candidate.
Returns a dict with individual dimension scores and a composite.
"""
user_age = int(user.get("age") or 25)
cand_age = int(candidate.get("age") or 25)
user_gender = safe_str(user.get("gender"))
# ── Dimension scores ──────────────────────────────────────────────────
age_compat = age_score(user_age, cand_age, user_gender)
user_traits = expand_traits(safe_str(user.get("personality_traits")))
cand_traits = expand_traits(safe_str(candidate.get("personality_traits")))
trait_sim = text_similarity(user_traits, cand_traits)
criteria_sim = bidirectional_criteria_score(user, candidate)
user_hobbies = safe_str(user.get("hobbies"))
cand_hobbies = safe_str(candidate.get("hobbies"))
hobby_sim = text_similarity(user_hobbies, cand_hobbies)
cat_bonus = categorical_bonus(user, candidate)
# ── Weighted composite ────────────────────────────────────────────────
composite = (
WEIGHTS["age_compatibility"] * age_compat +
WEIGHTS["personality_traits"] * trait_sim +
WEIGHTS["partner_criteria"] * criteria_sim +
WEIGHTS["hobbies"] * hobby_sim +
WEIGHTS["categorical_bonus"] * cat_bonus
)
return {
"candidate_id": candidate["id"],
"candidate_name": candidate.get("full_name", "Unknown"),
"age": cand_age,
"religion": candidate.get("religion"),
"marital_status": candidate.get("marital_status"),
"scores": {
"age_compatibility": round(age_compat, 4),
"personality_traits": round(trait_sim, 4),
"partner_criteria": round(criteria_sim, 4),
"hobbies": round(hobby_sim, 4),
"categorical_bonus": round(cat_bonus, 4),
},
"composite_score": round(composite, 4),
}
def recommend(user_id: str, all_profiles: list[dict] | None = None) -> list[dict]:
"""
Return up to TOP_N recommendations for user_id.
Parameters
----------
user_id : the requester's profile ID
all_profiles : pre-fetched profiles (optional, for caching)
Returns
-------
List of score dicts sorted by composite_score descending.
"""
# 1. Load user
user = fetch_profile(user_id)
if not user:
raise ValueError(f"User {user_id!r} not found")
user_gender = safe_str(user.get("gender"))
user_religion = safe_str(user.get("religion"))
user_marital = safe_str(user.get("marital_status"))
if user_gender not in ("male", "female"):
raise ValueError(f"Invalid gender for user {user_id!r}: {user_gender!r}")
opposite_gender = "female" if user_gender == "male" else "male"
# 2. Load pool
profiles = all_profiles if all_profiles is not None else fetch_all_active_profiles()
# 3. Hard filters
candidates = []
reject_counts = {
"self": 0,
"gender": 0,
"inactive": 0,
"religion": 0,
"marital_status": 0,
}
for profile in profiles:
pid = profile.get("id")
if pid == user_id:
reject_counts["self"] += 1
continue
if safe_str(profile.get("gender")) != opposite_gender:
reject_counts["gender"] += 1
continue
if not profile.get("is_active"):
reject_counts["inactive"] += 1
continue
if not religion_compatible(user_religion, safe_str(profile.get("religion"))):
reject_counts["religion"] += 1
continue
if not marital_status_compatible(user_marital, safe_str(profile.get("marital_status"))):
reject_counts["marital_status"] += 1
continue
candidates.append(profile)
logger.info(
"Hard filter summary — total=%d passed=%d rejected: %s",
len(profiles), len(candidates), reject_counts,
)
if not candidates:
logger.warning("No candidates survived hard filters for user %s", user_id)
return []
# 4. Soft scoring
scored = [score_candidate(user, c) for c in candidates]
# 5. Rank and return top-N
scored.sort(key=lambda x: x["composite_score"], reverse=True)
results = scored[:TOP_N]
logger.info(
"Top-%d recommendations for %s | best=%.3f worst=%.3f",
len(results), user_id,
results[0]["composite_score"] if results else 0,
results[-1]["composite_score"] if results else 0,
)
return results
# ---------------------------------------------------------------------------
# Debug / test runner
# ---------------------------------------------------------------------------
async def test_recommend():
"""Step-by-step diagnostic that mirrors the original debug script."""
print("\n" + "=" * 70)
print("DEBUGGING RECOMMEND ENDPOINT")
print("=" * 70)
# Resolve a test user dynamically
print("\n0️⃣ Resolving a test user...")
try:
resp = httpx.get(
f"{SUPABASE_URL}/rest/v1/profiles",
headers=HEADERS,
params={"select": "id", "is_active": "eq.true", "limit": 1},
timeout=10,
)
data = resp.json()
if not data:
print(" ✗ No active users found"); return
user_id = data[0]["id"]
print(f" ✓ Using user_id: {user_id}")
except Exception as e:
print(f" ✗ Could not resolve test user: {e}"); return
# Run the full pipeline
try:
results = recommend(user_id)
except Exception as e:
print(f"\n✗ recommend() raised: {e}")
import traceback; traceback.print_exc()
return
print(f"\n✅ recommend() returned {len(results)} result(s)")
if results:
print("\nTop 5 results:")
print(f" {'Name':<25} {'Age':>4} {'Score':>7} Breakdown")
print(" " + "-" * 70)
for r in results[:5]:
s = r["scores"]
breakdown = (
f"age={s['age_compatibility']:.2f} "
f"traits={s['personality_traits']:.2f} "
f"criteria={s['partner_criteria']:.2f} "
f"hobbies={s['hobbies']:.2f} "
f"cat={s['categorical_bonus']:.2f}"
)
print(f" {r['candidate_name']:<25} {r['age']:>4} {r['composite_score']:>7.4f} {breakdown}")
if __name__ == "__main__":
import asyncio
asyncio.run(test_recommend())