AI-система персоналізації новинної стрічки

Проектуємо та впроваджуємо системи штучного інтелекту: від прототипу до production-ready рішення. Наша команда поєднує експертизу в машинному навчанні, дата-інжинірингу та MLOps, щоб AI працював не в лабораторії, а в реальному бізнесі.
Показано 1 з 1Усі 1566 послуг
AI-система персоналізації новинної стрічки
Середній
~2-4 тижні
Часті запитання

Напрямки AI-розробки

Етапи розробки AI-рішення

Останні роботи

  • image_website-b2b-advance_0.webp
    Розробка сайту компанії B2B ADVANCE
    1285
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1198
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    902
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1122
  • image_logo-advance_0.webp
    Розробка логотипу компанії B2B Advance
    589
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    859

AI-система персоналізації новинної ленти

Персоналізація новинного фіду — це баланс між релевантністю та різноманітністю. Чиста оптимізація релевантності створює «бульбашки фільтрів» і знижує engagement через 2-3 тижні. Сучасні системи (Google News, Apple News) явно вводять компонент різноманітності і забезпечують експозицію точкам зору за межами еха-камери.

Багатофакторне ранжування

import numpy as np
import pandas as pd
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity

class NewsPersonalizationEngine:
    """Персоналізація новинного контенту"""

    def __init__(self):
        self.encoder = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2')

    def build_user_interest_profile(self,
                                     reading_history: list[dict],
                                     explicit_preferences: dict = None) -> dict:
        """
        Профіль інтересів з історії читання.
        reading_history: [{'article_id': ..., 'topic': ..., 'time_spent_sec': ..., 'completed': ...}]
        """
        if not reading_history:
            return {'topics': {}, 'is_cold_start': True}

        # Зважуємо інтереси: час читання + факт дочитування
        topic_weights = {}
        for article in reading_history:
            topic = article.get('topic', 'general')
            time_weight = min(article.get('time_spent_sec', 30) / 180, 1.0)  # Нормалізуємо на 3 хв
            completion_bonus = 0.5 if article.get('completed') else 0
            weight = time_weight + completion_bonus

            topic_weights[topic] = topic_weights.get(topic, 0) + weight

        # Нормалізація + затухання (старі інтереси важать менше)
        total = sum(topic_weights.values())
        normalized = {t: w / total for t, w in topic_weights.items()}

        # Топ інтересів для embedding профілю
        recent_titles = [a.get('title', '') for a in reading_history[-20:] if a.get('completed')]
        profile_embedding = None
        if recent_titles:
            profile_embedding = np.mean(
                self.encoder.encode(recent_titles, normalize_embeddings=True),
                axis=0
            )

        return {
            'topics': normalized,
            'top_interests': sorted(normalized.items(), key=lambda x: -x[1])[:5],
            'profile_embedding': profile_embedding,
            'is_cold_start': False,
            'explicit_preferences': explicit_preferences or {}
        }

    def score_article(self, article: dict,
                       user_profile: dict,
                       seen_topics_last_hour: list[str]) -> dict:
        """Багатофакторний скор статті для конкретного користувача"""
        topic = article.get('topic', 'general')
        topics = user_profile.get('topics', {})

        # === Релевантність ===
        topic_score = topics.get(topic, 0.05)  # Базовий інтерес до теми

        # Семантична схожість з профілем
        semantic_score = 0.5  # Дефолт для cold start
        profile_emb = user_profile.get('profile_embedding')
        if profile_emb is not None and article.get('embedding') is not None:
            semantic_score = float(cosine_similarity(
                profile_emb.reshape(1, -1),
                np.array(article['embedding']).reshape(1, -1)
            )[0, 0])

        relevance = topic_score * 0.4 + semantic_score * 0.6

        # === Свіжість ===
        hours_old = article.get('hours_since_published', 24)
        freshness = np.exp(-hours_old / 12)  # Період напіврозпаду 12 годин

        # === Якість ===
        quality_score = (
            article.get('engagement_rate', 0.5) * 0.4 +
            article.get('source_trust_score', 0.7) * 0.3 +
            min(article.get('word_count', 500) / 800, 1.0) * 0.3
        )

        # === Штраф різноманітності ===
        # Якщо тему вже видів недавно — знижуємо скор
        topic_seen_count = seen_topics_last_hour.count(topic)
        diversity_penalty = 0.9 ** topic_seen_count  # 0→1.0, 1→0.9, 2→0.81...

        # === Підвищення для breaking news ===
        breaking_boost = 1.5 if article.get('is_breaking') else 1.0

        # === Фінальний скор ===
        final_score = (
            relevance * 0.40 +
            freshness * 0.25 +
            quality_score * 0.20 +
            0.15  # Base noise для serendipity
        ) * diversity_penalty * breaking_boost

        return {
            'article_id': article.get('id'),
            'final_score': round(final_score, 4),
            'relevance': round(relevance, 3),
            'freshness': round(freshness, 3),
            'quality': round(quality_score, 3),
            'diversity_penalty': round(diversity_penalty, 3),
        }

    def rank_feed(self, articles: list[dict],
                   user_profile: dict,
                   max_items: int = 20,
                   diversity_floor: float = 0.15) -> list[dict]:
        """
        Фінальне ранжування фіду з обмеженням різноманітності.
        diversity_floor: мінімальна доля статей поза топ-3 темами користувача.
        """
        seen_topics = []
        scored = []

        for article in articles:
            score_data = self.score_article(article, user_profile, seen_topics)
            scored.append({**article, **score_data})

        scored.sort(key=lambda x: -x['final_score'])

        # Застосовуємо різноманітність: не більше 3 статей підряд з однієї теми
        result = []
        topic_counts = {}
        max_per_topic = max(2, max_items // len(user_profile.get('topics', {'general': 1})))

        for item in scored:
            if len(result) >= max_items:
                break

            topic = item.get('topic', 'general')
            if topic_counts.get(topic, 0) >= max_per_topic:
                continue

            result.append(item)
            topic_counts[topic] = topic_counts.get(topic, 0) + 1
            seen_topics.append(topic)

        # Забезпечуємо мінімум різноманітності: додаємо статті з інших тем
        if len(result) > 5:
            top_topics = set(list(topic_counts.keys())[:2])
            non_top_in_result = sum(1 for item in result if item.get('topic') not in top_topics)
            diversity_actual = non_top_in_result / len(result)

            if diversity_actual < diversity_floor:
                # Вставляємо статті з неохоплених тем
                for item in scored[len(result):]:
                    if item.get('topic') not in top_topics:
                        result.insert(len(result) // 2, item)  # Вставка у середину
                        if sum(1 for i in result if i.get('topic') not in top_topics) / len(result) >= diversity_floor:
                            break

        return result[:max_items]


class EngagementTracker:
    """Відстеження поведінки читача для оновлення профілю"""

    def update_profile_from_session(self, user_profile: dict,
                                     session_events: list[dict]) -> dict:
        """Інкрементальне оновлення профілю на основі сесії"""
        profile = user_profile.copy()
        topics = dict(profile.get('topics', {}))

        for event in session_events:
            topic = event.get('topic', 'general')
            action = event.get('action')
            value = event.get('value', 0)

            if action == 'completed_read':
                topics[topic] = topics.get(topic, 0) + 0.3
            elif action == 'quick_skip':
                topics[topic] = max(0, topics.get(topic, 0) - 0.1)
            elif action == 'share':
                topics[topic] = topics.get(topic, 0) + 0.5
            elif action == 'dislike':
                topics[topic] = max(0, topics.get(topic, 0) - 0.3)

        # Нормалізація
        total = sum(topics.values())
        if total > 0:
            profile['topics'] = {t: w / total for t, w in topics.items()}

        return profile

Правильно настроєна персоналізація збільшує time-on-site на 25-40% і DAU/MAU на 8-15%. Без обмеження різноманітності: короткострокове зростання engagement, довгострокове зростання churn через інформаційне виснаження. Google News відкрито публікує, що вводить різноманітність як explicit objective у ранжуванні.