Деревья решений остаются одним из самых интуитивно понятных и мощных инструментов в арсенале специалиста по данным. Выбор критерия разделения деревьев без преувеличения влияет на все: от скорости обучения до интерпретируемости результатов.
В процессе работы с финансовыми данными я обнаружил, что разные критерии могут давать совершенно разные результаты даже на одних и тех же данных. Некоторые методы отлично работают с несбалансированными выборками, другие — с непрерывными переменными, третьи — демонстрируют устойчивость к выбросам. В этой статье мы рассмотрим методы разделения узлов деревьев решений, их особенности, достоинства и недостатки.
Теоретические основы разделения узлов
Процесс построения дерева решений представляет собой последовательность оптимизационных задач. На каждом шаге алгоритм должен найти такое разделение данных, которое максимально повышает однородность получаемых подмножеств. Математически это формулируется как задача минимизации функции потерь или максимизации информационного выигрыша.
Ключевая идея заключается в том, что мы хотим создать разделения, которые максимально снижают неопределенность в предсказаниях. Если до разделения у нас была смесь различных классов или значений, то после разделения каждая ветка должна содержать более однородные данные. Это напоминает процесс дистилляции в химии — мы последовательно очищаем наши данные от примесей, получая все более чистые группы.
Математически процесс разделения можно представить как функцию качества:
Q(S, A)
где:
- S — множество примеров в узле,
- A — атрибут для разделения.
Цель состоит в том, чтобы найти такой атрибут A*, который максимизирует эту функцию:
A* = argmax Q(S, A).
Различные критерии разделения определяют разные способы вычисления этой функции качества.
Важно понимать, что выбор критерия влияет не только на конечное качество модели, но и на процесс обучения. Некоторые критерии более склонны к переобучению, другие — к созданию глубоких деревьев, третьи — к выбору определенных типов признаков. Это делает понимание их особенностей крайне важным для практического применения.
Индекс Gini
Коэффициент Gini, названный в честь итальянского статистика Коррадо Джини, изначально разрабатывался для измерения неравенства доходов. В контексте деревьев решений он измеряет степень неоднородности узла. Математически индекс Gini определяется как:
Gini(S) = 1 — Σ(pi)²
, где pi — доля примеров класса i в множестве S.
Минимальное значение Gini равно 0 (идеальная однородность), максимальное — 0.5 для бинарной классификации. Интуитивно, индекс Gini можно интерпретировать как вероятность неправильной классификации случайно выбранного элемента, если мы присваиваем ему класс согласно распределению классов в узле.
В процессе работы с финансовыми данными я заметил, что Gini особенно эффективен при работе с несбалансированными выборками. Это связано с тем, что он учитывает квадрат долей классов, что делает его более чувствительным к доминирующим классам. Например, при анализе событий дефолта, где положительных случаев может быть всего 5-10%, Gini часто показывает лучшие результаты по сравнению с другими критериями.
Давайте рассмотрим следующий пример применения Gini:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.tree import DecisionTreeClassifier, plot_tree
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.metrics import confusion_matrix
# Генерация данных
np.random.seed(42)
n_samples = 1000
returns = np.random.normal(0.001, 0.02, n_samples)
volatility = np.abs(np.random.normal(0.015, 0.005, n_samples))
volume = np.random.lognormal(15, 1, n_samples)
rsi = np.random.beta(2, 2, n_samples) * 100
signal_strength = (returns * 50 +
(volatility - 0.015) * 30 +
np.log(volume / np.mean(volume)) * 10 +
(rsi - 50) * 0.1)
noise = np.random.normal(0, 2, n_samples)
direction = (signal_strength + noise > 0).astype(int)
data = pd.DataFrame({
'returns': returns,
'volatility': volatility,
'volume': volume,
'rsi': rsi,
'direction': direction
})
X = data[['returns', 'volatility', 'volume', 'rsi']]
y = data['direction']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
# Обучение дерева
clf = DecisionTreeClassifier(
criterion='gini',
max_depth=6,
min_samples_split=50,
min_samples_leaf=20,
random_state=42
)
clf.fit(X_train, y_train)
# Оценка
train_score = clf.score(X_train, y_train)
test_score = clf.score(X_test, y_test)
cv_scores = cross_val_score(clf, X, y, cv=5, scoring='accuracy')
print(f"Точность на обучающей выборке: {train_score:.4f}")
print(f"Точность на тестовой выборке: {test_score:.4f}")
print(f"Средняя точность CV: {cv_scores.mean():.4f} (+/- {cv_scores.std() * 2:.4f})")
# Важность признаков
feature_importance = pd.DataFrame({
'feature': X.columns,
'importance': clf.feature_importances_
}).sort_values('importance', ascending=False)
print("\nВажность признаков:")
print(feature_importance)
# Предсказания
y_pred = clf.predict(X_test)
# Визуализация 1: Матрица ошибок
cm = confusion_matrix(y_test, y_pred)
plt.figure(figsize=(5, 4))
sns.heatmap(cm, annot=True, fmt="d", cmap='Blues', xticklabels=["⬇", "⬆"], yticklabels=["⬇", "⬆"])
plt.title("Матрица ошибок")
plt.xlabel("Предсказано")
plt.ylabel("Истинное значение")
plt.tight_layout()
plt.show()
# Визуализация 2: Структура дерева решений
plt.figure(figsize=(20, 6))
plot_tree(clf, feature_names=X.columns, class_names=["⬇", "⬆"], filled=True, rounded=True)
plt.title("Структура дерева решений")
plt.show()
Точность на обучающей выборке: 0.9414
Точность на тестовой выборке: 0.9400
Средняя точность CV: 0.9160 (+/- 0.0676)
Важность признаков:
feature importance
2 volume 0.874315
3 rsi 0.118263
1 volatility 0.007422
0 returns 0.000000

Рис. 1: Матрица ошибок обучения модели с Gini

Рис. 2: Структура дерева решений с Gini
Приведенный код демонстрирует практическое применение критерия Gini для классификации финансовых данных. Особенность этого примера заключается в том, что мы создаем нелинейную зависимость между признаками и целевой переменной, что более точно отражает реальные условия финансовых рынков.
Результаты показывают, что дерево с критерием Gini эффективно выявляет важные признаки и создает интерпретируемую модель. Важность признаков, вычисляемая через снижение индекса Gini, дает представление о том, какие факторы наиболее значимы для принятия решений. В финансовом контексте это может означать, что объем торгов, индикаторы и волатильность являются более важными предикторами направления движения цены, чем сами доходности.
Критерий Gini обладает рядом преимуществ, которые делают его популярным выбором:
- Во-первых, он вычислительно эффективен — не требует логарифмических вычислений;
- Во-вторых, он естественным образом работает с многоклассовой классификацией;
- В-третьих, он менее чувствителен к небольшим изменениям в данных по сравнению с информационными критериями.
Энтропия и информационный выигрыш (Information Gain)
Энтропия, заимствованная из теории информации Клода Шеннона, представляет собой меру неопределенности или беспорядка в системе. В контексте деревьев решений энтропия измеряет количество информации, необходимой для классификации элементов в узле.
Математически энтропия определяется как:
H(S) = -Σ(pi * log2(pi))
, где pi — доля примеров класса i в множестве S.
Информационный выигрыш (Information Gain) вычисляется как разность между энтропией исходного множества и взвешенной энтропией получаемых подмножеств:
IG(S, A) = H(S) — Σ(|Sv|/|S| * H(Sv))
, где Sv — подмножество S для каждого значения v атрибута A.
В моей практике работы с алгоритмическими торговыми стратегиями я заметил, что энтропия особенно эффективна при работе с признаками, имеющими много категорий. Это связано с тем, что информационный выигрыш напрямую связан с концепцией взаимной информации между признаками и целевой переменной.
Давайте сравним будет ли энтропия с выигрышем эффективнее Джини?
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.tree import DecisionTreeClassifier, plot_tree
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.metrics import confusion_matrix
# Генерация данных
np.random.seed(42)
n_samples = 1000
returns = np.random.normal(0.001, 0.02, n_samples)
volatility = np.abs(np.random.normal(0.015, 0.005, n_samples))
volume = np.random.lognormal(15, 1, n_samples)
rsi = np.random.beta(2, 2, n_samples) * 100
signal_strength = (returns * 50 +
(volatility - 0.015) * 30 +
np.log(volume / np.mean(volume)) * 10 +
(rsi - 50) * 0.1)
noise = np.random.normal(0, 2, n_samples)
direction = (signal_strength + noise > 0).astype(int)
data = pd.DataFrame({
'returns': returns,
'volatility': volatility,
'volume': volume,
'rsi': rsi,
'direction': direction
})
X = data[['returns', 'volatility', 'volume', 'rsi']]
y = data['direction']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
# Обучение дерева с критерием энтропии
clf = DecisionTreeClassifier(
criterion='entropy',
max_depth=6,
min_samples_split=50,
min_samples_leaf=20,
random_state=42
)
clf.fit(X_train, y_train)
# Оценка
train_score = clf.score(X_train, y_train)
test_score = clf.score(X_test, y_test)
cv_scores = cross_val_score(clf, X, y, cv=5, scoring='accuracy')
print(f"Точность на обучающей выборке: {train_score:.4f}")
print(f"Точность на тестовой выборке: {test_score:.4f}")
print(f"Средняя точность CV: {cv_scores.mean():.4f} (+/- {cv_scores.std() * 2:.4f})")
# Важность признаков
feature_importance = pd.DataFrame({
'feature': X.columns,
'importance': clf.feature_importances_
}).sort_values('importance', ascending=False)
print("\nВажность признаков:")
print(feature_importance)
# Предсказания
y_pred = clf.predict(X_test)
# Визуализация 1: Матрица ошибок
cm = confusion_matrix(y_test, y_pred)
plt.figure(figsize=(5, 4))
sns.heatmap(cm, annot=True, fmt="d", cmap='Blues', xticklabels=["⬇", "⬆"], yticklabels=["⬇", "⬆"])
plt.title("Матрица ошибок")
plt.xlabel("Предсказано")
plt.ylabel("Истинное значение")
plt.tight_layout()
plt.show()
# Визуализация 2: Структура дерева решений
plt.figure(figsize=(20, 6))
plot_tree(clf, feature_names=X.columns, class_names=["⬇", "⬆"], filled=True, rounded=True)
plt.title("Структура дерева решений")
plt.show()
Точность на обучающей выборке: 0.9414
Точность на тестовой выборке: 0.9300
Средняя точность CV: 0.9180 (+/- 0.0215)
Важность признаков:
feature importance
2 volume 0.876223
3 rsi 0.123777
1 volatility 0.000000
0 returns 0.000000
Модель на одних и тех же данных с методом разделения Entropy вместо Gini показала такую же точность на обучащей выборке, однако на тестовой точность упала на 1%, в то же время Средняя точность CV выросла на 0.2%.
Интересно так же отметить, что из числа важных признаков при методе Entropy «выпала» волатильность.

