import random
import numpy as np
import pandas as pd
from numba import jit
from sklearn.cluster import KMeans
from scipy.signal import savgol_filter
from scipy.interpolate import UnivariateSpline
from sklearn.preprocessing import StandardScaler

def smooth_labels(dataset, period=50) -> pd.DataFrame:
    # dataset['labels'] = savgol_filter(dataset['labels'].values, window_length=period, polyorder=2)
    dataset['labels'] = dataset['labels'].rolling(period).mean()
    dataset = dataset.dropna()
    dataset['labels'] = dataset['labels'].apply(lambda x: 0.0 if x < 0.5 else 1.0)
    return(dataset)

def fix_labels(dataset, n_clusters=100) -> pd.DataFrame:
    # Применяем KMeans для кластеризации
    dataset['clusters'] = KMeans(n_clusters=n_clusters).fit(dataset[dataset.columns[:-1]]).labels_
    # Вычисляем среднее значение 'labels' для каждого кластера
    cluster_means = dataset.groupby('clusters')['labels'].mean()
    # Создаем словарь для отображения средних значений в новые значения
    mean_to_new_value = {cluster: 0.0 if mean < 0.5 else 1.0 for cluster, mean in cluster_means.items()}
    # Применяем изменения к исходным значениям 'labels'
    dataset['labels'] = dataset['clusters'].map(mean_to_new_value)
    dataset = dataset.drop(columns=['clusters'])
    return dataset

def fix_labels_subset(dataset, n_clusters=10, subset_size=5) -> pd.DataFrame:
    # Применяем KMeans для кластеризации
    dataset['clusters'] = KMeans(n_clusters=n_clusters).fit(dataset[dataset.columns[1:-1]]).labels_
    # Вычисляем среднее значение 'labels' для каждого кластера
    cluster_means = dataset.groupby('clusters')['labels'].mean()
    # Выбираем случайное подмножество кластеров
    all_clusters = list(cluster_means.index)
    subset_clusters = np.random.choice(all_clusters, size=min(subset_size, len(all_clusters)), replace=False)
    # Создаем словарь для отображения средних значений в новые значения только для выбранных кластеров
    mean_to_new_value = {cluster: 0.0 if mean < 0.5 else 1.0 for cluster, mean in cluster_means.items() if cluster in subset_clusters}
    # Применяем изменения к исходным значениям 'labels' только для выбранных кластеров
    dataset['labels'] = dataset.apply(lambda row: mean_to_new_value[row['clusters']] if row['clusters'] in mean_to_new_value else row['labels'], axis=1)

    dataset = dataset.drop(columns=['clusters'])
    return dataset

def fix_labels_subset_mean(dataset, n_clusters=10, subset_size=5) -> pd.DataFrame:
    # Применяем KMeans для кластеризации
    dataset['clusters'] = KMeans(n_clusters=n_clusters).fit(dataset[dataset.columns[1:-1]]).labels_
    # Вычисляем среднее значение 'labels' для каждого кластера
    cluster_means = dataset.groupby('clusters')['labels'].mean()
    # Сортируем кластеры по их средним значениям и выбираем те, которые далеки от 0.5
    sorted_clusters = cluster_means.sub(0.5).abs().sort_values(ascending=False).index[:subset_size]
    # Создаем словарь для отображения средних значений в новые значения только для выбранных кластеров
    mean_to_new_value = {cluster: 0.0 if mean < 0.5 else 1.0 for cluster, mean in cluster_means.items() if cluster in sorted_clusters}
    # Применяем изменения к исходным значениям 'labels' только для выбранных кластеров
    dataset['labels'] = dataset.apply(lambda row: mean_to_new_value[row['clusters']] if row['clusters'] in mean_to_new_value else row['labels'], axis=1)
    dataset = dataset.drop(columns=['clusters'])
    return dataset

