Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2011,6 +2011,52 @@ def get_recommendations(
if not query_title:
raise HTTPException(422, "Query parameter 'title' is required.")

# KNN branch - Issue #51
if method == "knn":
try:
from knn_collaborative import KNNCollaborativeRecommender
import pandas as _pd
_sb = get_supabase()
_purchases = (_sb.table("purchases").select("user_id, product_id, rating").limit(50000).execute().data or [])
if len(_purchases) < 2:
raise HTTPException(status_code=400, detail="Not enough interaction data for KNN.")
_item_df = models["item_df"]
_title_map = dict(zip(_item_df["id"], _item_df["title"])) if "id" in _item_df.columns else {}
_rows = [{"user_id": p["user_id"], "title": _title_map[p["product_id"]], "rating": float(p.get("rating") or 3.0)} for p in _purchases if _title_map.get(p.get("product_id"))]
if len(_rows) < 2:
raise HTTPException(status_code=400, detail="Not enough matched interactions for KNN.")
_knn = KNNCollaborativeRecommender(_pd.DataFrame(_rows), k=10)
_recs = _knn.recommend(title=query_title, top_n=top_n, user_id=user_id)
return {"query_item": query_title, "method": "knn", "count": len(_recs), "recommendations": _recs}
except HTTPException:
raise
except Exception as _e:
logger.error("KNN recommend error: %s", _e, exc_info=True)
raise HTTPException(status_code=500, detail=f"KNN failed: {str(_e)}")

# KNN branch - Issue #51
if method == "knn":
try:
from knn_collaborative import KNNCollaborativeRecommender
import pandas as _pd
_sb = get_supabase()
_purchases = (_sb.table("purchases").select("user_id, product_id, rating").limit(50000).execute().data or [])
if len(_purchases) < 2:
raise HTTPException(status_code=400, detail="Not enough interaction data for KNN.")
_item_df = models["item_df"]
_title_map = dict(zip(_item_df["id"], _item_df["title"])) if "id" in _item_df.columns else {}
_rows = [{"user_id": p["user_id"], "title": _title_map[p["product_id"]], "rating": float(p.get("rating") or 3.0)} for p in _purchases if _title_map.get(p.get("product_id"))]
if len(_rows) < 2:
raise HTTPException(status_code=400, detail="Not enough matched interactions for KNN.")
_knn = KNNCollaborativeRecommender(_pd.DataFrame(_rows), k=10)
_recs = _knn.recommend(title=query_title, top_n=top_n, user_id=user_id)
return {"query_item": query_title, "method": "knn", "count": len(_recs), "recommendations": _recs}
except HTTPException:
raise
except Exception as _e:
logger.error("KNN recommend error: %s", _e, exc_info=True)
raise HTTPException(status_code=500, detail=f"KNN failed: {str(_e)}")

# ----- EDGE CASES SAFE CHECK -----
# Agar model ready nahi hai ya database bilkul khali hai
if not models or "ready" not in models or not models["ready"]:
Expand Down Expand Up @@ -2172,6 +2218,7 @@ def get_recommendations_alias(
target_catalog: Optional[str] = Query(None),
model_version: Optional[str] = Query(None),
strategy: Optional[str] = Query(None),
method: Optional[str] = Query(None, description="knn for KNN-based collaborative filtering"),
):
"""Backward-compatible alias for clients calling /api/recommendations."""
return get_recommendations(
Expand Down
100 changes: 100 additions & 0 deletions knn_collaborative.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""knn_collaborative.py - KNN-based user collaborative filtering (Issue #51)"""
from __future__ import annotations
import logging
from typing import Any, Optional
import numpy as np
import pandas as pd
from sklearn.metrics.pairwise import cosine_similarity

logger = logging.getLogger(__name__)


class KNNCollaborativeRecommender:
def __init__(self, interaction_df: pd.DataFrame, k: int = 10, min_common_items: int = 1):
self.k = k
self.min_common_items = min_common_items
self._user_to_idx = {}
self._idx_to_user = {}
self._title_to_idx = {}
self._idx_to_title = {}
self._matrix = np.array([])
self._fit(interaction_df)

def _fit(self, df: pd.DataFrame) -> None:
required = {"user_id", "title", "rating"}
if not required.issubset(df.columns):
raise ValueError(f"interaction_df missing columns: {required - set(df.columns)}")
df = df.dropna(subset=["user_id", "title", "rating"]).copy()
df["rating"] = pd.to_numeric(df["rating"], errors="coerce").fillna(0.0)
users = sorted(df["user_id"].unique())
titles = sorted(df["title"].unique())
self._user_to_idx = {u: i for i, u in enumerate(users)}
self._idx_to_user = {i: u for u, i in self._user_to_idx.items()}
self._title_to_idx = {t: i for i, t in enumerate(titles)}
self._idx_to_title = {i: t for t, i in self._title_to_idx.items()}
matrix = np.zeros((len(users), len(titles)), dtype=np.float32)
for _, row in df.iterrows():
matrix[self._user_to_idx[row["user_id"]], self._title_to_idx[row["title"]]] = float(row["rating"])
self._matrix = matrix
logger.info("KNN fitted: %d users, %d items", len(users), len(titles))

def _user_similarity(self, user_vec: np.ndarray) -> np.ndarray:
if self._matrix.shape[0] == 0:
return np.array([])
return cosine_similarity(user_vec.reshape(1, -1), self._matrix).flatten()

def _top_k_similar_users(self, user_vec, exclude_indices):
sims = self._user_similarity(user_vec)
for idx in exclude_indices:
if 0 <= idx < len(sims):
sims[idx] = -1.0
common = np.count_nonzero((self._matrix > 0) & (user_vec > 0), axis=1)
sims[common < self.min_common_items] = -1.0
top_k = np.argsort(sims)[::-1][:self.k]
return [(int(i), float(sims[i])) for i in top_k if sims[i] > 0]

def recommend(self, title: str, top_n: int = 10, user_id: Optional[str] = None, target_catalog: Optional[str] = None) -> list:
if title not in self._title_to_idx and user_id not in self._user_to_idx:
return []
if user_id and user_id in self._user_to_idx:
query_vec = self._matrix[self._user_to_idx[user_id]].copy()
exclude = {self._user_to_idx[user_id]}
elif title in self._title_to_idx:
item_idx = self._title_to_idx[title]
raters = np.where(self._matrix[:, item_idx] > 0)[0]
if len(raters) == 0:
return []
query_vec = self._matrix[raters].mean(axis=0)
exclude = set(raters.tolist())
else:
return []
neighbours = self._top_k_similar_users(query_vec, exclude)
if not neighbours:
return self._popularity_fallback(top_n)
item_scores = np.zeros(self._matrix.shape[1], dtype=np.float64)
sim_sum = np.zeros(self._matrix.shape[1], dtype=np.float64)
for n_idx, sim in neighbours:
mask = self._matrix[n_idx] > 0
item_scores[mask] += sim * self._matrix[n_idx][mask]
sim_sum[mask] += sim
with np.errstate(divide="ignore", invalid="ignore"):
predicted = np.where(sim_sum > 0, item_scores / sim_sum, 0.0)
if title in self._title_to_idx:
predicted[self._title_to_idx[title]] = 0.0
if user_id and user_id in self._user_to_idx:
predicted[np.where(self._matrix[self._user_to_idx[user_id]] > 0)[0]] = 0.0
top_indices = np.argsort(predicted)[::-1][:top_n]
max_score = float(predicted.max()) if predicted.max() > 0 else 1.0
results = []
for idx in top_indices:
score = float(predicted[idx])
if score <= 0:
break
results.append({"title": self._idx_to_title[idx], "collab_score": round(score / max_score, 4)})
return results

def _popularity_fallback(self, top_n: int) -> list:
counts = np.count_nonzero(self._matrix, axis=0)
top = np.argsort(counts)[::-1][:top_n]
max_c = int(counts.max()) if counts.max() > 0 else 1
return [{"title": self._idx_to_title[int(i)], "collab_score": round(counts[i] / max_c, 4)} for i in top if counts[i] > 0]
83 changes: 83 additions & 0 deletions tests/test_knn_collaborative.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import pytest
import pandas as pd
import numpy as np
from knn_collaborative import KNNCollaborativeRecommender

@pytest.fixture
def df():
return pd.DataFrame([
{"user_id": "u1", "title": "Item A", "rating": 5.0},
{"user_id": "u1", "title": "Item B", "rating": 4.0},
{"user_id": "u1", "title": "Item C", "rating": 3.0},
{"user_id": "u2", "title": "Item A", "rating": 4.0},
{"user_id": "u2", "title": "Item B", "rating": 5.0},
{"user_id": "u2", "title": "Item D", "rating": 4.0},
{"user_id": "u3", "title": "Item A", "rating": 3.0},
{"user_id": "u3", "title": "Item C", "rating": 4.0},
{"user_id": "u3", "title": "Item E", "rating": 5.0},
{"user_id": "u4", "title": "Item B", "rating": 2.0},
{"user_id": "u4", "title": "Item D", "rating": 5.0},
{"user_id": "u4", "title": "Item E", "rating": 4.0},
])

@pytest.fixture
def model(df):
return KNNCollaborativeRecommender(df, k=3)

def test_user_index_built(model):
assert "u1" in model._user_to_idx and len(model._user_to_idx) == 4

def test_item_index_built(model):
assert "Item A" in model._title_to_idx and len(model._title_to_idx) == 5

def test_matrix_shape(model):
assert model._matrix.shape == (4, 5)

def test_missing_columns_raises():
with pytest.raises(ValueError):
KNNCollaborativeRecommender(pd.DataFrame([{"user_id": "u1", "title": "A"}]))

def test_returns_list(model):
assert isinstance(model.recommend("Item A", top_n=3), list)

def test_top_n_respected(model):
assert len(model.recommend("Item A", top_n=2)) <= 2

def test_rec_has_keys(model):
for r in model.recommend("Item A", top_n=3):
assert "title" in r and "collab_score" in r

def test_scores_in_range(model):
for r in model.recommend("Item A", top_n=5):
assert 0.0 <= r["collab_score"] <= 1.0

def test_query_item_excluded(model):
assert "Item A" not in [r["title"] for r in model.recommend("Item A", top_n=5)]

def test_sorted_descending(model):
scores = [r["collab_score"] for r in model.recommend("Item A", top_n=5)]
assert scores == sorted(scores, reverse=True)

def test_unknown_item_returns_empty(model):
assert model.recommend("Nonexistent", top_n=5) == []

def test_user_personalisation(model):
titles = [r["title"] for r in model.recommend("Item A", top_n=5, user_id="u1")]
assert "Item A" not in titles and "Item B" not in titles and "Item C" not in titles

def test_popularity_fallback(model):
fb = model._popularity_fallback(top_n=3)
assert len(fb) <= 3 and all("title" in r for r in fb)

def test_single_user():
df = pd.DataFrame([{"user_id": "u1", "title": "A", "rating": 5.0}, {"user_id": "u1", "title": "B", "rating": 4.0}])
assert isinstance(KNNCollaborativeRecommender(df, k=5).recommend("A", top_n=2), list)

def test_nan_ratings_handled():
df = pd.DataFrame([
{"user_id": "u1", "title": "A", "rating": None},
{"user_id": "u1", "title": "B", "rating": 4.0},
{"user_id": "u2", "title": "A", "rating": 3.0},
{"user_id": "u2", "title": "B", "rating": 5.0},
])
assert isinstance(KNNCollaborativeRecommender(df, k=2).recommend("A", top_n=2), list)
Loading