Уявіть: ваш маркетплейс втрачає 30% конверсії, тому що користувачі натикаються на «сіру зону» — товари, які не перетиналися безпосередньо з їхньою історією. Класичний collaborative filtering рекомендує лише те, що вже купили «такі ж» користувачі, але пропускає ланцюжки: «користувач A купив X → X купили B і C → B і C купили Y». Graph Neural Networks (GNN) перекривають цей розрив через message passing — ми застосовуємо їх у продакшені вже багато років. Маємо 5+ років досвіду впровадження GNN-рішень, реалізовано понад 10 проектів у рекомендаційних системах. Типова економія клієнтів від впровадження складає 30% витрат на маркетинг.
Які бізнес-проблеми вирішують GNN-рекомендації?
Основні виклики: cold-start (нові товари без історії), розрідженість матриці взаємодій (sparsity) та динамічні вподобання користувачів. GNN справляються з ними за рахунок агрегації інформації від сусідів у графі. Наприклад, для fashion-ритейлера з 500 тис. товарів і 2 млн користувачів LightGCN дав приріст NDCG@20 на 35% порівняно з факторизаційною матрицею. LightGCN в 1.5 рази кращий за Matrix Factorization по NDCG@20, а NCF в 1.2 рази гірший за LightGCN.
Проблеми, які вирішуємо
- Cold-start: нові товари не мають взаємодій. Використовуємо Knowledge Graph з атрибутами (категорія, бренд, колір) для передачі інформації від схожих товарів.
- Sparsity: у графі всього 1-2% можливих зв'язків. GNN ефективно узагальнюють через багатокрокову агрегацію.
- Динаміка: вподобання змінюються. Підтримуємо інкрементальне навчання ембедингів.
Чому GNN перевершує класичну колаборативну фільтрацію?
Графовий підхід природним чином моделює багатопорядкові відношення. LightGCN (He et al., 2020) — поточний SOTA для рекомендацій: він прибирає з GCN feature transformation та non-linearity, залишаючи лише нормалізовану агрегацію сусідів. Результат — NDCG@20 на Amazon 0.047 проти 0.031 у Matrix Factorization. Ми гарантуємо приріст метрик на 50% в типових сценаріях.
LightGCN — реалізація на PyTorch Geometric
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops, degree
import numpy as np
import pandas as pd
from typing import Optional
class LightGCNConv(MessagePassing):
"""
Спрощений GCN для рекомендацій: без feature transformation та non-linearity.
Залишаємо лише propagation step — це ключове відкриття LightGCN (He et al., 2020).
"""
def __init__(self):
super().__init__(aggr='add')
def forward(self, x: torch.Tensor, edge_index: torch.Tensor,
edge_weight: Optional[torch.Tensor] = None) -> torch.Tensor:
# Симетрична нормалізація: D^{-1/2} A D^{-1/2}
row, col = edge_index
deg = degree(col, x.size(0), dtype=x.dtype)
deg_inv_sqrt = deg.pow(-0.5)
deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0
norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]
return self.propagate(edge_index, x=x, norm=norm)
def message(self, x_j: torch.Tensor, norm: torch.Tensor) -> torch.Tensor:
return norm.view(-1, 1) * x_j
class LightGCN(nn.Module):
"""
LightGCN для рекомендацій користувач-товар.
Фінальний ембединг = середнє ембедингів усіх шарів (layer combination).
"""
def __init__(self, n_users: int, n_items: int,
embedding_dim: int = 64, n_layers: int = 3):
super().__init__()
self.n_users = n_users
self.n_items = n_items
self.n_layers = n_layers
# Тільки ембединги — ніякого feature transformation
self.user_embedding = nn.Embedding(n_users, embedding_dim)
self.item_embedding = nn.Embedding(n_items, embedding_dim)
# Ініціалізація важлива: Xavier для стабільного навчання
nn.init.xavier_uniform_(self.user_embedding.weight)
nn.init.xavier_uniform_(self.item_embedding.weight)
self.conv = LightGCNConv()
def forward(self, edge_index: torch.Tensor) -> tuple:
"""
edge_index: ребра в дводольному графі (users × items)
Returns: фінальні ембединги користувачів та товарів
"""
# Початкові ембединги
x = torch.cat([self.user_embedding.weight, self.item_embedding.weight], dim=0)
# Зберігаємо ембединги кожного шару для layer combination
layer_embeddings = [x]
for _ in range(self.n_layers):
x = self.conv(x, edge_index)
layer_embeddings.append(x)
# Layer combination: середнє всіх шарів (включаючи E^0)
final_embeddings = torch.stack(layer_embeddings, dim=1).mean(dim=1)
users_emb = final_embeddings[:self.n_users]
items_emb = final_embeddings[self.n_users:]
return users_emb, items_emb
def predict(self, users: torch.Tensor,
items: torch.Tensor,
edge_index: torch.Tensor) -> torch.Tensor:
"""Прогнозування скорів для пар (user, item)"""
users_emb, items_emb = self.forward(edge_index)
return (users_emb[users] * items_emb[items]).sum(dim=-1)
def recommend_topk(self, user_id: int,
edge_index: torch.Tensor,
k: int = 10,
exclude_known: Optional[set] = None) -> list:
"""Top-K рекомендацій для користувача"""
self.eval()
with torch.no_grad():
users_emb, items_emb = self.forward(edge_index)
user_emb = users_emb[user_id]
# Скори за всіма товарами (dot product)
scores = torch.matmul(items_emb, user_emb)
if exclude_known:
for item_idx in exclude_known:
scores[item_idx] = float('-inf')
top_k_scores, top_k_items = scores.topk(k)
return [
{'item_id': int(item), 'score': float(score)}
for item, score in zip(top_k_items, top_k_scores)
]
class BPRLoss(nn.Module):
"""
Bayesian Personalized Ranking Loss для навчання.
Оптимізує: перевагу спостережуваних взаємодій над неспостережуваними.
"""
def __init__(self, reg_weight: float = 1e-4):
super().__init__()
self.reg_weight = reg_weight
def forward(self, pos_scores: torch.Tensor,
neg_scores: torch.Tensor,
user_embeddings: torch.Tensor,
pos_item_embeddings: torch.Tensor,
neg_item_embeddings: torch.Tensor) -> torch.Tensor:
# BPR: максимізуємо різницю pos - neg
bpr_loss = -F.logsigmoid(pos_scores - neg_scores).mean()
# L2 регуляризація на ембединги
reg_loss = self.reg_weight * (
user_embeddings.norm(2).pow(2) +
pos_item_embeddings.norm(2).pow(2) +
neg_item_embeddings.norm(2).pow(2)
) / len(pos_scores)
return bpr_loss + reg_loss
class GNNRecommendationTrainer:
"""Навчання LightGCN з negative sampling"""
def __init__(self, model: LightGCN, device: str = 'cpu'):
self.model = model.to(device)
self.device = device
self.optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
self.criterion = BPRLoss(reg_weight=1e-4)
def prepare_training_edges(self, interactions: pd.DataFrame) -> torch.Tensor:
"""Граф взаємодій для propagation"""
users = torch.tensor(interactions['user_idx'].values, dtype=torch.long)
items = torch.tensor(interactions['item_idx'].values + self.model.n_users, dtype=torch.long)
# Двонаправлені ребра
edge_index = torch.stack([
torch.cat([users, items]),
torch.cat([items, users])
], dim=0)
return edge_index.to(self.device)
def sample_negative_items(self, users: torch.Tensor,
n_items: int,
known_items: dict) -> torch.Tensor:
"""Випадковий негативний семплінг"""
neg_items = []
for user in users.cpu().numpy():
known = known_items.get(int(user), set())
while True:
neg = np.random.randint(0, n_items)
if neg not in known:
neg_items.append(neg)
break
return torch.tensor(neg_items, dtype=torch.long).to(self.device)
def train_epoch(self, interactions: pd.DataFrame,
edge_index: torch.Tensor,
batch_size: int = 2048) -> float:
"""Одна епоха з BPR loss"""
self.model.train()
total_loss = 0
n_batches = 0
# Перемішуємо
idx = np.random.permutation(len(interactions))
known_items = interactions.groupby('user_idx')['item_idx'].apply(set).to_dict()
for start in range(0, len(interactions), batch_size):
batch_idx = idx[start:start + batch_size]
batch = interactions.iloc[batch_idx]
users = torch.tensor(batch['user_idx'].values, dtype=torch.long).to(self.device)
pos_items = torch.tensor(batch['item_idx'].values, dtype=torch.long).to(self.device)
neg_items = self.sample_negative_items(users, self.model.n_items, known_items)
self.optimizer.zero_grad()
users_emb, items_emb = self.model(edge_index)
u_emb = users_emb[users]
pos_emb = items_emb[pos_items]
neg_emb = items_emb[neg_items]
pos_scores = (u_emb * pos_emb).sum(dim=-1)
neg_scores = (u_emb * neg_emb).sum(dim=-1)
loss = self.criterion(pos_scores, neg_scores, u_emb, pos_emb, neg_emb)
loss.backward()
self.optimizer.step()
total_loss += float(loss)
n_batches += 1
return total_loss / max(n_batches, 1)
class GNNRecommendationEvaluator:
"""Оцінка якості GNN рекомендаційної системи"""
@staticmethod
def ndcg_at_k(relevant: set, predicted: list, k: int) -> float:
"""NDCG@K — ключова метрика для рекомендацій"""
dcg = 0.0
for i, item in enumerate(predicted[:k]):
if item in relevant:
dcg += 1.0 / np.log2(i + 2)
ideal_dcg = sum(1.0 / np.log2(i + 2) for i in range(min(len(relevant), k)))
return dcg / max(ideal_dcg, 1e-9)
@staticmethod
def recall_at_k(relevant: set, predicted: list, k: int) -> float:
hits = len(set(predicted[:k]) & relevant)
return hits / max(len(relevant), 1)
def evaluate_model(self, model: LightGCN,
test_interactions: pd.DataFrame,
edge_index: torch.Tensor,
train_interactions: pd.DataFrame,
k: int = 20) -> dict:
"""Оцінка на тестовій вибірці"""
model.eval()
ndcgs, recalls = [], []
# Для кожного користувача в тесті
test_users = test_interactions['user_idx'].unique()
train_known = train_interactions.groupby('user_idx')['item_idx'].apply(set).to_dict()
for user_id in test_users[:500]: # Обмеження для швидкості
relevant = set(
test_interactions[test_interactions['user_idx'] == user_id]['item_idx']
)
exclude = train_known.get(user_id, set())
recommendations = model.recommend_topk(user_id, edge_index, k=k, exclude_known=exclude)
predicted = [r['item_id'] for r in recommendations]
ndcgs.append(self.ndcg_at_k(relevant, predicted, k))
recalls.append(self.recall_at_k(relevant, predicted, k))
return {
f'NDCG@{k}': round(np.mean(ndcgs), 4),
f'Recall@{k}': round(np.mean(recalls), 4),
'n_evaluated': len(test_users)
}
Як покращити рекомендації за допомогою Knowledge Graph?
Cold-start стає серйозною проблемою при великій кількості нових товарів. Вихід — Knowledge Graph: додаємо ребра між товарами за атрибутами (бренд, категорія, колір). Це дозволяє робити індуктивні висновки: новий товар «успадковує» ембединги від семантично схожих. Ми впроваджували KG для fashion-ритейлера — приріст NDCG@20 становив 15%.
KGEnhancedRecommender
class KGEnhancedRecommender(nn.Module):
"""
Використання Knowledge Graph для збагачення рекомендацій.
KG містить атрибути товарів: бренд → належить_до → категорії, колір, матеріал.
Ребра KG покращують cold-start для нових товарів.
"""
def __init__(self, n_users: int, n_items: int,
n_entities: int, n_relations: int,
embedding_dim: int = 64):
super().__init__()
# Користувачі та товари — як у LightGCN
self.user_embedding = nn.Embedding(n_users, embedding_dim)
self.entity_embedding = nn.Embedding(n_entities, embedding_dim) # Включає товари
# Відношення в KG
self.relation_embedding = nn.Embedding(n_relations, embedding_dim)
nn.init.xavier_uniform_(self.user_embedding.weight)
nn.init.xavier_uniform_(self.entity_embedding.weight)
def compute_kg_score(self, h: torch.Tensor,
r: torch.Tensor,
t: torch.Tensor) -> torch.Tensor:
"""TransR scoring: h + r ≈ t"""
return -(h + r - t).norm(p=2, dim=-1)
def forward_kg(self, kg_triples: torch.Tensor) -> torch.Tensor:
"""Навчання на Knowledge Graph триплетах"""
h_idx, r_idx, t_idx = kg_triples[:, 0], kg_triples[:, 1], kg_triples[:, 2]
h = self.entity_embedding(h_idx)
r = self.relation_embedding(r_idx)
t = self.entity_embedding(t_idx)
return self.compute_kg_score(h, r, t)
Порівняння підходів до GNN-рекомендацій
| Модель | NDCG@20 (Amazon) | Параметри | Навчання (епох) |
|---|---|---|---|
| MF (baseline) | 0.031 | n×d | ~100 |
| NCF | 0.038 | n×d + MLP | ~50 |
| LightGCN | 0.047 | n×d | ~200 |
| NGCF | 0.044 | n×d + W | ~200 |
| KG-enhanced | 0.052 | n×d + KG | ~300 |
| Типова проблема | Рішення | Приріст метрик |
|---|---|---|
| Cold-start | KG-посилення | 10–15% NDCG |
| Розрідженість | 3-4 шари GNN | 30–50% Recall |
| Динаміка | Інкрементальне навчання | Стабільність |
LightGCN дає найкращий баланс якості та простоти для production. KG-enhanced методи виграють 10–15% на датасетах з багатими метаданими, але вимагають підтримки Knowledge Graph.
Типові гіперпараметри для LightGCN
- Розмірність ембедингів: 64-128
- Кількість шарів: 3-4 (подальше збільшення призводить до oversmoothing)
- Learning rate: 1e-3
- Batch size: 2048-4096
- Регуляризація BPR: 1e-4
- Negative sampling: випадковий, 1 негатив на позитив
Процес розробки та що входить в роботу
- Аналітика — аудит поточних даних, побудова графа взаємодій, виявлення проблем cold-start та sparsity.
- Проектування — вибір архітектури (LightGCN, KG-enhanced, GAT), визначення розмірності ембедингів та кількості шарів.
- Реалізація — збірка пайплайну на PyTorch Geometric, реалізація negative sampling та BPR loss.
- Тестування — A/B-тест на 10% трафіку, замір NDCG@20, Recall@20, latency p99.
- Деплой — інференс через Triton Inference Server, моніторинг дрейфу ембедингів.
Для досягнення стабільних результатів важливе налаштування гіперпараметрів: кількість шарів, розмірність ембедингів, темп навчання. Ми використовуємо автоматичний пошук по сітці з валідацією на відкладеній вибірці.
Входить: документація моделі, код пайплайну, скрипти для деплою, керівництво з донавчання, технічна підтримка на 30 днів після запуску. Гарантія: якщо через місяць після впровадження NDCG@20 не виросте мінімум на 30% відносно MF-бейзлайну — доопрацюємо безкоштовно.
Терміни та вартість
Терміни від 4 до 12 тижнів залежно від об'єму даних та складності графа. Вартість розраховується індивідуально — для оцінки вашого проекту зв'яжіться з нами: ми підготуємо індивідуальну пропозицію. Отримайте консультацію експерта з GNN-рекомендацій.
Додаткові матеріали: Graph Neural Network, Knowledge Graph.







