Проблема, о которой никто не просил (но которая нужна всем)

Вы знаете это чувство, когда открываете новостную приложение, а там просто… хаос? Тысячи статей кричат о внимании, ни одна из них не знает ничего о вас, ваших интересах или о том, почему вы вообще захотели бы читать о квантовых вычислениях, если вы явно спортивный энтузиаст в 6 утра до того, как подействует ваш кофе. Именно эту проблему мы решаем сегодня.

Системы рекомендаций новостей — это невоспетые герои обнаружения контента. Именно они определяют разницу между пользователями, которые проводят пять минут, бесцельно прокручивая ленту (и уходят), и теми, кто проводит тридцать минут, увлечённо знакомясь с контентом, который действительно имеет для них значение. И если вы создаёте агрегатор новостей, то именно это отличает вас от общего шума.

Хорошая новость заключается в том, что вам не нужно иметь степень PhD в области машинного обучения или команду из сотни инженеров, чтобы создать что-то, что работает на удивление хорошо. Вам нужно понимание, стратегия и правильный подход для вашего случая использования.

Понимание ландшафта рекомендаций

Прежде чем мы начнём писать код, давайте определимся, что мы на самом деле пытаемся решить. Система рекомендаций новостей должна решать три фундаментальные задачи:

  1. Проблема холодного старта: когда у вас новые пользователи или новые статьи, у вас нет исторических данных об взаимодействиях, с которыми можно было бы работать.
  2. Разрежённость: пользователи взаимодействуют лишь с крошечной долей доступных статей, оставляя большинство предпочтений неизвестными.
  3. Разнообразие против релевантности: рекомендовать только то, что пользователям уже нравится, становится скучно; рекомендовать слишком много новинок их теряет.

Существует три основных подхода к решению этих задач, каждый со своей спецификой: Фильтрация на основе контента анализирует свойства статей (категории, ключевые слова, темы) и сопоставляет их с предпочтениями пользователей. Представьте себе библиотекаря, который помнит, что вы любите детективы, поэтому он продолжает рекомендовать вам детективы. Просто, предсказуемо, но иногда скучно.

Фильтрация на основе коллаборации смотрит на то, что понравилось пользователям, похожим на вас. Это учитывает социальную сеть: если 1000 человек, точно таких же как вы, прочитали и полюбили статью, то, вероятно, она понравится и вам. Это может выявить неожиданные жемчужины, но сталкивается с трудностями, когда данные разрежены.

Гибридные подходы сочетают оба метода, заимствуя лучшие части каждого и пытаясь компенсировать их слабые стороны. Обычно именно здесь происходит волшебство.

Архитектурная схема

Давайте посмотрим, как на самом деле собирается готовая к производству система:

graph TB A["RSS Feed Parser"] -->|Articles| B["Content Pipeline"] C["User Interactions"] -->|Clicks/Reads| D["Interaction Logger"] B -->|Processed Data| E["Recommendation Engine"] D -->|User Behavior| E E -->|Scores & Ranks| F["Ranking Module"] F -->|Final List| G["API Endpoint"] G -->|Recommendations| H["Client Application"] H -->|Feedback| D

Вот что происходит на каждом этапе: Парсер: извлекает статьи из RSS-каналов, вытаскивая заголовки, аннотации, категории, URL-адреса и метаданные.

Конвейер контента: очищает, токенизирует и векторизует содержание статей. Именно здесь вы извлекаете функции, которые будет использовать система рекомендаций.

Регистратор взаимодействий: записывает каждый клик, чтение, репост или сохранение. Это ваша золотая жила данных. Временные метки имеют большее значение, чем вы думаете.

Механизм рекомендаций: мозг операции. Он обрабатывает как функции контента, так и шаблоны поведения пользователей.

Модуль ранжирования: применяет бизнес-логику к сырым оценкам рекомендаций. Возможно, вы хотите обеспечить разнообразие, избежать дубликатов или уважать свежий контент.

Конечная точка API: предоставляет рекомендации в режиме реального времени вашим клиентам.

