На mir-kvestov.ru нужно давать рекомендации пользователям, про которых мы почти ничего не знаем: большинство не авторизованы, истории просмотров нет, на сайте только точный поиск по названию квеста. Т.е. не было даже нормальной истории текстовых запросов, из которой можно было бы собрать частотные подсказки или похожие запросы. Я обучил решающее дерево на 6500 анкетах пользователей, превратив 60 вопросов анкеты в 5 кликов по чипсам под строкой поиска. Так появилась фича, которая за пять шагов отправляет человека в нужный тип квестов. По пути пришлось согласовать математическую модель с пониманием стейкхолдеров о том «как правильно». Из этого конфликта родилось гибридное дерево, понятное и людям, и метрикам.

Я буду вести повествование от лица программиста-аналитика данных и менеджера продукта — двух ролей который исполняю в Мире Квестов. Но сначала хочу поблагодарить мою команду: Дарю Гурину и Марию Подколзину за првоедение опроса. Сергея Коваленко за внедрение схемы в прод и Евгения Кудрявцева и Игоря Бобба за обсуждения и А/Б тесты.
Как рекомендовать квесты, если мы ничего не знаем про пользователя
Мир Квестов — агрегатор квестов. Пользователь приходит «за чем-то интересным», но:
большая часть не авторизована;
нет истории просмотров и бронирований;
нет истории поисковых запросов, кроме точного поиска по названию.
То есть классические подходы «рекомендуем по похожим» или «по истории просмотров» не работают: истории нет. Частотные подсказки по вводу в строку поиска тоже бесполезны — человек не знает, что искать: он хочет «нестрашный квест для подростков на Новый год про магию и драконов», а не «Последний дракон».
Решение на уровне UX такое: под строкой поиска добавляем серия чипсов.

Пользователь кликает по вариантам ответа, за пять шагов «накликивает» себе фильтры, а дальше уже показывается список подходящих квестов из базы (которая заранее размечена по страшности, формату, тематикам, сеттингам, возрасту и т.п.), результат отсортирован по конверсии и рейтингам.
Возникает резонный вопрос — как мы определили порядок вопросов и варианты ответа для каждого? Да так, чтобы давать наилучшую рекомендацию за минимум шагов. Все дело в том, что с самого начала у нас была какая-то стратегия, и мы ее придерживались.
Сбор данных: большая анкета и 6500 респондентов
Прежде чем что-то автоматизировать, нужно понять, как люди вообще выбирают квесты. Мы собрали длинную анкету примерно из 60 вопросов про предпочтения: формат, страшность, тематики, возраст и так далее. Затем собрали 6.500 таких анкет с наших клиентов.

Ключевой момент: каждый респондент не просто отвечал на вопросы, но и реально ходил на какой-то квест. У нас была связка: анкета пользователя + квест, который он в итоге посетил.
В базе было больше 2500 квестов по всей России. Обучать модель напрямую с 2500+ классами бесполезно — много редких классов, разреженная статистика и трудно интерпретировать. Поэтому первым шагом я сгруппировал квесты в кластеры. Но перед этим, давайте взглянем на кластеры самих пользователей.
Чего хотят наши пользователи
Анкеты — кладезь знаний о предпочтениях наших игроков. Что он может рассказать и как извлечь инсайты?
Я выделил основные сегменты пользователей. Для чего кластеризовал анкеты методом k-means, а затем описал кластеры методом топ-N ключевых признаков. Это делается вот как:
Считаем среднюю частотность каждого признака во всем датасете (отношение числа анкет с этим признаков к общему числу анкет)
Затем то же самое считаем для каждого признака в каждом классе
Сравниваем результат: те признаки, который в каком-то классе встречаются гораздо чаще, чем во всей выборке в целом и есть наши ключевые признаки.
Затем можно построить вот такую паутинчатую диаграмму, и сравнить профили кластеров визуально.

На диаграмме видно, что профили четырех сегментов покупателей отличаются. Две группы (синяя и зеленая) тяготеют к страшным квестам и хоррорам, а две другие (красная и оранжевая) к страшным квестам равнодушны, и предпочитают другие тематики.
На основании этих данных я выделил, и попросил нейросеть сгенерировать портреты, четырех групп покупателей.

