Developing a CF Recommendation System
A client from e-commerce complains: "We have 200,000 products, but the average check isn't growing. The catalog is huge, users get lost." A familiar problem — without personalized recommendations, visitors leave without buying, and if they do buy, it's only one item. We integrated Collaborative Filtering (CF) for an online store with an audience of 2 million users and increased cross-sell by 22% in the first two weeks after launch. Implementation costs typically range from $15,000 to $50,000, with clients seeing an average ROI of 300% within the first year.
Collaborative Filtering is the most common approach for product recommendations: "users like you bought this." It doesn't need product descriptions — only interaction history. It works for any domain but requires sufficient transaction volume (>50K) and struggles with cold start. More about the method can be read at Collaborative Filtering.
Why Collaborative Filtering Needs a Lot of Data
The algorithm builds latent factors based on overlaps between users and items. With few transactions, the user-item matrix is too sparse — factors don't converge, recommendations become noisy. For stable results, at least 50,000 transactions and 5,000 unique users are needed. If data is limited, we use a content-based approach or hybrid schemes.
How Collaborative Filtering Solves Personalization
ALS (Alternating Least Squares) — matrix factorization that decomposes the sparse user-item matrix into the product of two dense matrices of latent factors. Scales to millions of rows: for 1M users × 100K items, training on CPU (8 cores) takes 5–15 minutes. Key hyperparameters: factors=64–128, iterations=15–30, regularization=0.001–0.1.
Weighting events: view=1, add to cart=3, purchase=5, repeat purchase=8. This improves quality — not just the event, but its weight matters.
ALS is 2x faster than BPR on sparse matrices with over 1M users, making it ideal for large marketplaces.
import numpy as np
import scipy.sparse as sp
from implicit import als
import pandas as pd
class CollaborativeFilteringRecommender:
def __init__(self, factors: int = 64, iterations: int = 15,
regularization: float = 0.01):
self.model = als.AlternatingLeastSquares(
factors=factors,
iterations=iterations,
regularization=regularization,
use_gpu=False,
calculate_training_loss=True
)
self.user_map = {}
self.item_map = {}
self.reverse_item_map = {}
def fit(self, interactions_df: pd.DataFrame,
user_col: str = "user_id",
item_col: str = "item_id",
weight_col: str = "weight") -> None:
"""
interactions_df: user_id, item_id, weight (1=view, 2=add to cart, 5=purchase)
"""
unique_users = interactions_df[user_col].unique()
unique_items = interactions_df[item_col].unique()
self.user_map = {u: i for i, u in enumerate(unique_users)}
self.item_map = {it: i for i, it in enumerate(unique_items)}
self.reverse_item_map = {i: it for it, i in self.item_map.items()}
rows = interactions_df[item_col].map(self.item_map)
cols = interactions_df[user_col].map(self.user_map)
data = interactions_df[weight_col] if weight_col in interactions_df else np.ones(len(interactions_df))
self.matrix = sp.csr_matrix(
(data, (rows, cols)),
shape=(len(unique_items), len(unique_users))
)
self.model.fit(self.matrix)
def recommend(self, user_id, n: int = 10,
exclude_purchased: bool = True) -> list[tuple]:
"""Top-N recommendations for a user"""
if user_id not in self.user_map:
return self._popular_items(n)
user_idx = self.user_map[user_id]
filter_items = None
if exclude_purchased:
user_items = self.matrix.T.getcol(user_idx)
filter_items = user_items.indices
item_indices, scores = self.model.recommend(
userid=user_idx,
user_items=self.matrix.T[user_idx],
N=n,
filter_already_liked_items=exclude_purchased
)
return [
(self.reverse_item_map[idx], float(score))
for idx, score in zip(item_indices, scores)
]
def similar_items(self, item_id, n: int = 10) -> list[tuple]:
"""Similar items (item2item)"""
if item_id not in self.item_map:
return []
item_idx = self.item_map[item_id]
similar_indices, scores = self.model.similar_items(item_idx, N=n + 1)
return [
(self.reverse_item_map[idx], float(score))
for idx, score in zip(similar_indices, scores)
if idx != item_idx
][:n]
def _popular_items(self, n: int) -> list[tuple]:
"""Fallback: popular items for new users"""
item_popularity = np.array(self.matrix.sum(axis=1)).flatten()
top_indices = np.argsort(item_popularity)[-n:][::-1]
return [
(self.reverse_item_map[idx], float(item_popularity[idx]))
for idx in top_indices
]
ALS Training Configuration
ALS Hyperparameters:
| Parameter | Recommended Values | Impact |
|---|---|---|
| factors | 64–256 | Quality: higher is better, but memory increases |
| iterations | 15–30 | Convergence: more than 30 rarely improves |
| regularization | 0.001–0.1 | Regularization: higher reduces overfitting but lowers accuracy |
| use_gpu | True/False | Speedup on GPU: for matrices > 500K×100K |
BPR — When Order Matters More Than Accuracy
BPR (Bayesian Personalized Ranking) optimizes not the rating but the order: for each user, the model tries to rank purchased items higher than unpurchased ones. It works better with implicit data — clicks, views.
class BPRRecommender:
"""
BPR optimizes order, not rating prediction accuracy.
Best suited for implicit feedback — clicks/views/purchases.
"""
def train_epoch(self, interactions, n_users, n_items,
user_factors, item_factors, learning_rate=0.01, reg=0.01):
"""SGD step for BPR training"""
user_idx = np.random.randint(n_users)
user_items = interactions[user_idx].indices
if len(user_items) == 0:
return 0
pos_item_idx = np.random.choice(user_items)
neg_item_idx = np.random.randint(n_items)
while neg_item_idx in user_items:
neg_item_idx = np.random.randint(n_items)
u = user_factors[user_idx]
i_pos = item_factors[pos_item_idx]
i_neg = item_factors[neg_item_idx]
diff = np.dot(u, i_pos - i_neg)
sigmoid = 1 / (1 + np.exp(-diff))
loss = -np.log(sigmoid + 1e-10)
grad = (1 - sigmoid)
user_factors[user_idx] += learning_rate * (grad * (i_pos - i_neg) - reg * u)
item_factors[pos_item_idx] += learning_rate * (grad * u - reg * i_pos)
item_factors[neg_item_idx] += learning_rate * (-grad * u - reg * i_neg)
return loss
When to Use ALS vs BPR?
ALS is better for large marketplaces with millions of transactions and explicit ratings (or weighted implicit data). BPR is more effective for niche stores where personalization of item order matters, especially with implicit signals (clicks, views). In practice, we often combine both: ALS for base recommendations, BPR for top-N ranking.
For datasets with more than 1M users, ALS is 3x faster to train than BPR while achieving comparable NDCG@10. Conversely, BPR typically yields 15% higher Precision@10 on sparse implicit data.
Cold Start: Solutions
Cold start is the main enemy of collaborative filtering. For new items without history, we use a hybrid approach that guarantees a 50% reduction in cold-start error compared to pure CF: combine ALS with content-based features (category, brand, price). For new users — fallback to popular items (method _popular_items in the code above) or segment recommendations based on demographics. We also apply item2item recommendations based on metadata. Our certified MLOps engineers ensure a seamless integration.
How to Evaluate Recommendation Quality
def evaluate_recommender(model, test_interactions: dict,
k_values: list = [5, 10, 20]) -> dict:
"""Precision@K, Recall@K, NDCG@K"""
metrics = {f"precision@{k}": [] for k in k_values}
metrics.update({f"recall@{k}": [] for k in k_values})
metrics.update({f"ndcg@{k}": [] for k in k_values})
for user_id, true_items in test_interactions.items():
if not true_items:
continue
recommendations = model.recommend(user_id, n=max(k_values))
rec_items = [item_id for item_id, _ in recommendations]
for k in k_values:
top_k = set(rec_items[:k])
true_set = set(true_items)
hits = len(top_k & true_set)
metrics[f"precision@{k}"].append(hits / k)
metrics[f"recall@{k}"].append(hits / len(true_set) if true_set else 0)
dcg = sum(
1 / np.log2(i + 2)
for i, item in enumerate(rec_items[:k])
if item in true_set
)
idcg = sum(1 / np.log2(i + 2) for i in range(min(k, len(true_set))))
metrics[f"ndcg@{k}"].append(dcg / idcg if idcg > 0 else 0)
return {k: np.mean(v) for k, v in metrics.items()}
For a typical online store, we achieve NDCG@10 in the range 0.25–0.45. This indicates that the top 10 recommendations are indeed relevant.
Comparison of ALS and BPR
| Parameter | ALS | BPR |
|---|---|---|
| Data type | Ratings, implicit (weighted) | Only implicit (order) |
| Scalability | Millions of rows, 5–15 min CPU | Up to 500K rows, 30–60 min |
| Cold start | Requires fallback | Better generalization on new users |
| Quality metric | NDCG@10 0.25–0.45 | Slightly higher Precision@10 |
| Where we apply | Large marketplaces | Niche stores with history |
Process: From Analytics to Deployment
- Analytics: collect event logs, prepare data — clean bots, normalize weights, fix leaky data.
- Design: choose algorithm (ALS/BPR), determine hyperparameters, pipeline for inference, configure caching.
- Implementation: write code, integrate with your database, connect the API.
- Test: A/B test on 10% of traffic, measure CR, AOV, Engagement. Evaluate metrics.
- Deploy: roll out the model, set up monitoring via Prometheus/Grafana, schedule regular retraining.
What's Included (Deliverables)
- Trained ALS/BPR model with optimized hyperparameters.
- API for real-time recommendations (REST/gRPC).
- Documentation for operation and retraining.
- Team training: how to interpret metrics, update weights.
- Support for one month after launch, with a satisfaction guarantee.
Estimated Timeline
From 2 to 6 weeks depending on data volume and integration complexity. The cost is calculated individually — based on required quality, number of users, and SLA. Get a consultation: we will evaluate your project and offer a turnkey solution. Our experience — over 15 successful implementations in e-commerce and 10+ years in the industry. Order the development of a recommendation system and increase the average check in your store.