Реализация: давайте сделаем это

Шаг 1: Создание основы

import numpy as np
import pandas as pd
from datetime import datetime, timedelta
from typing import List, Dict, Tuple
from dataclasses import dataclass
from collections import defaultdict
@dataclass
class Article:
    """Represents a news article with its metadata"""
    article_id: str
    title: str
    content: str
    category: str
    url: str
    published_at: datetime
    source: str
    keywords: List[str]
@dataclass
class UserInteraction:
    """Records how a user interacted with an article"""
    user_id: str
    article_id: str
    interaction_type: str  # 'click', 'read', 'share', 'save'
    timestamp: datetime
    engagement_score: float  # 0.0 to 1.0
class NewsRecommendationSystem:
    """Main recommendation engine"""
    def __init__(self, articles: List[Article], interactions: List[UserInteraction]):
        self.articles = {a.article_id: a for a in articles}
        self.interactions = interactions
        self.user_profiles = self._build_user_profiles()
        self.article_vectors = self._vectorize_articles()
    def _build_user_profiles(self) -> Dict[str, Dict]:
        """Build preference profiles based on user interactions"""
        profiles = defaultdict(lambda: {
            'categories': defaultdict(float),
            'keywords': defaultdict(float),
            'sources': defaultdict(float),
            'interaction_count': 0,
            'last_active': None
        })
        for interaction in self.interactions:
            article = self.articles[interaction.article_id]
            user_id = interaction.user_id
            profile = profiles[user_id]
            # Weight interactions by recency and engagement
            time_weight = self._calculate_time_weight(interaction.timestamp)
            final_weight = interaction.engagement_score * time_weight
            profile['categories'][article.category] += final_weight
            profile['sources'][article.source] += final_weight
            for keyword in article.keywords:
                profile['keywords'][keyword] += final_weight
            profile['interaction_count'] += 1
            profile['last_active'] = max(
                profile['last_active'] or interaction.timestamp,
                interaction.timestamp
            )
        return dict(profiles)
    def _calculate_time_weight(self, interaction_time: datetime) -> float:
        """Recent interactions matter more than old ones"""
        days_ago = (datetime.now() - interaction_time).days
        # Exponential decay: halve the weight every 30 days
        return 0.5 ** (days_ago / 30.0)
    def _vectorize_articles(self) -> Dict[str, np.ndarray]:
        """Convert articles to numerical vectors"""
        # For simplicity, we'll use a basic approach
        # In production, use TF-IDF, word embeddings, or transformers
        vectors = {}
        unique_keywords = set()
        unique_categories = set()
        for article in self.articles.values():
            unique_keywords.update(article.keywords)
            unique_categories.add(article.category)
        keyword_list = sorted(list(unique_keywords))
        category_list = sorted(list(unique_categories))
        for article in self.articles.values():
            vector = []
            # Add category one-hot encoding
            for cat in category_list:
                vector.append(1.0 if article.category == cat else 0.0)
            # Add keyword presence
            for kw in keyword_list:
                vector.append(1.0 if kw in article.keywords else 0.0)
            vectors[article.article_id] = np.array(vector)
        return vectors

Шаг 2: Рекомендации на основе контента

from sklearn.metrics.pairwise import cosine_similarity
class ContentBasedRecommender:
    """Recommends articles similar to user's reading history"""
    def __init__(self, system: NewsRecommendationSystem):
        self.system = system
    def recommend(self, user_id: str, top_k: int = 10) -> List[Tuple[str, float]]:
        """Get content-based recommendations for a user"""
        if user_id not in self.system.user_profiles:
            return self._get_popular_articles(top_k)
        user_profile = self.system.user_profiles[user_id]
        user_interactions = [
            i for i in self.system.interactions if i.user_id == user_id
        ]
        if not user_interactions:
            return self._get_popular_articles(top_k)
        # Build user preference vector based on articles they've interacted with
        user_vector = self._build_user_vector(user_interactions)
        # Calculate similarity between user preferences and all articles