def fix_labels_subset_mean_updated(dataset: pd.DataFrame, n_clusters: int = 10, subset_size: int = 5) -> pd.DataFrame:
    """
    Корректирует метки в датасете на основе KMeans кластеризации,
    игнорируя строки, где значение в столбце 'labels' равно 2.0.

    Функция предполагает, что первый столбец датасета является идентификатором (не используется для кластеризации),
    а также что существует столбец с именем 'labels'. Все остальные столбцы рассматриваются как признаки для кластеризации.

    Args:
        dataset (pd.DataFrame): Входной DataFrame. Должен содержать столбец 'labels'.
        n_clusters (int): Количество кластеров для KMeans.
        subset_size (int): Количество кластеров (наиболее удаленных от среднего значения метки 0.5)
                           для выбора и коррекции меток.

    Returns:
        pd.DataFrame: DataFrame с скорректированными метками. Строки с исходной меткой 2.0 остаются неизменными.
    """

    if 'labels' not in dataset.columns:
        raise ValueError("Датасет должен содержать столбец 'labels'.")
    
    # Проверка на минимальное количество столбцов (ID/признак + 'labels')
    if dataset.shape[1] < 2:
        print("Предупреждение: В датасете менее двух столбцов. Обработка невозможна.")
        return dataset

    # Определяем строки с меткой 2.0
    is_label_2 = dataset['labels'] == 2.0
    
    # Разделяем датасет: одна часть для обработки, другая - для игнорирования
    dataset_to_process = dataset[~is_label_2].copy()  # Строки, где метка НЕ 2.0
    dataset_to_ignore = dataset[is_label_2]          # Строки, где метка 2.0

    # Если нет строк для обработки (например, все метки были 2.0 или датасет_для_обработки пуст)
    if dataset_to_process.empty:
        return dataset # Возвращаем исходный датасет

    # --- Выбор признаков для KMeans ---
    all_column_names = list(dataset_to_process.columns)
    # Предполагаем, что первый столбец - это ID и он не используется в кластеризации
    id_column_name = all_column_names[0] 
    
    # Признаки - это все столбцы, кроме ID и столбца 'labels'
    feature_column_names = [
        col for col in all_column_names if col not in [id_column_name, 'labels']
    ]
    
    if not feature_column_names:
        # Если признаки не найдены (например, датасет содержит только ID и 'labels')
        print("Предупреждение: Признаки для кластеризации не найдены. "
              "Возвращаем данные без коррекции меток для обрабатываемой части.")
        if not dataset_to_ignore.empty:
            return pd.concat([dataset_to_process, dataset_to_ignore]).sort_index()
        else:
            return dataset_to_process
            
    features_for_kmeans = dataset_to_process[feature_column_names]

    # --- Кластеризация KMeans ---
    # KMeans требует n_samples >= n_clusters. sklearn обрабатывает это, устанавливая n_clusters = n_samples, если n_samples слишком мало.
    # Для данной задачи коррекции, кластеризация имеет смысл, если есть хотя бы 2 кластера.
    effective_n_clusters = min(n_clusters, len(dataset_to_process))

    if effective_n_clusters < 2: 
        # Если эффективное количество кластеров меньше 2 (например, 0 или 1 сэмпл, или пользователь задал n_clusters < 2)
        print(f"Предупреждение: Эффективное количество кластеров ({effective_n_clusters}) меньше 2. "
              "Пропускаем кластеризацию и коррекцию меток для обрабатываемой части.")
        if not dataset_to_ignore.empty:
            return pd.concat([dataset_to_process, dataset_to_ignore]).sort_index()
        else:
            return dataset_to_process # Возвращаем часть данных, которая должна была обрабатываться, без изменений
    
    try:
        # n_init='auto' для совместимости с новыми версиями sklearn (в старых по умолчанию было 10)
        # random_state для воспроизводимости результатов
        kmeans = KMeans(n_clusters=effective_n_clusters, n_init='auto', random_state=42)
        dataset_to_process['clusters'] = kmeans.fit_predict(features_for_kmeans)
    except ValueError as e:
        # Может произойти, если, например, все признаки идентичны, а effective_n_clusters > 1
        print(f"Ошибка кластеризации KMeans: {e}. Возвращаем данные без коррекции меток для обрабатываемой части.")
        if 'clusters' in dataset_to_process.columns: # Удаляем столбец, если он был добавлен до ошибки
            dataset_to_process.drop(columns=['clusters'], inplace=True)
        if not dataset_to_ignore.empty:
            return pd.concat([dataset_to_process, dataset_to_ignore]).sort_index()
        else:
            return dataset_to_process

    # --- Вычисляем среднее значение 'labels' для каждого кластера ---
    cluster_means = dataset_to_process.groupby('clusters')['labels'].mean()
    
    if cluster_means.empty: # Маловероятно, если KMeans отработал успешно
        print("Предупреждение: Средние значения кластеров не вычислены. Пропускаем коррекцию меток.")
        dataset_to_process = dataset_to_process.drop(columns=['clusters'])
        if not dataset_to_ignore.empty:
            return pd.concat([dataset_to_process, dataset_to_ignore]).sort_index()
        else:
            return dataset_to_process

    # --- Сортируем кластеры по удаленности их средних значений от 0.5 ---
    # Учитываем, что subset_size не может быть больше реального количества кластеров
    effective_subset_size = min(subset_size, len(cluster_means))
    
    if effective_subset_size <= 0: # Если subset_size=0 или нет кластеров
        dataset_to_process = dataset_to_process.drop(columns=['clusters'])
        if not dataset_to_ignore.empty:
            return pd.concat([dataset_to_process, dataset_to_ignore]).sort_index()
        else:
            return dataset_to_process

    sorted_cluster_indices_by_dist = cluster_means.sub(0.5).abs().sort_values(ascending=False).index
    clusters_to_correct = sorted_cluster_indices_by_dist[:effective_subset_size]
    
    # --- Создаем словарь для отображения средних значений в новые значения для выбранных кластеров ---
    correction_map = {
        cluster_idx: 0.0 if cluster_means[cluster_idx] < 0.5 else 1.0
        for cluster_idx in clusters_to_correct
    }
    
    # --- Применяем изменения к исходным значениям 'labels' только для выбранных кластеров ---
    # Используем .loc для более эффективного присваивания
    for cluster_idx_to_correct, new_label_value in correction_map.items():
        rows_in_cluster = dataset_to_process['clusters'] == cluster_idx_to_correct
        dataset_to_process.loc[rows_in_cluster, 'labels'] = new_label_value
    
    # Удаляем временный столбец 'clusters'
    dataset_to_process = dataset_to_process.drop(columns=['clusters'])
    
    # --- Объединяем обработанную и проигнорированную части ---
    # Сортируем по индексу для восстановления исходного порядка, если необходимо
    if not dataset_to_ignore.empty:
        final_dataset = pd.concat([dataset_to_process, dataset_to_ignore]).sort_index()
    else:
        final_dataset = dataset_to_process # Уже отсортирован, если был отсортирован ранее
        
    return final_dataset