В таком разрезе видно, что 28% респондентов либо не любит страшные квесты вообще, либо ценят страшность меньше, чем другие аспекты игрового опыта. А наш сайт визуально и концептуально ориентирован на аудиторию любителей хорроров. Вот и диссонанс.
Есть значительный сегмент людей, в которых визуальный стиль сайта, может не вполне попадать. Может быть они будут бронировать чаще, если дать им удобный инструмент поиска подходящих квестов.
Кластеризация квестов: от 2500 квестов к 12 «сетам»
Вернемся к опросу. Напомню, что моей первой задачей было схлопнуть число классов с 2.,500 до небольшого числа, подходящего для обучения модели на размеченных данных. Enter кластеризация.
У каждого квеста в БД есть описательные признаки: тематики, формат, возраст, уровень страшности, сложности и т.п. Из этого мы собираем несколько наборов признаков. Я экспериментировал с векторизацией текстовых описаний, их tf-idf кодированием, ценами, городами и оценками игроков, но в итоге лучшие метрики кластеризации дал простой one-hot по страшности, сложности и тематикам.
Перебрав несколько моделей кластеризации я остановился на k-means, дававший неплохие метрики на 6 и 12 кластерах. Гиперпараметры перебирал по метрикам:
Силуэтный коэффициент
Калински-Харабаз
Дэвис-Болдуин

Пример кода для перебора параметров и отрисовки графика:
# Делаем one-hot
from sklearn.preprocessing import MultiLabelBinarizer
mlb = MultiLabelBinarizer()
tag_matrix = mlb.fit_transform(df_result['Категории квестов one-hot'])
# Преобразуем в DataFrame с метками столбцов
tags_df = pd.DataFrame(tag_matrix, columns=mlb.classes_)
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score, calinski_harabasz_score, davies_bouldin_score
from sklearn.preprocessing import normalize
from tqdm import tqdm
X = normalize(tags_df.values)
# Диапазон количества кластеров
cluster_range = range(2, 40)
# Список для метрик и датафрейм для меток
results = []
all_labels_df = pd.DataFrame(index=tags_df.index)
for k in tqdm(cluster_range, desc="Подбор кластеров"):
model = KMeans(n_clusters=k, random_state=42, n_init='auto')
labels = model.fit_predict(X)
silhouette = silhouette_score(X, labels)
ch_score = calinski_harabasz_score(X, labels)
db_score = davies_bouldin_score(X, labels)
largest_cluster = pd.Series(labels).value_counts(normalize=True).max()
results.append({
"n_clusters": k,
"Silhouette": silhouette,
"Calinski-Harabasz": ch_score,
"Davies-Bouldin": db_score,
"Largest Cluster %": largest_cluster
})
all_labels_df[f"cluster_k_{k}"] = labels
# Преобразуем в DataFrame
results_df = pd.DataFrame(results)
# Построение одного графика с тремя осями
fig, ax1 = plt.subplots(figsize=(12, 6))
ax1.plot(results_df["n_clusters"], results_df["Silhouette"], label="Silhouette", color='blue', marker='o')
ax1.set_ylabel("Silhouette Score", color='blue')
ax1.tick_params(axis='y', labelcolor='blue')
# Вторая ось -- Calinski-Harabasz
ax2 = ax1.twinx()
ax2.plot(results_df["n_clusters"], results_df["Calinski-Harabasz"], label="Calinski-Harabasz", color='green', marker='x')
ax2.set_ylabel("Calinski-Harabasz", color='green')
ax2.tick_params(axis='y', labelcolor='green')
# Третья ось -- Davies-Bouldin (на другой шкале)
ax3 = ax1.twinx()
ax3.spines.right.set_position(("axes", 1.15))
ax3.plot(results_df["n_clusters"], results_df["Davies-Bouldin"], label="Davies-Bouldin", color='red', marker='s')
ax3.set_ylabel("Davies-Bouldin (меньше -- лучше)", color='red')
ax3.tick_params(axis='y', labelcolor='red')
plt.title("Метрики кластеризации по числу кластеров (KMeans, one-hot)")
ax1.set_xlabel("Количество кластеров")
plt.tight_layout()
plt.show()
# Вывод метрик
results_df
Алгоритм k-means требовал от меня выбрать число классов, на которое я бы хотел поделить данные заранее. Важную роль здесь играл баланс классов после кластеризации. Квесты имеют большой перекос в сторону страшных перформансов, и я проверял баланс классов для разных вариантов разбиения.

