Building an AI Cross-Sell Recommendation System for Sales
We were approached by an e-commerce project with 50,000 SKUs: the rule "printer → cartridge" yielded only 8% conversion. They needed a machine learning-based recommendation system that finds non-obvious pairs and adapts to each customer's behavior. We implemented a hybrid system for e-commerce and retail: association rules for basic bundles + gradient boosting for personalization. Result: an 18% increase in average order value in the first month, providing a significant revenue boost. Here’s how it works.
Problems We Solve
Static rules do not capture hidden patterns. For example, customers who bought a child car seat have a 22% probability of buying a trunk organizer—even though the items are from different categories. ML finds such patterns through association rules (Lift > 2.0). Personalization via gradient boosting takes into account customer history: if the user already bought an organizer, the recommendation shifts to another item.
Another common problem is goods with a short consumption cycle (consumables, subscriptions). Here, predicting the next purchase is required. A model based on interval distribution outputs items the customer is about to reorder. This way, we increase repeat sales without promotions.
The third problem is the cold start for new products. If a product has no purchase history, Apriori rules won't work. The solution is to use content-based features (category, price, brand) and collaborative filtering through embeddings. For this, we enrich the gradient boosting model with product features.
How We Build the CrossSellRecommender Model
Market Basket Analysis + Personalization
Hybrid architecture: first Apriori gives raw rules, then gradient boosting ranks candidates for a specific user. This combination provides a conversion lift 3 times higher than a rule-based approach.
import pandas as pd
import numpy as np
from mlxtend.frequent_patterns import apriori, association_rules
from mlxtend.preprocessing import TransactionEncoder
from sklearn.ensemble import GradientBoostingClassifier
class CrossSellRecommender:
def __init__(self, min_support: float = 0.01, min_confidence: float = 0.1):
self.min_support = min_support
self.min_confidence = min_confidence
self.rules = None
self.cross_sell_map = {}
self.personalization_model = None
def fit_association_rules(self, orders_df: pd.DataFrame,
order_col: str = "order_id",
item_col: str = "item_id"):
"""Find association rules via Apriori"""
# Transaction baskets
baskets = orders_df.groupby(order_col)[item_col].apply(list).tolist()
te = TransactionEncoder()
te_array = te.fit_transform(baskets)
df_encoded = pd.DataFrame(te_array, columns=te.columns_)
# Frequent itemsets
frequent_itemsets = apriori(
df_encoded,
min_support=self.min_support,
use_colnames=True,
max_len=3
)
# Association rules
self.rules = association_rules(
frequent_itemsets,
metric="lift",
min_threshold=1.2
)
self.rules = self.rules[self.rules['confidence'] >= self.min_confidence]
self.rules = self.rules.sort_values('lift', ascending=False)
# Mapping: item → list of recommendations with metrics
for _, rule in self.rules.iterrows():
for antecedent in rule['antecedents']:
if antecedent not in self.cross_sell_map:
self.cross_sell_map[antecedent] = []
for consequent in rule['consequents']:
if antecedent != consequent:
self.cross_sell_map[antecedent].append({
'item_id': consequent,
'confidence': rule['confidence'],
'lift': rule['lift'],
'support': rule['support']
})
# Sort by lift
for item in self.cross_sell_map:
self.cross_sell_map[item].sort(key=lambda x: x['lift'], reverse=True)
def recommend_cross_sell(self, cart_items: list[str],
user_history: list[str] = None,
n: int = 5) -> list[dict]:
"""Cross-sell for current cart"""
candidates = {}
for item_id in cart_items:
related = self.cross_sell_map.get(item_id, [])
for rec in related:
rec_id = rec['item_id']
# Skip if already in cart or in history
if rec_id in cart_items:
continue
if user_history and rec_id in user_history:
continue
if rec_id not in candidates:
candidates[rec_id] = {'score': 0, 'triggers': []}
candidates[rec_id]['score'] += rec['lift']
candidates[rec_id]['triggers'].append(item_id)
# Normalization
if not candidates:
return []
sorted_candidates = sorted(
[{'item_id': k, **v} for k, v in candidates.items()],
key=lambda x: x['score'],
reverse=True
)
return sorted_candidates[:n]
def get_complementary_categories(self, category: str) -> list[str]:
"""Complementary categories"""
category_rules = self.rules[
self.rules['antecedents'].apply(lambda x: category in str(x))
]['consequents'].apply(lambda x: list(x)).explode().value_counts()
return category_rules.head(5).index.tolist()
Temporal Patterns: Next Purchase
class NextPurchasePredictor:
"""Predict next purchase based on history"""
def predict_next_items(self, user_id: str,
purchase_history: list[dict],
catalog_features: pd.DataFrame) -> list[tuple]:
"""
purchase_history: [{item_id, date, quantity, category}]
Returns: [(item_id, probability)]
"""
if len(purchase_history) < 3:
return []
# Repeat purchase patterns
item_intervals = {}
for i in range(1, len(purchase_history)):
item = purchase_history[i]['item_id']
prev_same = [h for h in purchase_history[:i] if h['item_id'] == item]
if prev_same:
days_between = (
pd.to_datetime(purchase_history[i]['date']) -
pd.to_datetime(prev_same[-1]['date'])
).days
if item not in item_intervals:
item_intervals[item] = []
item_intervals[item].append(days_between)
# Predict repeat purchases
predictions = []
last_purchase_date = pd.to_datetime(purchase_history[-1]['date'])
today = pd.Timestamp.now()
days_since_last = (today - last_purchase_date).days
for item_id, intervals in item_intervals.items():
avg_interval = np.mean(intervals)
std_interval = np.std(intervals) if len(intervals) > 1 else avg_interval * 0.3
# Probability via normal distribution
from scipy.stats import norm
prob = norm.cdf(days_since_last + 7, avg_interval, std_interval + 1)
prob -= norm.cdf(days_since_last - 7, avg_interval, std_interval + 1)
prob = min(max(prob, 0), 1)
if prob > 0.1:
predictions.append((item_id, prob))
return sorted(predictions, key=lambda x: x[1], reverse=True)[:10]
More on Apriori Tuning
Association rules with min_support=0.01, min_confidence=0.1 typically yield 500-5000 significant rules for 100K orders. Lift > 2.0 indicates a strong association. For hyperparameter tuning, we use cross-validation: split data by time (train/test) and select min_support, min_confidence that maximize lift on the validation set.
How to Train the CrossSellRecommender on Your Data
- Data preparation. Collect order history with columns: order_id, item_id, date. Minimum number of transactions — 10,000.
-
Run Apriori. Use the
fit_association_rulesmethod withmin_support=0.01andmin_confidence=0.1. This will give about 1000 rules. -
Tune parameters. Check the distribution of lift and confidence. If there are too many rules, increase
min_supportto 0.02 ormin_confidenceto 0.2. - Evaluate quality. Use precision@k and recall@k metrics on a held-out set. Rule-based cross-sell gives an average basket uplift of 15-25%. Combining with personalization (user history) adds another 5-10% to acceptance rate.
How to Measure Recommendation Quality?
Main offline metrics: precision@k, recall@k, lift@k. We use a truncated basket — hide part of items from each transaction and check if they appear in recommendations. Online metrics: acceptance rate, AOV uplift, share of carts with cross-sell. On an A/B test with 50% audience, typical acceptance rate uplift is 10-25%.
Why is the Hybrid Model More Effective Than Rule-Based?
Rule-based (e.g., "bought A → show B") does not scale to thousands of items: rules become sparse and don't account for context. ML finds high-lift associations even for rare pairs. In addition, the hybrid personalizes ranking: the same item is ranked differently for different users. In our projects, the acceptance rate of the hybrid is 3 times higher than that of rules.
Implementation Stages
| Stage | What We Do | Result | Duration |
|---|---|---|---|
| 1. Data audit | Check completeness and quality: number of SKUs, history length, order frequency | Metrics dashboard, list of gaps | 1-2 days |
| 2. Pipeline building | ETL to collect data from CRM, cleaning, basket aggregation | orders_df dataset | 3-4 days |
| 3. Rule training | Apriori, tuning min_support and min_confidence via cross-validation |
Rules file (.pkl) | 1-2 days |
| 4. Personalization | Train gradient boosting on user history | Ranking model | 3-5 days |
| 5. A/B test | 50% audience — old system, 50% — new. Measure conversion and AOV | Report with uplift | 7-14 days |
| 6. Production deployment | Integration via REST API, monitoring p99 latency | Documentation, dashboard | 2-3 days |
What's Included in the Result
- Documentation: description of rules, metrics, instructions for model updates.
- Source code: repository with modules
CrossSellRecommender,NextPurchasePredictor, configs and tests. - API endpoints: two methods —
recommend_by_cart(based on current cart) andrecommend_by_user(based on history). - Team training: we show how to interpret results and retrain the model.
- Guarantee: we support the A/B test until statistically significant uplift is achieved.
How to Estimate ROI for Your Business
| Metric | Typical Value | How We Calculate |
|---|---|---|
| Average order value increase | 15-25% | (AOV_test - AOV_control) / AOV_control * 100% |
| Acceptance rate | 5-12% | Clicks on recommendations / impressions |
| Share of carts with cross-sell | 30-50% | Carts with ≥1 recommended item / total carts |
| Payback time | 2-4 months | Implementation cost / monthly revenue increase |
To assess the cross-sell potential for your assortment, contact us. We will conduct a data audit and provide a prototype in 2 weeks with a confidentiality guarantee. Request a consultation — we'll discuss the details of your project and prepare a tailored proposal.