def fix_labels_subset_mean_del(dataset, n_clusters=10, subset_size=5) -> pd.DataFrame:
    # Применяем KMeans для кластеризации
    dataset['clusters'] = KMeans(n_clusters=n_clusters).fit(dataset[dataset.columns[:-1]]).labels_

    # Вычисляем среднее значение 'labels' для каждого кластера
    cluster_means = dataset.groupby('clusters')['labels'].mean()

    # Сортируем кластеры по их средним значениям и выбираем те, которые наиболее далеки от 0.5
    sorted_clusters = cluster_means.sub(0.5).abs().sort_values(ascending=False).index[:subset_size]

    # Создаем словарь для отображения средних значений в новые значения только для выбранных кластеров
    mean_to_new_value = {cluster: 0.0 if mean < 0.5 else 1.0 for cluster, mean in cluster_means.items() if cluster in sorted_clusters}

    # Применяем изменения к исходным значениям 'labels' только для выбранных кластеров
    dataset['labels'] = dataset.apply(lambda row: mean_to_new_value[row['clusters']] if row['clusters'] in mean_to_new_value else row['labels'], axis=1)

    # Удаляем строки, которые не попали в sorted_clusters
    dataset = dataset[dataset['clusters'].isin(sorted_clusters)]

    # Удаляем временный столбец 'clusters'
    dataset = dataset.drop(columns=['clusters'])

    return dataset