Интерпретация классов
В итоге выбрал вариант на 10 кластеров — это хороший баланс между детализацией и интерпретируемостью. Но что они означают? Математика просто разделила квесты на 10 «кучек», но мне было интересно найти «физический смысл» такой классификации.
Я применил уже продемонстрированный мной выше метод метод top-k признаков среднего для каждого класса. Вот пример для разбиения на 5 классов. На паучковой диаграмме каждого класса видны наиболее выделяющиеся в каждом классе тематики:

Видно, что все квесты можно поделить на:
Фэнтези и приключения по фильмам
Хоррор
Детективы и расследования
Состязания и прятки в лабиринтах
Криминал, расследования и головоломки
Обучение решающего дерева: как сжать 60 вопросов до 5 шагов
Кластеризация позволила сжать число классов и датасете опроса с 2500 до 10, и сделала обучение дерева возможным. Однако оставалась вторая проблема размерности — 60 вопросов в анкете.
Теоретически можно было бы просто разместить эту анкету на главной странице сайта. Посетители бы заполняли анкету и получали, почти наверное, точную рекомендацию. Но сложно представить юзера, которых будет отвечать на 60 вопросов чтобы забронировать квест. Сжать 60 вопросов до 5 помогло решающее дерево.

Я пробовал и другие модели: линейная регрессия, решающий лес, XGBoost и т.д. Однако для интерпретируемости мне больше подходило дерево. Обучив решающий лес я мог потом аппроксимировать его каким-то средним деревом, тоже превратив в диаграмму. Но после всех экспериментов я остановился на обычном decisionTreeClassifier из библиотеки scikitlearn.
Упрощенный пример кода обучения дерева с подбором гиперпараметров:
import pandas as pd
import numpy as np
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, f1_score
from tqdm import tqdm
# Столбцы, которые НЕ признаки
non_feature_cols = [
'QUEST_ID',
'cluster_k_8', 'cluster_k_8_name',
'cluster_k_21', 'cluster_k_21_name',
'cluster_final', 'cluster_final_name'
]
# Выделим X и y
X = df_merged.drop(columns=non_feature_cols)
y = df_merged['cluster_final']
# Делим на train/test
X_train, X_test, y_train, y_test = train_test_split(
X, y,
stratify=y,
test_size=0.2,
random_state=42
)
results = []
for max_depth in range(2, 16):
for min_samples_leaf in [1, 3, 5, 10]:
clf = DecisionTreeClassifier(
max_depth=max_depth,
min_samples_leaf=min_samples_leaf,
random_state=42
)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
acc = accuracy_score(y_test, y_pred)
f1 = f1_score(y_test, y_pred, average='weighted')
results.append({
'max_depth': max_depth,
'min_samples_leaf': min_samples_leaf,
'accuracy': acc,
'f1_weighted': f1
})
results_df = pd.DataFrame(results)
results_df = results_df.sort_values(by='f1_weighted', ascending=False)
results_df.head(10)
В итоге я подобрал разумную глубину дерева (ориентир — чтобы путь до листа был порядка 5—6 вопросов) и минимальный размер листа, чтобы не переобучаться на редких сочетаниях ответов.
Сокращение анкеты до 5 шагов оставило сравнительно высокую точность попадания в «правильный» кластер. То есть, чтобы понять, к какому типу квестов отнести пользователя, достаточно 4–5 информативных вопросов, если правильно выбирать сплиты.
Когда математика не нравится стейкхолдерам
Кажется теперь можно просто взять дерево вопросов и ответов, превратить в JSON и попросить бекендеров запилить это на сайт под строку поиска. Но есть проблема: дерево бинарно, пользователи — нет.
Вот фрагмент диаграммы одного из деревьев, получившихся на данных опроса. Видите проблему? Кроме опечатки в слове страшные.