Рис. 3: Матрица ошибок обучения модели с Entropy

Рис. 4: Структура дерева решений с Entropy
По изображению выше мы так же видим, что поменялась и структура дерева решений — оно стало более компактным.
Энтропия имеет тенденцию создавать более сбалансированные деревья, поскольку она максимизирует информационный выигрыш, что часто приводит к более равномерному распределению классов в узлах. Это особенно важно при работе с финансовыми данными, где дисбаланс классов может привести к смещенным предсказаниям.
Информационный выигрыш также предоставляет интуитивно понятную интерпретацию: он показывает, сколько битов информации мы получаем, используя конкретный признак для разделения. В контексте финансовых данных это может означать, что определенные индикаторы несут больше информации о будущих движениях цены, чем другие.
Однако энтропия имеет и свои недостатки. Она вычислительно более затратна из-за логарифмических операций, и может быть склонна к созданию более глубоких деревьев, что увеличивает риск переобучения. Кроме того, информационный выигрыш имеет смещение в пользу признаков с большим количеством различных значений, что может быть проблематично при работе с непрерывными переменными.
Gain Ratio
Gain Ratio представляет собой модификацию информационного выигрыша, разработанную для решения проблемы смещения в пользу признаков с большим количеством значений. Этот критерий нормализует информационный выигрыш путем деления на внутреннюю информацию (Intrinsic Information) признака.
Математически Gain Ratio определяется как:
GR(S, A) = IG(S, A) / IV(S, A)
, где IV(S, A) = -Σ(|Sv|/|S| * log2(|Sv|/|S|)) — внутренняя информация признака A.
По моему опыту Gain Ratio особенно полезен при работе с признаками, имеющими различные шкалы измерения. Например, при одновременном использовании категориальных признаков (тип инструмента, сектор) и непрерывных (цена, объем, технические индикаторы).
import numpy as np
import pandas as pd
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.preprocessing import LabelEncoder
from math import log2
import matplotlib.pyplot as plt
# Функция для вычисления внутренней информации
def intrinsic_value(X, feature_idx):
"""Вычисляет внутреннюю информацию признака"""
unique_values, counts = np.unique(X[:, feature_idx], return_counts=True)
probabilities = counts / len(X)
iv = -sum(p * log2(p) for p in probabilities if p > 0)
return iv
# Функция для вычисления Gain Ratio
def gain_ratio(X, y, feature_idx, threshold=None):
"""Вычисляет Gain Ratio для заданного признака"""
# Вычисляем информационный выигрыш
ig = information_gain(X, y, feature_idx, threshold)
# Вычисляем внутреннюю информацию
if threshold is None:
iv = intrinsic_value(X, feature_idx)
else:
# Для непрерывных признаков с пороговым значением
left_count = np.sum(X[:, feature_idx] <= threshold)
right_count = len(X) - left_count
if left_count == 0 or right_count == 0:
return 0
left_prob = left_count / len(X)
right_prob = right_count / len(X)
iv = -(left_prob * log2(left_prob) + right_prob * log2(right_prob))
# Избегаем деления на ноль
if iv == 0:
return 0
return ig / iv
# Создаем комплексный датасет с различными типами признаков
np.random.seed(42)
n_samples = 2000
# Непрерывные признаки
price_return = np.random.normal(0.0005, 0.02, n_samples)
volume_ratio = np.random.lognormal(0, 0.5, n_samples)
volatility = np.abs(np.random.normal(0.015, 0.008, n_samples))
momentum_5d = np.random.normal(0, 0.08, n_samples)
momentum_20d = np.random.normal(0, 0.12, n_samples)
# Категориальные признаки с разным количеством категорий
market_regime = np.random.choice(['bull', 'bear', 'sideways'], n_samples, p=[0.4, 0.3, 0.3])
day_of_week = np.random.choice(['mon', 'tue', 'wed', 'thu', 'fri'], n_samples)
hour_of_day = np.random.choice(range(24), n_samples) # 24 категории
sector = np.random.choice(['tech', 'finance', 'energy', 'healthcare', 'consumer', 'industrial'],
n_samples, p=[0.2, 0.2, 0.15, 0.15, 0.15, 0.15])
# Создаем сложную нелинейную зависимость
regime_effect = (market_regime == 'bull').astype(int) * 15 + (market_regime == 'bear').astype(int) * (-10)
day_effect = np.array([{'mon': 2, 'tue': 1, 'wed': 0, 'thu': 1, 'fri': 3}[day] for day in day_of_week])
hour_effect = np.sin(2 * np.pi * hour_of_day / 24) * 5 # Циклический эффект
sector_effect = pd.get_dummies(sector).values.dot([8, 5, 3, 6, 4, 2]) # Различные веса для секторов
signal = (price_return * 200 +
np.log(volume_ratio) * 10 +
volatility * 100 +
momentum_5d * 50 +
momentum_20d * 30 +
regime_effect +
day_effect +
hour_effect +
sector_effect)
# Создаем целевую переменную с шумом
noise = np.random.normal(0, 12, n_samples)
target_continuous = signal + noise
# Создаем категориальную целевую переменную
target = np.where(target_continuous < -8, 0, np.where(target_continuous > 8, 2, 1))
# Кодируем категориальные признаки
le_regime = LabelEncoder()
le_day = LabelEncoder()
le_sector = LabelEncoder()
regime_encoded = le_regime.fit_transform(market_regime)
day_encoded = le_day.fit_transform(day_of_week)
sector_encoded = le_sector.fit_transform(sector)
# Создаем матрицу признаков
X = np.column_stack([
price_return,
volume_ratio,
volatility,
momentum_5d,
momentum_20d,
regime_encoded,
day_encoded,
hour_of_day,
sector_encoded
])
feature_names = ['price_return', 'volume_ratio', 'volatility', 'momentum_5d',
'momentum_20d', 'regime', 'day_of_week', 'hour', 'sector']
# Анализируем Gain Ratio для каждого признака
print("Анализ Gain Ratio для признаков:")
print("=" * 50)
for i, feature_name in enumerate(feature_names):
if i < 5: # Непрерывные признаки threshold = np.median(X[:, i]) gr = gain_ratio(X, target, i, threshold) ig = information_gain(X, target, i, threshold) iv = -(0.5 * log2(0.5) + 0.5 * log2(0.5)) # Приблизительно для медианного разделения else: # Категориальные признаки gr = gain_ratio(X, target, i) ig = information_gain(X, target, i) iv = intrinsic_value(X, i) print(f"{feature_name:15} | IG: {ig:.4f} | IV: {iv:.4f} | GR: {gr:.4f}") # Создаем кастомный класс дерева решений с Gain Ratio class GainRatioTree: def __init__(self, max_depth=10, min_samples_split=20): self.max_depth = max_depth self.min_samples_split = min_samples_split self.tree = None def fit(self, X, y): self.tree = self._build_tree(X, y, depth=0) def _build_tree(self, X, y, depth): # Условия остановки if (depth >= self.max_depth or
len(y) < self.min_samples_split or
len(np.unique(y)) == 1):
return {'class': np.bincount(y).argmax()}
# Найти лучшее разделение
best_feature, best_threshold, best_gr = None, None, 0
for feature_idx in range(X.shape[1]):
if feature_idx < 5: # Непрерывные признаки thresholds = np.percentile(X[:, feature_idx], [25, 50, 75]) for threshold in thresholds: gr = gain_ratio(X, y, feature_idx, threshold) if gr > best_gr:
best_gr = gr
best_feature = feature_idx
best_threshold = threshold
else: # Категориальные признаки
gr = gain_ratio(X, y, feature_idx)
if gr > best_gr:
best_gr = gr
best_feature = feature_idx
best_threshold = None
if best_feature is None:
return {'class': np.bincount(y).argmax()}
# Создаем разделение
if best_threshold is not None:
left_mask = X[:, best_feature] <= best_threshold right_mask = ~left_mask left_tree = self._build_tree(X[left_mask], y[left_mask], depth + 1) right_tree = self._build_tree(X[right_mask], y[right_mask], depth + 1) return { 'feature': best_feature, 'threshold': best_threshold, 'left': left_tree, 'right': right_tree, 'gain_ratio': best_gr } else: # Категориальное разделение unique_values = np.unique(X[:, best_feature]) branches = {} for value in unique_values: mask = X[:, best_feature] == value if np.sum(mask) > 0:
branches[value] = self._build_tree(X[mask], y[mask], depth + 1)
return {
'feature': best_feature,
'branches': branches,
'gain_ratio': best_gr
}
# Обучаем наше дерево с Gain Ratio
gr_tree = GainRatioTree(max_depth=6, min_samples_split=50)
gr_tree.fit(X, target)
# Сравниваем с стандартными критериями
X_train, X_test, y_train, y_test = train_test_split(X, target, test_size=0.3, random_state=42)
# Стандартные деревья
gini_tree = DecisionTreeClassifier(criterion='gini', max_depth=6, min_samples_split=50, random_state=42)
entropy_tree = DecisionTreeClassifier(criterion='entropy', max_depth=6, min_samples_split=50, random_state=42)
# Обучение и оценка
gini_scores = cross_val_score(gini_tree, X, target, cv=5, scoring='accuracy')
entropy_scores = cross_val_score(entropy_tree, X, target, cv=5, scoring='accuracy')
print("\n" + "="*60)
print("СРАВНЕНИЕ ПРОИЗВОДИТЕЛЬНОСТИ КРИТЕРИЕВ")
print("="*60)
print(f"Gini - средняя точность: {gini_scores.mean():.4f} (+/- {gini_scores.std() * 2:.4f})")
print(f"Энтропия - средняя точность: {entropy_scores.mean():.4f} (+/- {entropy_scores.std() * 2:.4f})")
# Обучаем модели для детального анализа
gini_tree.fit(X_train, y_train)
entropy_tree.fit(X_train, y_train)
print(f"\nПараметры деревьев:")
print(f"Gini - глубина: {gini_tree.get_depth()}, листья: {gini_tree.get_n_leaves()}")
print(f"Энтропия - глубина: {entropy_tree.get_depth()}, листья: {entropy_tree.get_n_leaves()}")
# Анализ важности признаков
print(f"\nВажность признаков (Gini):")
gini_importance = pd.DataFrame({
'feature': feature_names,
'importance': gini_tree.feature_importances_
}).sort_values('importance', ascending=False)
print(gini_importance)
print(f"\nВажность признаков (Энтропия):")
entropy_importance = pd.DataFrame({
'feature': feature_names,
'importance': entropy_tree.feature_importances_
}).sort_values('importance', ascending=False)
print(entropy_importance)
Анализ Gain Ratio для признаков:
==================================================
price_return | IG: 0.0118 | IV: 1.0000 | GR: 0.0118
volume_ratio | IG: 0.0142 | IV: 1.0000 | GR: 0.0142
volatility | IG: 0.0018 | IV: 1.0000 | GR: 0.0018
momentum_5d | IG: 0.0202 | IV: 1.0000 | GR: 0.0202
momentum_20d | IG: 0.0133 | IV: 1.0000 | GR: 0.0133
regime | IG: 0.2296 | IV: 1.5685 | GR: 0.1464
day_of_week | IG: 0.0061 | IV: 2.3201 | GR: 0.0026
hour | IG: 0.0353 | IV: 4.5748 | GR: 0.0077
sector | IG: 0.0110 | IV: 2.5659 | GR: 0.0043
============================================================
СРАВНЕНИЕ ПРОИЗВОДИТЕЛЬНОСТИ КРИТЕРИЕВ
============================================================
Gini - средняя точность: 0.6080 (+/- 0.0174)
Энтропия - средняя точность: 0.6125 (+/- 0.0122)
Параметры деревьев:
Gini - глубина: 6, листья: 35
Энтропия - глубина: 6, листья: 34
Важность признаков (Gini):
feature importance
5 regime 0.492336
1 volume_ratio 0.129156
0 price_return 0.123522
4 momentum_20d 0.108043
3 momentum_5d 0.078315
7 hour 0.035249
2 volatility 0.031439
6 day_of_week 0.001940
8 sector 0.000000
Важность признаков (Энтропия):
feature importance
5 regime 0.482643
0 price_return 0.139612
1 volume_ratio 0.126230
3 momentum_5d 0.090091
4 momentum_20d 0.064376
2 volatility 0.043762
7 hour 0.030949
6 day_of_week 0.011910
8 sector 0.010427
Этот пример демонстрирует ключевые преимущества Gain Ratio при работе с гетерогенными данными. Мы видим, как различные типы признаков — от непрерывных финансовых показателей до категориальных переменных с разным количеством категорий — влияют на процесс разделения.
Особенно интересно наблюдать, как Gain Ratio справляется с признаком «час дня», который имеет 24 различных значения. Классический информационный выигрыш мог бы отдать этому признаку неоправданно высокий приоритет, в то время как Gain Ratio нормализует это преимущество, учитывая внутреннюю информацию признака.
Gain Ratio особенно эффективен при построении интерпретируемых моделей для регуляторной отчетности. Банки и финансовые институты часто требуют объяснения, почему модель принимает те или иные решения, и сбалансированный подход Gain Ratio к выбору признаков обеспечивает более справедливое представление факторов риска.
Недостатком Gain Ratio является его вычислительная сложность и потенциальная нестабильность при работе с признаками, имеющими очень низкую внутреннюю информацию. В таких случаях малые изменения в данных могут привести к значительным изменениям в рейтинге признаков.
Критерий Хи-квадрат
Критерий хи-квадрат (Chi-square) представляет собой статистический подход к оценке качества разделения, основанный на тесте независимости между признаком и целевой переменной. В отличие от информационных критериев, хи-квадрат имеет четкую статистическую интерпретацию и позволяет оценивать значимость разделений.
Математически статистика хи-квадрат для разделения вычисляется как:
χ² = Σᵢⱼ((Oᵢⱼ — Eᵢⱼ)² / Eᵢⱼ)
где:
- Oᵢⱼ — наблюдаемые частоты класса j в узле i,
- Eᵢⱼ — ожидаемые частоты при условии независимости.
Я иногда использую критерий хи-квадрат для предварительного отбора значимых признаков. Ниже пример кода, который реализует и сравнивает дерево решений с использованием критерия хи-квадрат для анализа финансовых данных, включая оценку значимости признаков и их влияние на целевую переменную.
import numpy as np
import pandas as pd
from scipy.stats import chi2_contingency, chi2
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import cross_val_score
from sklearn.preprocessing import KBinsDiscretizer
import matplotlib.pyplot as plt
# Функция для вычисления хи-квадрат статистики для разделения
def chi_square_split(X_feature, y, threshold=None):
"""Вычисляет хи-квадрат статистику для разделения по признаку"""
if threshold is not None:
# Для непрерывных признаков создаем бинарное разделение
feature_binned = (X_feature <= threshold).astype(int) else: # Для категориальных признаков используем как есть feature_binned = X_feature # Создаем таблицу сопряженности contingency_table = pd.crosstab(feature_binned, y) # Вычисляем хи-квадрат статистику chi2_stat, p_value, dof, expected = chi2_contingency(contingency_table) return chi2_stat, p_value, contingency_table # Функция для поиска оптимального порога по критерию хи-квадрат def find_best_chi2_threshold(X_feature, y, n_thresholds=20): """Находит оптимальный порог для непрерывного признака по критерию хи-квадрат""" # Создаем кандидатов на пороговые значения thresholds = np.percentile(X_feature, np.linspace(10, 90, n_thresholds)) best_chi2, best_threshold, best_p_value = 0, None, 1 for threshold in thresholds: try: chi2_stat, p_value, _ = chi_square_split(X_feature, y, threshold) if chi2_stat > best_chi2:
best_chi2 = chi2_stat
best_threshold = threshold
best_p_value = p_value
except:
continue
return best_chi2, best_threshold, best_p_value
# Создаем расширенный финансовый датасет
np.random.seed(42)
n_samples = 3000
# Финансовые признаки
returns_1d = np.random.normal(0.0008, 0.025, n_samples)
returns_5d = np.random.normal(0.004, 0.055, n_samples)
volume_change = np.random.lognormal(0, 0.8, n_samples)
price_to_ma50 = np.random.normal(1.0, 0.15, n_samples)
rsi = np.random.beta(2, 2, n_samples) * 100
# Категориальные признаки
credit_rating = np.random.choice(['AAA', 'AA', 'A', 'BBB', 'BB', 'B'], n_samples,
p=[0.05, 0.15, 0.25, 0.30, 0.15, 0.10])
market_cap_category = np.random.choice(['large', 'mid', 'small'], n_samples, p=[0.4, 0.35, 0.25])
industry = np.random.choice(['tech', 'finance', 'healthcare', 'energy', 'consumer'], n_samples,
p=[0.25, 0.20, 0.20, 0.15, 0.20])
# Создаем сложную зависимость с различными эффектами для разных групп
rating_effect = {'AAA': 20, 'AA': 15, 'A': 10, 'BBB': 5, 'BB': -5, 'B': -15}
rating_scores = np.array([rating_effect[r] for r in credit_rating])
mcap_effect = {'large': 8, 'mid': 3, 'small': -2}
mcap_scores = np.array([mcap_effect[m] for m in market_cap_category])
industry_effect = {'tech': 12, 'finance': -3, 'healthcare': 5, 'energy': -8, 'consumer': 2}
industry_scores = np.array([industry_effect[i] for i in industry])
# Комбинированный сигнал
signal = (returns_1d * 300 +
returns_5d * 100 +
np.log(volume_change) * 15 +
(price_to_ma50 - 1) * 50 +
(rsi - 50) * 0.3 +
rating_scores +
mcap_scores +
industry_scores)
# Добавляем шум и создаем бинарную целевую переменную
noise = np.random.normal(0, 18, n_samples)
target_continuous = signal + noise
# Создаем несбалансированную бинарную классификацию
target = (target_continuous > np.percentile(target_continuous, 80)).astype(int)
print(f"Распределение классов: {np.bincount(target)}")
print(f"Доля положительного класса: {np.mean(target):.3f}")
# Подготавливаем данные для анализа
continuous_features = {
'returns_1d': returns_1d,
'returns_5d': returns_5d,
'volume_change': volume_change,
'price_to_ma50': price_to_ma50,
'rsi': rsi
}
categorical_features = {
'credit_rating': credit_rating,
'market_cap': market_cap_category,
'industry': industry
}
# Анализ хи-квадрат для непрерывных признаков
print("\n" + "="*80)
print("АНАЛИЗ ХИ-КВАДРАТ ДЛЯ НЕПРЕРЫВНЫХ ПРИЗНАКОВ")
print("="*80)
continuous_results = {}
for feature_name, feature_values in continuous_features.items():
chi2_stat, best_threshold, p_value = find_best_chi2_threshold(feature_values, target)
continuous_results[feature_name] = {
'chi2': chi2_stat,
'threshold': best_threshold,
'p_value': p_value,
'significant': p_value < 0.05
}
print(f"{feature_name:15} | χ²: {chi2_stat:8.3f} | p-value: {p_value:.6f} | "
f"Threshold: {best_threshold:8.4f} | Significant: {p_value < 0.05}")
# Анализ хи-квадрат для категориальных признаков
print("\n" + "="*80)
print("АНАЛИЗ ХИ-КВАДРАТ ДЛЯ КАТЕГОРИАЛЬНЫХ ПРИЗНАКОВ")
print("="*80)
categorical_results = {}
for feature_name, feature_values in categorical_features.items():
chi2_stat, p_value, contingency = chi_square_split(feature_values, target)
categorical_results[feature_name] = {
'chi2': chi2_stat,
'p_value': p_value,
'significant': p_value < 0.05,
'contingency': contingency
}
print(f"{feature_name:15} | χ²: {chi2_stat:8.3f} | p-value: {p_value:.6f} | "
f"Significant: {p_value < 0.05}") print("Таблица сопряженности:") print(contingency) print("-" * 40) # Создаем кастомную реализацию дерева с критерием хи-квадрат class ChiSquareTree: def __init__(self, max_depth=8, min_samples_split=50, significance_level=0.05): self.max_depth = max_depth self.min_samples_split = min_samples_split self.significance_level = significance_level self.tree = None self.feature_types = None def fit(self, X, y, feature_types=None): """ feature_types: список типов признаков ('continuous' или 'categorical') """ self.feature_types = feature_types or ['continuous'] * X.shape[1] self.tree = self._build_tree(X, y, depth=0) def _build_tree(self, X, y, depth): # Условия остановки if (depth >= self.max_depth or
len(y) < self.min_samples_split or
len(np.unique(y)) == 1):
return {'class': np.bincount(y).argmax(), 'samples': len(y)}
best_feature, best_threshold, best_chi2, best_p = None, None, 0, 1
for feature_idx in range(X.shape[1]):
if self.feature_types[feature_idx] == 'continuous':
chi2_stat, threshold, p_value = find_best_chi2_threshold(X[:, feature_idx], y)
else:
chi2_stat, p_value, _ = chi_square_split(X[:, feature_idx], y)
threshold = None
# Проверяем значимость и выбираем лучший признак
if p_value < self.significance_level and chi2_stat > best_chi2:
best_chi2 = chi2_stat
best_feature = feature_idx
best_threshold = threshold
best_p = p_value
if best_feature is None:
return {'class': np.bincount(y).argmax(), 'samples': len(y)}
# Создаем разделение
if best_threshold is not None:
left_mask = X[:, best_feature] <= best_threshold right_mask = ~left_mask left_tree = self._build_tree(X[left_mask], y[left_mask], depth + 1) right_tree = self._build_tree(X[right_mask], y[right_mask], depth + 1) return { 'feature': best_feature, 'threshold': best_threshold, 'left': left_tree, 'right': right_tree, 'chi2': best_chi2, 'p_value': best_p, 'samples': len(y) } else: # Категориальное разделение unique_values = np.unique(X[:, best_feature]) branches = {} for value in unique_values: mask = X[:, best_feature] == value if np.sum(mask) > 0:
branches[value] = self._build_tree(X[mask], y[mask], depth + 1)
return {
'feature': best_feature,
'branches': branches,
'chi2': best_chi2,
'p_value': best_p,
'samples': len(y)
}
# Подготавливаем данные для обучения
from sklearn.preprocessing import LabelEncoder
# Кодируем категориальные признаки
le_rating = LabelEncoder()
le_mcap = LabelEncoder()
le_industry = LabelEncoder()
rating_encoded = le_rating.fit_transform(credit_rating)
mcap_encoded = le_mcap.fit_transform(market_cap_category)
industry_encoded = le_industry.fit_transform(industry)
# Создаем матрицу признаков
X_combined = np.column_stack([
returns_1d, returns_5d, volume_change, price_to_ma50, rsi, # Непрерывные
rating_encoded, mcap_encoded, industry_encoded # Категориальные
])
feature_names_combined = ['returns_1d', 'returns_5d', 'volume_change', 'price_to_ma50', 'rsi',
'credit_rating', 'market_cap', 'industry']
feature_types_combined = ['continuous'] * 5 + ['categorical'] * 3
# Обучаем дерево с критерием хи-квадрат
chi2_tree = ChiSquareTree(max_depth=6, min_samples_split=100, significance_level=0.01)
chi2_tree.fit(X_combined, target, feature_types_combined)
# Сравниваем с классическими критериями
gini_tree_chi2 = DecisionTreeClassifier(criterion='gini', max_depth=6,
min_samples_split=100, random_state=42)
entropy_tree_chi2 = DecisionTreeClassifier(criterion='entropy', max_depth=6,
min_samples_split=100, random_state=42)
# Кросс-валидация
gini_scores_chi2 = cross_val_score(gini_tree_chi2, X_combined, target, cv=5, scoring='roc_auc')
entropy_scores_chi2 = cross_val_score(entropy_tree_chi2, X_combined, target, cv=5, scoring='roc_auc')
print("\n" + "="*80)
print("СРАВНЕНИЕ ПРОИЗВОДИТЕЛЬНОСТИ (ROC-AUC)")
print("="*80)
print(f"Gini - средний ROC-AUC: {gini_scores_chi2.mean():.4f} (+/- {gini_scores_chi2.std() * 2:.4f})")
print(f"Энтропия - средний ROC-AUC: {entropy_scores_chi2.mean():.4f} (+/- {entropy_scores_chi2.std() * 2:.4f})")
Распределение классов: [2400 600]
Доля положительного класса: 0.200
================================================================================
АНАЛИЗ ХИ-КВАДРАТ ДЛЯ НЕПРЕРЫВНЫХ ПРИЗНАКОВ
================================================================================
returns_1d | χ²: 101.411 | p-value: 0.000000 | Threshold: 0.0135 | Significant: True
returns_5d | χ²: 49.791 | p-value: 0.000000 | Threshold: 0.0355 | Significant: True
volume_change | χ²: 182.052 | p-value: 0.000000 | Threshold: 1.8730 | Significant: True
price_to_ma50 | χ²: 73.920 | p-value: 0.000000 | Threshold: 1.0044 | Significant: True
rsi | χ²: 56.043 | p-value: 0.000000 | Threshold: 37.0137 | Significant: True
================================================================================
АНАЛИЗ ХИ-КВАДРАТ ДЛЯ КАТЕГОРИАЛЬНЫХ ПРИЗНАКОВ
================================================================================
credit_rating | χ²: 154.746 | p-value: 0.000000 | Significant: True
Таблица сопряженности:
col_0 0 1
row_0
A 581 181
AA 317 151
AAA 89 56
B 299 17
BB 408 47
BBB 706 148
----------------------------------------
market_cap | χ²: 36.279 | p-value: 0.000000 | Significant: True
Таблица сопряженности:
col_0 0 1
row_0
large 908 298
mid 886 208
small 606 94
----------------------------------------
industry | χ²: 69.447 | p-value: 0.000000 | Significant: True
Таблица сопряженности:
col_0 0 1
row_0
consumer 478 107
energy 399 42
finance 499 96
healthcare 465 141
tech 559 214
----------------------------------------
================================================================================
СРАВНЕНИЕ ПРОИЗВОДИТЕЛЬНОСТИ (ROC-AUC)
================================================================================
Gini - средний ROC-AUC: 0.7388 (+/- 0.0190)
Энтропия - средний ROC-AUC: 0.7327 (+/- 0.0445)
В приведенном примере мы видим, как различные типы финансовых признаков демонстрируют разную степень связи с целевой переменной. Кредитный рейтинг, как и ожидалось, показывает высокую статистическую значимость, в то время как некоторые технические индикаторы могут оказаться незначимыми.
Критерий хи-квадрат обладает несколькими ключевыми преимуществами в финансовом моделировании:
- Он предоставляет четкий статистический тест для каждого разделения, что позволяет избежать случайных паттернов в данных;
- Он естественным образом работает как с категориальными, так и с непрерывными признаками;
- p-value каждого разделения может использоваться для автоматической остановки роста дерева, предотвращая переобучение.
Однако у этого критерия есть и ограничения. Он требует достаточно большого размера выборки для корректной работы статистических тестов. Кроме того, при множественном тестировании возникает проблема множественных сравнений, которая может требовать коррекции уровня значимости.
Variance Reduction
Критерий Variance Reduction (снижение дисперсии) специально разработан для регрессионных задач, где целевая переменная является непрерывной. Этот метод стремится минимизировать дисперсию целевой переменной в получаемых узлах, что эквивалентно минимизации среднеквадратичной ошибки.
Математически критерий определяется как:
VR(S, A) = Var(S) — Σ(|Sv|/|S| * Var(Sv))
где:
- Var(S) — дисперсия целевой переменной в множестве S,
- Sv — подмножества для каждого значения атрибута A.
В моей работе с предсказанием волатильности и ценообразованием деривативов я часто использую деревья регрессии с критерием снижения дисперсии. Этот подход особенно эффективен при моделировании нелинейных зависимостей в финансовых временных рядах.
import numpy as np
import pandas as pd
from sklearn.tree import DecisionTreeRegressor
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.metrics import mean_squared_error, r2_score, mean_absolute_error
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
import warnings
warnings.filterwarnings('ignore')
# Функция для вычисления снижения дисперсии
def variance_reduction(y_parent, y_left, y_right):
"""Вычисляет снижение дисперсии для разделения"""
if len(y_left) == 0 or len(y_right) == 0:
return 0
var_parent = np.var(y_parent)
var_left = np.var(y_left)
var_right = np.var(y_right)
weight_left = len(y_left) / len(y_parent)
weight_right = len(y_right) / len(y_parent)
weighted_var = weight_left * var_left + weight_right * var_right
return var_parent - weighted_var
# Функция для поиска оптимального порога по критерию снижения дисперсии
def find_best_variance_threshold(X_feature, y, n_candidates=50):
"""Находит оптимальный порог для максимизации снижения дисперсии"""
# Создаем кандидатов на пороговые значения
thresholds = np.percentile(X_feature, np.linspace(5, 95, n_candidates))
best_vr, best_threshold = 0, None
for threshold in thresholds:
left_mask = X_feature <= threshold
right_mask = ~left_mask
if np.sum(left_mask) < 10 or np.sum(right_mask) < 10: continue y_left = y[left_mask] y_right = y[right_mask] vr = variance_reduction(y, y_left, y_right) if vr > best_vr:
best_vr = vr
best_threshold = threshold
return best_vr, best_threshold
# Создаем сложный финансовый датасет для регрессии
np.random.seed(42)
n_samples = 5000
# Временной компонент
time_index = np.arange(n_samples)
trend = 0.0001 * time_index # Долгосрочный тренд
# Макроэкономические факторы
interest_rate = 0.02 + 0.01 * np.sin(2 * np.pi * time_index / 252) + np.random.normal(0, 0.002, n_samples)
inflation = 0.025 + 0.005 * np.sin(2 * np.pi * time_index / 504) + np.random.normal(0, 0.001, n_samples)
gdp_growth = 0.03 + 0.01 * np.sin(2 * np.pi * time_index / 1008) + np.random.normal(0, 0.003, n_samples)
# Рыночные факторы
market_return = np.random.normal(0.0008, 0.015, n_samples)
market_volatility = np.abs(np.random.normal(0.18, 0.05, n_samples))
vix = 15 + 10 * np.random.beta(2, 5, n_samples)
# Специфические для актива факторы
beta = 1.2 + 0.3 * np.random.normal(0, 1, n_samples)
size_factor = np.random.normal(0, 0.008, n_samples)
value_factor = np.random.normal(0, 0.006, n_samples)
momentum = np.random.normal(0, 0.012, n_samples)
# Технические индикаторы (лаговые переменные)
rsi = 30 + 40 * np.random.beta(2, 2, n_samples)
macd = np.random.normal(0, 0.003, n_samples)
bollinger_position = np.random.uniform(-1, 1, n_samples)
# Режимные переменные
bull_market = (np.sin(2 * np.pi * time_index / 1260) > 0).astype(int)
high_volatility = (market_volatility > np.percentile(market_volatility, 75)).astype(int)
# Создаем сложную нелинейную зависимость для доходности актива
# Основная модель
base_return = (trend +
beta * market_return +
0.5 * size_factor +
0.3 * value_factor +
0.4 * momentum)
# Макроэкономические эффекты
macro_effect = (interest_rate - 0.02) * (-2) + (inflation - 0.025) * (-1.5) + gdp_growth * 0.8
# Режимные эффекты
regime_effect = bull_market * 0.002 + high_volatility * (-0.003)
# Нелинейные взаимодействия
interaction_effect = (np.sign(market_return) * np.log1p(np.abs(market_return * 100)) * 0.001 +
np.where(vix > 25, (vix - 25) * (-0.0002), 0) +
np.where(rsi > 70, (rsi - 70) * (-0.0001),
np.where(rsi < 30, (30 - rsi) * 0.0001, 0))) # Волатильностные кластеры (GARCH-эффект) volatility_clustering = np.zeros(n_samples) for i in range(1, n_samples): volatility_clustering[i] = 0.1 * volatility_clustering[i-1] + market_volatility[i] * 0.02 # Итоговая доходность target_return = (base_return + macro_effect + regime_effect + interaction_effect + volatility_clustering + np.random.normal(0, 0.008, n_samples)) # Создаем DataFrame с признаками features_data = { 'market_return': market_return, 'market_volatility': market_volatility, 'vix': vix, 'interest_rate': interest_rate, 'inflation': inflation, 'gdp_growth': gdp_growth, 'beta': beta, 'size_factor': size_factor, 'value_factor': value_factor, 'momentum': momentum, 'rsi': rsi, 'macd': macd, 'bollinger_position': bollinger_position, 'bull_market': bull_market, 'high_volatility': high_volatility } X = pd.DataFrame(features_data) y = target_return print("АНАЛИЗ СНИЖЕНИЯ ДИСПЕРСИИ ДЛЯ ФИНАНСОВЫХ ПРИЗНАКОВ") print("=" * 70) # Анализируем снижение дисперсии для каждого признака variance_analysis = {} for feature_name in X.columns: vr, threshold = find_best_variance_threshold(X[feature_name].values, y) variance_analysis[feature_name] = { 'variance_reduction': vr, 'threshold': threshold, 'relative_importance': vr / np.var(y) if np.var(y) > 0 else 0
}
# Сортируем признаки по снижению дисперсии
sorted_features = sorted(variance_analysis.items(),
key=lambda x: x[1]['variance_reduction'], reverse=True)
print("Ранжирование признаков по снижению дисперсии:")
print("-" * 70)
for feature_name, metrics in sorted_features:
print(f"{feature_name:20} | VR: {metrics['variance_reduction']:.6f} | "
f"Threshold: {metrics['threshold']:8.4f} | "
f"Rel.Imp: {metrics['relative_importance']:.4f}")
# Разделяем данные для обучения и тестирования
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
# Создаем и обучаем деревья регрессии с разными параметрами
variance_tree_shallow = DecisionTreeRegressor(
criterion='squared_error', # эквивалент variance reduction
max_depth=4,
min_samples_split=100,
min_samples_leaf=50,
random_state=42
)
variance_tree_deep = DecisionTreeRegressor(
criterion='squared_error',
max_depth=8,
min_samples_split=50,
min_samples_leaf=25,
random_state=42
)
# Сравниваем с альтернативными критериями
mae_tree = DecisionTreeRegressor(
criterion='absolute_error', # минимизация MAE
max_depth=6,
min_samples_split=75,
min_samples_leaf=35,
random_state=42
)
# Обучаем модели
variance_tree_shallow.fit(X_train, y_train)
variance_tree_deep.fit(X_train, y_train)
mae_tree.fit(X_train, y_train)
# Делаем предсказания
pred_var_shallow = variance_tree_shallow.predict(X_test)
pred_var_deep = variance_tree_deep.predict(X_test)
pred_mae = mae_tree.predict(X_test)
# Оценка производительности
def evaluate_regression_model(y_true, y_pred, model_name):
"""Комплексная оценка регрессионной модели"""
mse = mean_squared_error(y_true, y_pred)
rmse = np.sqrt(mse)
mae = mean_absolute_error(y_true, y_pred)
r2 = r2_score(y_true, y_pred)
# Дополнительные метрики для финансов
hit_rate = np.mean(np.sign(y_true) == np.sign(y_pred))
sharpe_like = np.mean(y_pred) / np.std(y_pred) if np.std(y_pred) > 0 else 0
print(f"\n{model_name}:")
print(f" RMSE: {rmse:.6f}")
print(f" MAE: {mae:.6f}")
print(f" R²: {r2:.4f}")
print(f" Hit Rate: {hit_rate:.4f}")
print(f" Sharpe-like: {sharpe_like:.4f}")
return {'rmse': rmse, 'mae': mae, 'r2': r2, 'hit_rate': hit_rate}
print("\n" + "="*70)
print("СРАВНЕНИЕ ПРОИЗВОДИТЕЛЬНОСТИ МОДЕЛЕЙ")
print("="*70)
results = {}
results['Variance (Shallow)'] = evaluate_regression_model(y_test, pred_var_shallow, "Variance Reduction (Shallow)")
results['Variance (Deep)'] = evaluate_regression_model(y_test, pred_var_deep, "Variance Reduction (Deep)")
results['MAE Criterion'] = evaluate_regression_model(y_test, pred_mae, "MAE Criterion")
# Кросс-валидация для более надежной оценки
print("\n" + "="*70)
print("КРОСС-ВАЛИДАЦИЯ (5-FOLD)")
print("="*70)
cv_var_shallow = cross_val_score(variance_tree_shallow, X, y, cv=5, scoring='neg_mean_squared_error')
cv_var_deep = cross_val_score(variance_tree_deep, X, y, cv=5, scoring='neg_mean_squared_error')
cv_mae = cross_val_score(mae_tree, X, y, cv=5, scoring='neg_mean_squared_error')
print(f"Variance (Shallow) - RMSE: {np.sqrt(-cv_var_shallow.mean()):.6f} (+/- {np.sqrt(cv_var_shallow.std() * 2):.6f})")
print(f"Variance (Deep) - RMSE: {np.sqrt(-cv_var_deep.mean()):.6f} (+/- {np.sqrt(cv_var_deep.std() * 2):.6f})")
print(f"MAE Criterion - RMSE: {np.sqrt(-cv_mae.mean()):.6f} (+/- {np.sqrt(cv_mae.std() * 2):.6f})")
# Анализ важности признаков
print("\n" + "="*70)
print("ВАЖНОСТЬ ПРИЗНАКОВ (VARIANCE REDUCTION)")
print("="*70)
feature_importance_var = pd.DataFrame({
'feature': X.columns,
'importance_shallow': variance_tree_shallow.feature_importances_,
'importance_deep': variance_tree_deep.feature_importances_
}).sort_values('importance_deep', ascending=False)
print("Топ-10 наиболее важных признаков:")
print(feature_importance_var.head(10).to_string(index=False))
# Создаем кастомную реализацию для демонстрации алгоритма
class VarianceReductionTree:
def __init__(self, max_depth=5, min_samples_split=50):
self.max_depth = max_depth
self.min_samples_split = min_samples_split
self.tree = None
def fit(self, X, y):
self.tree = self._build_tree(X, y, depth=0)
def _build_tree(self, X, y, depth):
# Условия остановки
if (depth >= self.max_depth or
len(y) < self.min_samples_split or
np.var(y) < 1e-7): return { 'prediction': np.mean(y), 'variance': np.var(y), 'samples': len(y) } best_feature, best_threshold, best_vr = None, None, 0 # Перебираем все признаки for feature_idx in range(X.shape[1]): vr, threshold = find_best_variance_threshold(X[:, feature_idx], y) if vr > best_vr:
best_vr = vr
best_feature = feature_idx
best_threshold = threshold
if best_feature is None or best_vr <= 0:
return {
'prediction': np.mean(y),
'variance': np.var(y),
'samples': len(y)
}
# Создаем разделение
left_mask = X[:, best_feature] <= best_threshold
right_mask = ~left_mask
left_tree = self._build_tree(X[left_mask], y[left_mask], depth + 1)
right_tree = self._build_tree(X[right_mask], y[right_mask], depth + 1)
return {
'feature': best_feature,
'threshold': best_threshold,
'left': left_tree,
'right': right_tree,
'variance_reduction': best_vr,
'variance': np.var(y),
'samples': len(y)
}
def predict_single(self, x, tree_node):
if 'prediction' in tree_node:
return tree_node['prediction']
if x[tree_node['feature']] <= tree_node['threshold']: return self.predict_single(x, tree_node['left']) else: return self.predict_single(x, tree_node['right']) def predict(self, X): return np.array([self.predict_single(x, self.tree) for x in X]) # Демонстрируем кастомную реализацию custom_tree = VarianceReductionTree(max_depth=4, min_samples_split=100) custom_tree.fit(X_train.values, y_train) custom_predictions = custom_tree.predict(X_test.values) results['Custom VR Tree'] = evaluate_regression_model(y_test, custom_predictions, "Custom Variance Reduction Tree") # Анализ структуры дерева def analyze_tree_structure(tree_node, feature_names, depth=0, max_depth=3): """Анализирует структуру дерева и показывает снижение дисперсии""" if depth > max_depth or 'prediction' in tree_node:
return
indent = " " * depth
feature_name = feature_names[tree_node['feature']]
print(f"{indent}Признак: {feature_name}")
print(f"{indent}Порог: {tree_node['threshold']:.4f}")
print(f"{indent}Снижение дисперсии: {tree_node['variance_reduction']:.6f}")
print(f"{indent}Исходная дисперсия: {tree_node['variance']:.6f}")
print(f"{indent}Образцов: {tree_node['samples']}")
print(f"{indent}{'='*40}")
if 'left' in tree_node:
print(f"{indent}ЛЕВАЯ ВЕТКА (≤ {tree_node['threshold']:.4f}):")
analyze_tree_structure(tree_node['left'], feature_names, depth + 1, max_depth)
print(f"{indent}ПРАВАЯ ВЕТКА (> {tree_node['threshold']:.4f}):")
analyze_tree_structure(tree_node['right'], feature_names, depth + 1, max_depth)
print("\n" + "="*70)
print("СТРУКТУРА КАСТОМНОГО ДЕРЕВА (ПЕРВЫЕ 3 УРОВНЯ)")
print("="*70)
if custom_tree.tree:
analyze_tree_structure(custom_tree.tree, X.columns.tolist())
# Создаем визуализацию остатков
fig, axes = plt.subplots(2, 2, figsize=(15, 12))
fig.suptitle('Анализ остатков для различных критериев', fontsize=16)
models = [
(pred_var_shallow, 'Variance Reduction (Shallow)'),
(pred_var_deep, 'Variance Reduction (Deep)'),
(pred_mae, 'MAE Criterion'),
(custom_predictions, 'Custom VR Tree')
]
for idx, (predictions, model_name) in enumerate(models):
ax = axes[idx // 2, idx % 2]
residuals = y_test - predictions
ax.scatter(predictions, residuals, alpha=0.6, color='darkgray', s=20)
ax.axhline(y=0, color='black', linestyle='--', alpha=0.8)
ax.set_xlabel('Предсказанные значения')
ax.set_ylabel('Остатки')
ax.set_title(f'{model_name}\nRMSE: {np.sqrt(mean_squared_error(y_test, predictions)):.6f}')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Анализ производительности в разных рыночных режимах
print("\n" + "="*70)
print("ПРОИЗВОДИТЕЛЬНОСТЬ В РАЗНЫХ РЫНОЧНЫХ РЕЖИМАХ")
print("="*70)
# Определяем режимы на тестовой выборке
test_indices = X_test.index
bull_test = X.loc[test_indices, 'bull_market'].values
high_vol_test = X.loc[test_indices, 'high_volatility'].values
# Анализируем производительность в разных режимах
for regime_name, regime_mask in [('Bull Market', bull_test == 1),
('Bear Market', bull_test == 0),
('High Volatility', high_vol_test == 1),
('Low Volatility', high_vol_test == 0)]:
if np.sum(regime_mask) > 50: # Достаточно наблюдений
print(f"\n{regime_name} ({np.sum(regime_mask)} наблюдений):")
print("-" * 50)
y_regime = y_test[regime_mask]
pred_regime = pred_var_deep[regime_mask]
rmse_regime = np.sqrt(mean_squared_error(y_regime, pred_regime))
hit_rate_regime = np.mean(np.sign(y_regime) == np.sign(pred_regime))
print(f"RMSE: {rmse_regime:.6f}")
print(f"Hit Rate: {hit_rate_regime:.4f}")
АНАЛИЗ СНИЖЕНИЯ ДИСПЕРСИИ ДЛЯ ФИНАНСОВЫХ ПРИЗНАКОВ
======================================================================
Ранжирование признаков по снижению дисперсии:
----------------------------------------------------------------------
bull_market | VR: 0.000958 | Threshold: 0.0000 | Rel.Imp: 0.0444
interest_rate | VR: 0.000348 | Threshold: 0.0231 | Rel.Imp: 0.0161
market_return | VR: 0.000280 | Threshold: -0.0021 | Rel.Imp: 0.0129
inflation | VR: 0.000240 | Threshold: 0.0235 | Rel.Imp: 0.0111
gdp_growth | VR: 0.000231 | Threshold: 0.0298 | Rel.Imp: 0.0107
market_volatility | VR: 0.000053 | Threshold: 0.2416 | Rel.Imp: 0.0025
momentum | VR: 0.000048 | Threshold: 0.0064 | Rel.Imp: 0.0022
size_factor | VR: 0.000031 | Threshold: -0.0100 | Rel.Imp: 0.0014
beta | VR: 0.000027 | Threshold: 1.1791 | Rel.Imp: 0.0012
vix | VR: 0.000021 | Threshold: 15.8384 | Rel.Imp: 0.0010
macd | VR: 0.000021 | Threshold: 0.0045 | Rel.Imp: 0.0010
high_volatility | VR: 0.000016 | Threshold: 0.0000 | Rel.Imp: 0.0007
rsi | VR: 0.000011 | Threshold: 43.1586 | Rel.Imp: 0.0005
value_factor | VR: 0.000011 | Threshold: -0.0077 | Rel.Imp: 0.0005
bollinger_position | VR: 0.000008 | Threshold: 0.7941 | Rel.Imp: 0.0004
======================================================================
СРАВНЕНИЕ ПРОИЗВОДИТЕЛЬНОСТИ МОДЕЛЕЙ
======================================================================
Variance Reduction (Shallow):
RMSE: 0.140311
MAE: 0.119260
R²: 0.1089
Hit Rate: 0.9953
Sharpe-like: 5.4111
Variance Reduction (Deep):
RMSE: 0.143764
MAE: 0.118275
R²: 0.0645
Hit Rate: 0.9953
Sharpe-like: 3.6369
MAE Criterion:
RMSE: 0.151726
MAE: 0.111109
R²: -0.0420
Hit Rate: 0.9953
Sharpe-like: 2.8558
======================================================================
КРОСС-ВАЛИДАЦИЯ (5-FOLD)
======================================================================
Variance (Shallow) - RMSE: 0.205699 (+/- 0.263866)
Variance (Deep) - RMSE: 0.208526 (+/- 0.265940)
MAE Criterion - RMSE: 0.221253 (+/- 0.276195)
======================================================================
ВАЖНОСТЬ ПРИЗНАКОВ (VARIANCE REDUCTION)
======================================================================
Топ-10 наиболее важных признаков:
feature importance_shallow importance_deep
gdp_growth 0.175928 0.208822
inflation 0.286933 0.157263
bull_market 0.301399 0.139554
interest_rate 0.061463 0.116205
market_return 0.131970 0.102124
market_volatility 0.000000 0.049581
momentum 0.000000 0.044172
macd 0.013665 0.036521
beta 0.013282 0.033372
bollinger_position 0.015360 0.027210
Custom Variance Reduction Tree:
RMSE: 0.138481
MAE: 0.117198
R²: 0.1320
Hit Rate: 0.9953
Sharpe-like: 5.1916
======================================================================
СТРУКТУРА КАСТОМНОГО ДЕРЕВА (ПЕРВЫЕ 3 УРОВНЯ)
======================================================================
Признак: bull_market
Порог: 0.0000
Снижение дисперсии: 0.000835
Исходная дисперсия: 0.021371
Образцов: 3500
========================================
ЛЕВАЯ ВЕТКА (≤ 0.0000):
Признак: inflation
Порог: 0.0221
Снижение дисперсии: 0.000291
Исходная дисперсия: 0.020021
Образцов: 1742
========================================
ЛЕВАЯ ВЕТКА (≤ 0.0221):
Признак: gdp_growth
Порог: 0.0265
Снижение дисперсии: 0.001284
Исходная дисперсия: 0.020691
Образцов: 535
ПРАВАЯ ВЕТКА (> 0.0000):
Признак: market_return
Порог: 0.0025
Снижение дисперсии: 0.000414
Исходная дисперсия: 0.021046
Образцов: 1758
========================================
ЛЕВАЯ ВЕТКА (≤ 0.0025):
Признак: market_return
Порог: -0.0207
Снижение дисперсии: 0.000415
Исходная дисперсия: 0.020924
Образцов: 927
========================================
ЛЕВАЯ ВЕТКА (≤ -0.0207):
Признак: interest_rate
Порог: 0.0292
Снижение дисперсии: 0.001645
Исходная дисперсия: 0.020985
Образцов: 132
======================================================================
ПРОИЗВОДИТЕЛЬНОСТЬ В РАЗНЫХ РЫНОЧНЫХ РЕЖИМАХ
======================================================================
Bull Market (762 наблюдений):
--------------------------------------------------
RMSE: 0.146609
Hit Rate: 0.9908
Bear Market (738 наблюдений):
--------------------------------------------------
RMSE: 0.140767
Hit Rate: 1.0000
High Volatility (368 наблюдений):
--------------------------------------------------
RMSE: 0.145411
Hit Rate: 0.9918
Low Volatility (1132 наблюдений):
--------------------------------------------------
RMSE: 0.143225
Hit Rate: 0.9965

Рис. 5: Анализ остатков для различных критериев: VR Shallow, VR Deep, MAE Criterion, Custom VR Tree
Представленный код демонстрирует глубокий анализ критерия снижения дисперсии в контексте финансового моделирования. Мы создали комплексный датасет, имитирующий реальные финансовые зависимости с макроэкономическими факторами, рыночными индикаторами и техническими переменными.
Ключевая особенность критерия снижения дисперсии заключается в его прямой связи с минимизацией среднеквадратичной ошибки. Это делает его естественным выбором для задач, где мы хотим точно предсказать численные значения, а не просто классифицировать наблюдения. В финансовом контексте это означает более точное предсказание доходностей, волатильности или других количественных показателей.
Результаты анализа показывают, что различные финансовые признаки демонстрируют разную способность к снижению дисперсии:
- Рыночные факторы, как правило, показывают высокое снижение дисперсии, что соответствует экономической теории об их влиянии на доходности отдельных активов;
- Технические индикаторы могут показывать меньшее снижение дисперсии, что ставит под сомнение их предсказательную способность.
Сравнение с критерием MAE (минимизация средней абсолютной ошибки) выявляет важные различия в поведении алгоритмов. Критерий снижения дисперсии более чувствителен к выбросам, что может быть как преимуществом, так и недостатком в финансовом моделировании. С одной стороны, это позволяет модели лучше улавливать экстремальные события. С другой — может привести к переобучению на аномальных наблюдениях.
Classification Error
Критерий Classification Error представляет собой наиболее интуитивно понятный подход к оценке качества разделения в классификационных задачах. Он напрямую минимизирует долю неправильно классифицированных объектов в узле.
Математически критерий определяется как:
CE(S) = 1 — max(pi)
, где pi — доля класса i в множестве S.
Снижение ошибки классификации вычисляется аналогично другим критериям:
CER(S, A) = CE(S) — Σ(|Sv|/|S| * CE(Sv)).
В моей практике я редко использую этот критерий в чистом виде из-за его низкой чувствительности к изменениям в распределении классов. Однако он может быть полезен в специфических задачах, где важна именно минимизация количества ошибок, а не их «качество».
import numpy as np
import pandas as pd
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, classification_report
import matplotlib.pyplot as plt
# Функция для вычисления ошибки классификации
def classification_error(y):
"""Вычисляет ошибку классификации для массива меток"""
if len(y) == 0:
return 0
# Находим наиболее частый класс
unique_classes, counts = np.unique(y, return_counts=True)
max_class_prob = np.max(counts) / len(y)
# Ошибка = 1 - доля мажоритарного класса
return 1 - max_class_prob
# Генерация данных
np.random.seed(42)
n_samples = 1000
# Генерация признаков
price_change = np.random.normal(0, 0.02, n_samples) # Изменение цены
volume = np.random.lognormal(15, 0.5, n_samples) # Объем торгов
volatility = np.abs(np.random.normal(0.01, 0.005, n_samples)) # Волатильность
# Создаем простую зависимость для направления движения цены
# 0 - падение, 1 - рост
signal = price_change * 50 + np.log(volume/np.mean(volume)) * 10 + volatility * 100
noise = np.random.normal(0, 5, n_samples)
target = (signal + noise > 0).astype(int)
# Создаем DataFrame
data = pd.DataFrame({
'price_change': price_change,
'volume': volume,
'volatility': volatility,
'direction': target
})
print("ПРИМЕР CLASSIFICATION ERROR")
print("=" * 50)
print(f"Размер выборки: {n_samples}")
print(f"Распределение классов: {np.bincount(target)}")
print(f"Доля класса 'рост': {np.mean(target):.3f}")
# Вычисление ошибки классификации для разных узлов
print(f"\nПримеры вычисления Classification Error:")
# Пример 1: сбалансированный узел
example1 = np.array([0, 0, 0, 1, 1, 1])
print(f"Узел [0,0,0,1,1,1]: CE = {classification_error(example1):.3f}")
# Пример 2: несбалансированный узел
example2 = np.array([0, 0, 0, 0, 1, 1])
print(f"Узел [0,0,0,0,1,1]: CE = {classification_error(example2):.3f}")
# Пример 3: чистый узел
example3 = np.array([1, 1, 1, 1, 1])
print(f"Узел [1,1,1,1,1]: CE = {classification_error(example3):.3f}")
# Разделяем данные
X = data[['price_change', 'volume', 'volatility']]
y = data['direction']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
# Сравниваем разные критерии
models = {
'Gini': DecisionTreeClassifier(criterion='gini', max_depth=5, random_state=42),
'Entropy': DecisionTreeClassifier(criterion='entropy', max_depth=5, random_state=42)
}
# Обучаем модели
results = {}
for name, model in models.items():
model.fit(X_train, y_train)
pred = model.predict(X_test)
accuracy = accuracy_score(y_test, pred)
results[name] = {
'model': model,
'accuracy': accuracy,
'predictions': pred
}
print(f"\n{name} - Точность: {accuracy:.4f}")
# Кастомная реализация Classification Error
class SimpleClassificationErrorTree:
def __init__(self, max_depth=3):
self.max_depth = max_depth
self.tree = None
def fit(self, X, y):
self.tree = self._build_tree(X.values, y.values, depth=0)
def _build_tree(self, X, y, depth):
# Условия остановки
if depth >= self.max_depth or len(np.unique(y)) == 1 or len(y) < 20:
return {'class': np.bincount(y).argmax()}
best_feature, best_threshold, best_reduction = None, None, 0
base_error = classification_error(y)
# Поиск лучшего разделения
for feature_idx in range(X.shape[1]):
# Используем медиану как порог
threshold = np.median(X[:, feature_idx])
left_mask = X[:, feature_idx] <= threshold
right_mask = ~left_mask
if np.sum(left_mask) < 5 or np.sum(right_mask) < 5: continue y_left, y_right = y[left_mask], y[right_mask] # Вычисляем взвешенную ошибку left_weight = len(y_left) / len(y) right_weight = len(y_right) / len(y) weighted_error = (left_weight * classification_error(y_left) + right_weight * classification_error(y_right)) error_reduction = base_error - weighted_error if error_reduction > best_reduction:
best_reduction = error_reduction
best_feature = feature_idx
best_threshold = threshold
if best_feature is None:
return {'class': np.bincount(y).argmax()}
# Создаем разделение
left_mask = X[:, best_feature] <= best_threshold
right_mask = ~left_mask
return {
'feature': best_feature,
'threshold': best_threshold,
'left': self._build_tree(X[left_mask], y[left_mask], depth + 1),
'right': self._build_tree(X[right_mask], y[right_mask], depth + 1)
}
def predict_single(self, x, tree_node):
if 'class' in tree_node:
return tree_node['class']
if x[tree_node['feature']] <= tree_node['threshold']:
return self.predict_single(x, tree_node['left'])
else:
return self.predict_single(x, tree_node['right'])
def predict(self, X):
return np.array([self.predict_single(x.values, self.tree) for _, x in X.iterrows()])
# Тестируем кастомную реализацию
ce_tree = SimpleClassificationErrorTree(max_depth=4)
ce_tree.fit(X_train, y_train)
ce_pred = ce_tree.predict(X_test)
ce_accuracy = accuracy_score(y_test, ce_pred)
print(f"\nClassification Error (кастомная реализация) - Точность: {ce_accuracy:.4f}")
# Создаем визуализацию
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
for idx, (name, result) in enumerate(results.items()):
ax = axes[idx]
# Важность признаков
importances = result['model'].feature_importances_
ax.bar(['Price Change', 'Volume', 'Volatility'], importances, color='darkgray', alpha=0.8)
ax.set_title(f'{name}\nТочность: {result["accuracy"]:.4f}')
ax.set_ylabel('Важность признака')
ax.grid(True, alpha=0.3)
# Третий график для кастомной реализации
ax = axes[2]
ax.text(0.5, 0.5, f'Classification Error\n(Кастомная реализация)\n\nТочность: {ce_accuracy:.4f}',
ha='center', va='center', transform=ax.transAxes, fontsize=12,
bbox=dict(boxstyle="round,pad=0.3", facecolor="lightgray", alpha=0.5))
ax.set_title('Classification Error')
ax.axis('off')
plt.tight_layout()
plt.show()
print(f"\nСравнение предсказаний на первых 10 примерах:")
print("Истинные значения:", y_test.iloc[:10].values)
print("Gini: ", results['Gini']['predictions'][:10])
print("Entropy: ", results['Entropy']['predictions'][:10])
print("Classification Error:", ce_pred[:10])
ПРИМЕР CLASSIFICATION ERROR
==================================================
Размер выборки: 1000
Распределение классов: [521 479]
Доля класса 'рост': 0.479
Примеры вычисления Classification Error:
Узел [0,0,0,1,1,1]: CE = 0.500
Узел [0,0,0,0,1,1]: CE = 0.333
Узел [1,1,1,1,1]: CE = 0.000
Gini - Точность: 0.6867
Entropy - Точность: 0.6833
Classification Error (кастомная реализация) - Точность: 0.7067
Сравнение предсказаний на первых 10 примерах:
Истинные значения: [0 0 0 1 0 1 0 0 1 1]
Gini: [1 0 0 1 0 1 0 0 1 1]
Entropy: [1 0 0 1 0 1 0 0 1 1]
Classification Error: [1 0 0 1 0 1 0 0 1 1]

Рис. 6: Эффективность критериев разделения деревьев решений на задаче прогнозирования направления движения цены
Приведенный код демонстрирует простую реализацию критерия Classification Error и его сравнение с классическими методами Gini и энтропии. Кастомная реализация использует медианное разделение для каждого признака и выбирает то разделение, которое максимально снижает ошибку классификации.
Основная идея Classification Error заключается в минимизации доли неправильно классифицированных объектов. В отличие от Gini и энтропии, которые учитывают распределение всех классов, этот критерий фокусируется только на мажоритарном классе в каждом узле. Это делает его вычислительно простым, но менее чувствительным к нюансам в данных.
В финансовом контексте Classification Error может быть полезен для быстрого прототипирования и в задачах, где важна максимальная простота интерпретации. Однако его ограниченная чувствительность к распределению классов делает его неподходящим для большинства практических задач, особенно с несбалансированными данными.
Применимость методов и критериев: сравнительная таблица

Рис. 7: Сравнительная таблица критериев разделения деревьев решений
Практические рекомендации по выбору:
- Финансовый скоринг: Gini — лучший баланс скорости и качества;
- Многоклассовая классификация рисков: Энтропия — максимальная информативность;
- Регуляторные модели: Gain Ratio или хи-квадрат — статистическая обоснованность;
- Прогнозирование цен: Variance Reduction — оптимизация для регрессии;
- Быстрые эксперименты: Classification Error — простота и скорость.
Разумеется, это все лишь best practices и окончательный выбор всегда остается за исследователем. Выбор критерия должен основываться на специфике задачи, размере данных и бизнес-требованиях. Для наиболее важных для бизнеса задач рекомендуется тестирование нескольких критериев с последующим выбором наиболее подходящего.
Заключение
В финансовом моделировании правильный выбор критерия может существенно повлиять на результаты. Модели кредитного скоринга с использованием Gini демонстрируют лучшую способность выявлять потенциальных дефолтеров, прогнозные модели волатильности с критерием снижения дисперсии показывают более точные оценки риска, а регуляторные модели с критерием хи-квадрат обеспечивают необходимую статистическую обоснованность решений.
Для большинства практических задач рекомендую начинать с Gini (для классификации) или снижения дисперсии (для регрессии), затем экспериментировать с альтернативными критериями в зависимости от специфики данных.
Глубокое понимание принципов работы различных критериев позволяет не только строить более качественные модели, но и лучше интерпретировать их результаты, что немаловажно ввиду возросшего в последние годы внимания к объяснимости алгоритмических решений в финансовой сфере.