def fix_labels_subset_mean_balanced(dataset, n_clusters=200, subset_size=100) -> pd.DataFrame:
    # Применяем KMeans для кластеризации
    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(dataset[dataset.columns[1:-2]])
    dataset['clusters'] = KMeans(n_clusters=n_clusters).fit(X_scaled).labels_
    
    # Вычисляем среднее значение 'labels' для каждого кластера
    cluster_means = dataset.groupby('clusters')['labels'].mean()
    
    # Сортируем кластеры по их средним значениям
    sorted_clusters = cluster_means.sort_values()
    
    # Выбираем равное количество кластеров с labels=0 и labels=1
    n_each = min(subset_size // 2, len(sorted_clusters) // 2)
    selected_clusters_0 = sorted_clusters.head(n_each).index
    selected_clusters_1 = sorted_clusters.tail(n_each).index
    selected_clusters = np.concatenate([selected_clusters_0, selected_clusters_1])
    
    # Создаем словарь для отображения средних значений в новые значения только для выбранных кластеров
    mean_to_new_value = {cluster: 0.0 if mean < 0.5 else 1.0 for cluster, mean in cluster_means.items() if cluster in selected_clusters}
    
    # Применяем изменения к исходным значениям 'labels' только для выбранных кластеров
    dataset['labels'] = dataset.apply(lambda row: mean_to_new_value[row['clusters']] if row['clusters'] in mean_to_new_value else row['labels'], axis=1)
    dataset = dataset.drop(columns=['clusters'])
    return dataset

def fix_labels_with_isolation_forest(dataset, contamination=0.1) -> pd.DataFrame:
    """
    Использует Isolation Forest для обнаружения выбросов и переразметки примеров.
    
    Args:
        dataset: Исходный датасет с признаками и метками
        contamination: Ожидаемая доля выбросов в данных
        
    Returns:
        Датасет с откорректированными метками
    """
    from sklearn.ensemble import IsolationForest
    
    df = dataset.copy()
    features = df.iloc[:, 1:-1]
    
    # Отдельно обрабатываем данные с меткой 0 и 1
    class_0 = df[df['labels'] < 0.5]
    class_1 = df[df['labels'] >= 0.5]
    
    for class_df, label in [(class_0, 0.0), (class_1, 1.0)]:
        if len(class_df) > 10:  # Минимальный размер для применения алгоритма
            # Находим выбросы внутри класса
            isolation = IsolationForest(contamination=contamination, random_state=42)
            class_features = class_df.iloc[:, 1:-1]
            outliers = isolation.fit_predict(class_features)
            
            # Идентифицируем индексы выбросов (-1) и меняем их метки на противоположные
            outlier_indices = class_df.index[outliers == -1]
            df.loc[outlier_indices, 'labels'] = 1.0 if label == 0.0 else 0.0
    
    return df

def fix_labels_with_decision_tree(dataset, max_depth=3) -> pd.DataFrame:
    """
    Использует решающее дерево для улучшения меток на основе паттернов в данных.
    
    Args:
        dataset: Исходный датасет с признаками и метками
        max_depth: Максимальная глубина дерева для предотвращения переобучения
        
    Returns:
        Датасет с откорректированными метками
    """
    from sklearn.tree import DecisionTreeClassifier
    from sklearn.model_selection import cross_val_score
    
    df = dataset.copy()
    features = df.iloc[:, 1:-1]
    labels = df['labels'].round().astype(int)  # Округляем до 0 или 1
    
    # Находим примеры, где дерево решений сильно расходится с исходными метками
    tree = DecisionTreeClassifier(max_depth=max_depth, random_state=42)
    tree.fit(features, labels)
    
    # Получаем уверенность модели в предсказаниях
    confidences = tree.predict_proba(features)
    predicted_labels = tree.predict(features)
    
    # Находим примеры, где модель очень уверена, но метка противоположная
    confidence_threshold = 0.8
    for i, (pred, conf, orig) in enumerate(zip(predicted_labels, confidences, labels)):
        # Берем уверенность в предсказанном классе
        confidence = conf[pred]
        if confidence > confidence_threshold and pred != orig:
            # Модель очень уверена, что метка должна быть другой
            df.iloc[i, -1] = float(pred)
    
    return df

def fix_labels_with_density_estimation(dataset, bandwidth=0.5) -> pd.DataFrame:
    """
    Использует оценку плотности распределения для переразметки примеров.
    
    Args:
        dataset: Исходный датасет с признаками и метками
        bandwidth: Параметр сглаживания для KDE
        
    Returns:
        Датасет с откорректированными метками
    """
    from sklearn.neighbors import KernelDensity
    
    df = dataset.copy()
    features = df.iloc[:, 1:-1]
    
    # Разделяем данные по классам
    class_0 = features[df['labels'] < 0.5]
    class_1 = features[df['labels'] >= 0.5]
    
    if len(class_0) > 5 and len(class_1) > 5:  # Проверяем, что достаточно данных
        # Обучаем оценщики плотности для обоих классов
        kde_0 = KernelDensity(bandwidth=bandwidth).fit(class_0)
        kde_1 = KernelDensity(bandwidth=bandwidth).fit(class_1)
        
        # Оцениваем плотность вероятности каждого примера в обоих распределениях
        log_dens_0 = kde_0.score_samples(features)
        log_dens_1 = kde_1.score_samples(features)
        
        # Переразмечаем примеры, где плотность вероятности явно указывает на другой класс
        for i in range(len(df)):
            # Если пример ближе к плотности противоположного класса с большим отрывом
            if log_dens_0[i] > log_dens_1[i] + 1 and df.iloc[i, -1] >= 0.5:
                df.iloc[i, -1] = 0.0
            elif log_dens_1[i] > log_dens_0[i] + 1 and df.iloc[i, -1] < 0.5:
                df.iloc[i, -1] = 1.0
                
    return df

def fix_labels_with_self_training(dataset, test_size=0.3) -> pd.DataFrame:
    """
    Использует полуавтоматическое обучение (self-training) для переразметки.
    
    Args:
        dataset: Исходный датасет с признаками и метками
        test_size: Размер части данных, которая будет переразмечена
    
    Returns:
        Датасет с откорректированными метками
    """
    from sklearn.model_selection import train_test_split
    from sklearn.ensemble import RandomForestClassifier
    
    df = dataset.copy()
    features = df.iloc[:, 1:-1]
    labels = df['labels'].round().astype(int)
    
    # Разделяем данные на "надежную" часть и часть для переразметки
    X_train, X_test, y_train, _ = train_test_split(
        features, labels, test_size=test_size, random_state=42, 
        stratify=labels  # Сохраняем распределение классов
    )
    
    # Обучаем модель на "надежной" части
    model = RandomForestClassifier(n_estimators=100, random_state=42)
    model.fit(X_train, y_train)
    
    # Получаем индексы тестовой части
    test_indices = X_test.index
    
    # Предсказываем новые метки с уверенностью
    probas = model.predict_proba(X_test)
    new_labels = model.predict(X_test)
    
    # Переразмечаем только примеры с высокой уверенностью
    confidence_threshold = 0.75
    for i, idx in enumerate(test_indices):
        confidence = max(probas[i])
        if confidence > confidence_threshold:
            df.loc[idx, 'labels'] = float(new_labels[i])
    
    return df

def fix_labels_with_active_learning(dataset, budget=0.2) -> pd.DataFrame:
    """
    Симулирует активное обучение для переразметки наиболее неопределенных примеров.
    
    Args:
        dataset: Исходный датасет с признаками и метками
        budget: Доля примеров для переразметки (0-1)
    
    Returns:
        Датасет с откорректированными метками
    """
    from sklearn.ensemble import RandomForestClassifier
    
    df = dataset.copy()
    features = df.iloc[:, 1:-1]
    labels = df['labels'].round().astype(int)
    
    # Начальная модель на всех данных
    model = RandomForestClassifier(n_estimators=100, random_state=42)
    model.fit(features, labels)
    
    # Находим примеры с наибольшей неопределенностью
    probas = model.predict_proba(features)
    uncertainties = [1 - max(p) for p in probas]  # Чем ближе к 0.5, тем выше неопределенность
    
    # Количество примеров для переразметки
    n_samples = int(len(df) * budget)
    
    # Индексы примеров с наибольшей неопределенностью
    uncertain_indices = sorted(range(len(uncertainties)), 
                             key=lambda i: uncertainties[i], 
                             reverse=True)[:n_samples]
    
    # "Переразмечаем" неопределенные примеры, используя модель, обученную на оставшихся
    mask = ~df.index.isin(uncertain_indices)
    confident_features = features[mask]
    confident_labels = labels[mask]
    
    # Переобучаем модель только на уверенных примерах
    model = RandomForestClassifier(n_estimators=100, random_state=42)
    model.fit(confident_features, confident_labels)
    
    # Предсказываем метки для неопределенных примеров
    uncertain_features = features.iloc[uncertain_indices]
    new_labels = model.predict(uncertain_features)
    
    # Обновляем метки
    for i, idx in zip(range(len(uncertain_indices)), uncertain_indices):
        df.iloc[idx, -1] = float(new_labels[i])
    
    return df

def fix_labels_with_metric_learning(dataset, k=5) -> pd.DataFrame:
    """
    Использует метрическое обучение для поиска неправильно размеченных примеров.
    
    Args:
        dataset: Исходный датасет с признаками и метками
        k: Количество соседей для рассмотрения
    
    Returns:
        Датасет с откорректированными метками
    """
    from sklearn.neighbors import NearestNeighbors
    from sklearn.preprocessing import StandardScaler
    
    df = dataset.copy()
    features = df.iloc[:, 1:-1]
    labels = df['labels'].values
    
    # Нормализация признаков
    scaler = StandardScaler()
    features_scaled = scaler.fit_transform(features)
    
    # Находим k ближайших соседей для каждой точки
    nn = NearestNeighbors(n_neighbors=k+1)  # +1 так как точка сама будет своим соседом
    nn.fit(features_scaled)
    distances, indices = nn.kneighbors(features_scaled)
    
    for i in range(len(df)):
        # Получаем индексы соседей (пропускаем первый - это сама точка)
        neighbor_indices = indices[i, 1:]
        neighbor_labels = labels[neighbor_indices]
        
        # Вычисляем среднюю метку соседей
        mean_neighbor_label = np.mean(neighbor_labels)
        
        # Если метка сильно отличается от метки соседей, вероятно ошибка
        current_label = labels[i]
        if abs(current_label - mean_neighbor_label) > 0.7:  # Порог различия
            # Переразмечаем на основе соседей
            df.iloc[i, -1] = 1.0 if mean_neighbor_label >= 0.5 else 0.0
    
    return df

def fix_labels_with_label_spreading(dataset, alpha=0.8) -> pd.DataFrame:
    """
    Использует распространение меток для исправления метки с учетом структуры данных.
    
    Args:
        dataset: Исходный датасет с признаками и метками
        alpha: Параметр регуляризации (0-1)
    
    Returns:
        Датасет с откорректированными метками
    """
    from sklearn.semi_supervised import LabelSpreading
    from sklearn.preprocessing import StandardScaler
    import numpy as np
    
    df = dataset.copy()
    features = df.iloc[:, 1:-1]
    labels = df['labels'].round().astype(int).values
    
    # Нормализация данных
    scaler = StandardScaler()
    features_scaled = scaler.fit_transform(features)
    
    # Создаем маску "неуверенности" для части примеров
    uncertainty = np.abs(df['labels'] - df['labels'].round())
    # Помечаем как ненадежные примеры с наибольшей неопределенностью
    uncertain_threshold = np.percentile(uncertainty, 70)  # Верхние 30% по неопределенности
    uncertain_mask = uncertainty >= uncertain_threshold
    
    # Копируем метки для модификации
    modified_labels = labels.copy()
    # Помечаем неопределенные метки как -1 (неизвестные)
    modified_labels[uncertain_mask] = -1
    
    # Применяем LabelSpreading для распространения надежных меток на неопределенные
    label_spreading = LabelSpreading(kernel='knn', alpha=alpha)
    new_labels = label_spreading.fit(features_scaled, modified_labels).predict(features_scaled)
    
    # Обновляем только те метки, которые были помечены как неопределенные
    df.loc[uncertain_mask, 'labels'] = new_labels[uncertain_mask].astype(float)
    
    return df

def fix_labels_with_bayesian_approach(dataset, n_samples=1000) -> pd.DataFrame:
    """
    Использует байесовский подход для оценки неопределенности и переразметки.
    
    Args:
        dataset: Исходный датасет с признаками и метками
        n_samples: Количество семплов для оценки неопределенности
    
    Returns:
        Датасет с откорректированными метками
    """
    import numpy as np
    from sklearn.ensemble import RandomForestClassifier
    from sklearn.model_selection import KFold
    
    df = dataset.copy()
    features = df.iloc[:, 1:-1]
    labels = df['labels'].round().astype(int)
    
    # Разделим данные на фолды для кросс-валидации
    kf = KFold(n_splits=5, shuffle=True, random_state=42)
    
    # Для каждого примера собираем предсказания из разных моделей
    all_predictions = np.zeros((len(df), n_samples))
    
    # Создаем много "семплов" моделей
    for sample in range(n_samples):
        # Выбираем разные подмножества признаков и примеров
        bootstrap_indices = np.random.choice(len(df), size=int(0.8*len(df)), replace=True)
        feature_indices = np.random.choice(features.shape[1], 
                                          size=int(0.8*features.shape[1]), 
                                          replace=False)
        
        # Выбираем подмножество данных
        X_bootstrap = features.iloc[bootstrap_indices, feature_indices]
        y_bootstrap = labels.iloc[bootstrap_indices]
        
        # Обучаем модель
        model = RandomForestClassifier(n_estimators=10, max_depth=3, random_state=sample)
        model.fit(X_bootstrap, y_bootstrap)
        
        # Предсказываем на всем датасете
        bootstrap_predictions = model.predict(features.iloc[:, feature_indices])
        all_predictions[:, sample] = bootstrap_predictions
    
    # Оцениваем неопределенность как вариативность предсказаний
    prediction_means = np.mean(all_predictions, axis=1)
    prediction_stds = np.std(all_predictions, axis=1)
    
    # Переразмечаем примеры с низкой неопределенностью и отличающимся предсказанием
    for i in range(len(df)):
        original_label = labels.iloc[i]
        mean_prediction = prediction_means[i]
        std_prediction = prediction_stds[i]
        
        # Если стандартное отклонение низкое (уверенное предсказание) и
        # предсказание отличается от оригинальной метки
        if std_prediction < 0.2 and abs(mean_prediction - original_label) > 0.5:
            df.iloc[i, -1] = round(mean_prediction)
    
    return df