Реальный пользователь не хочет отвечать на вопросы в стиле «нравится ли вам признак 137: “сверхъестественное и ужасы”? да/нет».
Ему удобнее видеть множественные варианты ответов, например:
«Квест в реальности / Перформанс / Экшн-игра»;
«Нестрашный / Немного страшный / Страшный»;
«Логический / Экшн / Детектив / Ужасы»;
4–5 вариантов жанров и тематик.

Плюс, люди хотят следовать понятной им логике. И не только люди, но и стейкхолдеры. У бизнеса есть понимание, как рекомендовать квесты. Например они уверены, что вопросы должны идти именно в таком порядке:
Сначала страшный или нет.
Потом формат: квест или перформанс.
Потом возраст.
Потом уже тематики и жанры.
Решающему дереву, напротив, нет никакого дела до предпочтений стейкхолдеров: дерево может начать с тематики, потом спросить про формат, а про возраст вообще не спросить, если он мало влияет на выбор кластера.
В результате в какой-то момент мы приходим к разговору:
Я: «Смотрите, вот дерево говорит сначала спрашивать про “дом маньяка”, а уже потом про страшность».
Стейкхолдеры: «Нет, так нельзя, это не user-friendly, и вообще мы так никогда квесты не продавали».
То есть я должен был сделать дерево, которое работает корректно с точки зрения информации, но выглядит логично с точки зрения бизнеса. А затем убедить бизнес что дерево корректно математически.
Визуализация дерева через D3.js: разговариваем не про модель, а про картинку

Чтобы обсуждать дерево с людьми, для которых «энтропия» и «информационный выигрыш» не главное, я сделал отдельное веб-приложение, которое визуализирует дерево. Бэкенд — маленький Flask-сервис, который отдает JSON с описанием дерева.
Вот пример кода такого приложения:
from flask import Flask, render_template, jsonify
import os
import json
app = Flask(__name__)
@app.route('/')
def index():
# Получаем все json-файлы из текущей директории
json_files = [f for f in os.listdir('.') if f.endswith('.json')]
# Сортируем по времени модификации (от новых к старым)
json_files.sort(key=lambda f: os.path.getmtime(f), reverse=True)
return render_template('tree.html', json_files=json_files)
@app.route('/tree_data/<filename>')
def tree_data(filename):
# Безопасность: принимаем только .json файлы из текущей директории
if not filename.endswith('.json'):
return jsonify({"error": "Invalid file extension"}), 400
try:
with open(filename, encoding='utf-8') as f:
data = json.load(f)
return jsonify(data)
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == '__main__':
app.run(debug=True)
Забавный факт. Первую версию веб-сервиса я выложил на сервер в режиме debug=True и поделился с коллегами. Текстовый файл с содержанием «я здесь был» появился на сервере почти сразу. И файл этот создал не я.
Не выкладывайте на прод приложения в дебаг-режиме. Но вернемся к коду.
Фронтенд на D3.js читает JSON и рисует дерево. Структура одного такого дерева выглядит примерно так:
{
"description": "Средний информационный выигрыш = 0.75, максимальный = 1.94. Дерево на всех квестах-партнерах в РФ. Тип Квест/Перформанс/Экшн-игра. Сплит на 3 ветки + Other, глубина 4.",
"depth": 0,
"features": [
"Страшный",
"Хоррор",
"Дом маньяка"
],
"info_gain": 0.30624979575441724,
"samples": 5910,
"entropy": 2.044787921111091,
"branches": {
"OTHER": {
"depth": 1,
"features": [
"Логический квест",
"Тюрьма",
"Детектив"
],
"info_gain": 0.11151725363072296,
"samples": 1053,
"entropy": 1.955503098928942,
"branches": {
"Прятки": {
"depth": 4,
"samples": 12,
"entropy": 1.6258145836939115,
"is_leaf": true
},
"Экшн": {
"depth": 4,
"samples": 13,
"entropy": 1.7381493331928664,
"is_leaf": true
}
},
"is_leaf": false
}
}
}
Каждый узел знает:
глубину;
набор признаков (features), по которым он сплитится;
информационный выигрыш;
количество анкет, которые сюда попали;
ветки с названиями вроде «Экшн», «Прятки», «OTHER».
Когда стейкхолдеры видят не абстрактную «модель», а дерево с понятными словами «страшный», «дом маньяка», «логический квест», разговор становится намного проще. Можно пальцем показать: вот здесь мы спрашиваем про страшность, вот здесь — про формат, вот здесь — про тематику.
Гибридное дерево: Начало вручную, концы алгоритмом
Следующий шаг — примирить порядок вопросов. Мы договорились о гибридной схеме:
первые уровни дерева фиксируются так, как логично бизнесу: страшный/нестрашный, формат (квест/перформанс/экшн-игра), возраст;
дальше, на более глубоких уровнях, уже используется структура, которую предлагает дерево: тематики, жанры, сеттинги.
Важный момент: я не просто «сломал» модель под бизнес. Я проверил, что не убиваю информативность. Для чего считал информационный выигрыш в тех узлах, которые «нарисовал» бизнес.
Оказалось, что стейкхолдеры были в целом правы: вопросы «страшный или нет» и «формат квеста» действительно дают хороший информационный выигрыш в начале дерева. То есть их интуитивная схема реально снижает неопределенность.
Мультивариативные узлы дерева
Еще одна инженерная деталь: классическое решающее дерево — бинарное. А мы хотим показывать пользователю вопрос с несколькими вариантами ответа в одном шаге.
Например, у нас в данных есть отдельные бинарные признаки:
Квест в реальности
Перформанс
Экшн-игра
В дереве это могут быть несколько узлов в разветвленной структуре. На фронте же это удобно собрать в один мультивариантный узел. По этому я написал кастомное дерево с мультивариативными узлами.
Я хотел в одном узле сразу выбирать несколько самых информативных признаков и делать по ним несколько веток плюс «OTHER» для всего остального. Поэтому я сделал простой перебор комбинаций признаков фиксированного размера n_branches и для каждой комбинации считал информационный выигрыш: энтропия до сплита минус взвешенная сумма энтропий по всем веткам (включая ветку OTHER).
Вот пример кода: перебираем комбинации признаков, считаем для каждой информационный выигрыш и выбираем лучшую. Веток несколько: для каждого признака своя ветка (где он равен 1) и ещё ветка OTHER, куда попадают анкеты, у которых ни один из этих признаков не включён.
import math
from itertools import combinations
from collections import Counter
import pandas as pd
def entropy(labels: pd.Series) -> float:
total = len(labels)
if total == 0:
return 0.0
counts = Counter(labels)
h = 0.0
for c in counts.values():
p = c / total
h -= p * math.log2(p)
return h
def evaluate_split(df: pd.DataFrame,
combo,
target_col: str):
"""
combo -- набор признаков (например, ("Хоррор", "Дом маньяка"))
Строим ветки: по каждому признаку + ветка OTHER
"""
parent_labels = df[target_col]
h_parent = entropy(parent_labels)
total = len(df)
branches = {}
# Ветка для каждого признака: где он равен 1
for feat in combo:
mask = df[feat] == 1
branch_labels = df.loc[mask, target_col]
if len(branch_labels) > 0:
branches[feat] = branch_labels
# Ветка OTHER: где ни один признак из combo не включен
other_mask = df[list(combo)].eq(1).any(axis=1) == False
other_labels = df.loc[other_mask, target_col]
if len(other_labels) > 0:
branches["OTHER"] = other_labels
# Если веток меньше двух -- сплит неинтересен
if len(branches) < 2:
return 0.0, {}
# Энтропия после сплита -- взвешенная сумма по веткам
h_after = 0.0
for name, labels in branches.items():
weight = len(labels) / total
h_after += weight * entropy(labels)
info_gain = h_parent - h_after
return info_gain, branches
def build_information_tree(df: pd.DataFrame,
feature_cols,
target_col='cluster_number',
n_branches=3,
max_depth=2,
depth=0):
# Условие останова
if depth >= max_depth or df.empty or len(feature_cols) < n_branches:
return {
"depth": depth,
"samples": len(df),
"entropy": entropy(df[target_col]),
"is_leaf": True,
}
best_gain = -1.0
best_combo = None
best_branches = None
# Перебираем все комбинации признаков заданного размера
for combo in combinations(feature_cols, n_branches):
gain, branches = evaluate_split(df, combo, target_col)
if gain > best_gain:
best_gain = gain
best_combo = combo
best_branches = branches
# Если не нашли полезный сплит -- делаем лист
if best_branches is None or best_gain <= 0:
return {
"depth": depth,
"samples": len(df),
"entropy": entropy(df[target_col]),
"is_leaf": True,
}
# Рекурсивно строим поддеревья для каждой ветки
children = {}
remaining_features = [f for f in feature_cols if f not in best_combo]
for branch_name, labels in best_branches.items():
branch_df = df.loc[labels.index]
children[branch_name] = build_information_tree(
df=branch_df,
feature_cols=remaining_features,
target_col=target_col,
n_branches=n_branches,
max_depth=max_depth,
depth=depth + 1,
)
return {
"depth": depth,
"features": list(best_combo),
"info_gain": best_gain,
"samples": len(df),
"entropy": entropy(df[target_col]),
"branches": children,
"is_leaf": False,
}
Интеграция на сайт: пять кликов по чипсам

В итоге пользовательский сценарий выглядит так:
Пользователь вводит что-то в строку поиска или вообще ничего не вводит.
Под строкой видит чипсы первого вопроса (страшность / формат и т.п.).
Кликает по чипсам, вопросы сменяются, дерево спускается.
За 5 кликов мы собираем набор фильтров вроде «нестрашный квест / для подростков / на Новый год / про магию и драконов».
Эти фильтры применяются к базе квестов, результаты сортируются по текущей логике сайта (конверсии, рейтинги и т.п.).
База квестов заранее размечена по нужным признакам. Дерево фактически управляет только порядком и набором вопросов; отфильтрованный и отсортированный список — результат уже существующей механики сайта.
АБ-тест и результат
Мне хотелось бы рассказать, как с помощью этой математики удалось вырастить продажи x10, но увы. Такое бывает только в кейсах Бизнес молодости. Нише квестов уже 10 лет. Паттерны бизнеса и пользователей уже сформировались, и здесь сложно ожидать прорыва. Предложенная система на А/Б тесте в проде показала результат на 7% выше старого поиска.
Кроме того мы улучшили пользовательский опыт. Я провел 10 качественных интервью с пользователями из разных сегментов и еще несколько опросов. Выяснилось что есть сегмент пользователей, которым эта фича очень заходит. Как правило это люди ~18 лет, любители ярких эмоций, не утруждающиеся скрупулезным анализом предложения. «Клац-клац-клац и готово, я довольна» — прямая речь респондента.

Что из этого можно забрать к себе
Если в вашем продукте:
мало или нет истории поведения;
большой каталог объектов (товары, курсы, квесты, отели);
стейкхолдеры знают «как правильно спрашивать пользователя»,
Вам может помочь подход с гибридным деревом. Пошаговый рецепт такой:
Собрать анкету и реальные пары анкета — выбор пользователя.
Сжать пространство классов через кластеризацию и описать кластеры человеческими тегами.
Обучить решающее дерево, подобрать глубину и параметр min_samples_leaf.
Визуализировать дерево и обсуждать его со стейкхолдерами не в виде «модели», а в виде картинок.
Зафиксировать первый–второй уровень под бизнес-логику, проверив метрики (энтропию, инфовыигрыш), а глубже оставить дереву свободу.
Поверх бинарного дерева построить мультивариантные узлы, которые можно превратить в чипсы или селекты.
Запустить AB-тест и проверить, что новый сценарий хотя бы не хуже, а лучше — чуть лучше.
В нашем случае на Мире Квестов это вылилось во вполне живую систему: пользователю — пять кликов до внятной рекомендации, бизнесу — дерево, которое можно глазами посмотреть и обсудить, а модели — возможность честно уменьшать энтропию, даже если первый вопрос в дереве теперь выбирает не она одна.