Пропуски в данных или NaN — это одна из самых частых проблем, с которой сталкиваются аналитики. И мало эти пропуски найти, важно еще правильно поработать с ними. Библиотека pandas предлагает множество методов замены пропусков, но на практике нередко возникает другой вопрос: а какой метод лучше выбрать, чтобы не исказить данные и сохранить их изначальный смысл и структуру?
В этой статье я покажу, как можно автоматизировать работу по заполнению пропусков в датафреймах pandas и сравнить эффективность 14 популярных способов импутации, с учетом их влияния на статитистические параметры данных. На конкретном примере мы сравним какие методы выполняют импутацию NaN максимально бережно, а какие могут существенно менять распределение.
Риски неправильного заполнения пропусков и сложность выбора метода
Работа с пропусками — важный этап в анализе данных и тут важна аккуратная работа. Неверное заполнение NaN в датафреймах может привести к нескольким проблемам:
- Искажение статистики данных: могут кардинально измениться среднее, медиана и разброс значений;
- Нарушение временной структуры: особенно во временных рядах, где пропуски разрывают автокорреляцию;
- Появление ложных корреляций/закономерностей: когда в данных возникают артефакты, которых не было в исходном наборе;
- Снижение качества моделей и потеря их устойчивости: если пропусков много, на искаженных данных ML-алгоритмы склонны к недообучению или переобучению, возрастают риски ошибочной классификации / прогнозов при работе на «боевых» данных;
- Нарушение бизнес-логики: формируются значения, невозможные или некорректные с точки зрения предметной области.
В библиотеках pandas и scikit-learn есть множество способов заполнения пропусков:
- Простые статистические методы: среднее и медиана;
- Последовательное заполнение: прямое и обратное заполнение (forward fill и backward fill);
- Интерполяция: линейная, полиномиальная и на основе сплайнов;
- Продвинутые методы машинного обучения: алгоритм ближайших соседей (KNN) и множественное восстановление пропусков (MICE).
Главная проблема заключается в том, что при работе с незнакомым датасетом нам остается угадывать какой метод подойдет лучше всего. Приходится пробовать каждый вручную, писать однотипный код и субъективно оценивать качество результата.
Пишем функцию автоматизации
Чтобы автоматизировать заполнение пропусков, нам нужна универсальная функция, которая сможет честно сравнивать разные методы. Для ее разработки сначала подготовим данные, на которых будем проводить эксперимент.
Подготовка данных и разведочный анализ (EDA)
Для того, чтобы объективно сравнивать методы импутации, нам нужен датасет с известными свойствами. Мы создадим синтетический временной ряд с трендом, сезонностью, периодическими колебаниями (циклами), случайным шумом. Затем искусственно добавим 15% пропусков и попробуем их восстановить так, чтобы максимально сохранить исходные свойства.
Почему 15%? Это середина диапазона 10%-20% — частая доля пропусков во многих реальных датасетах. Если пропусков меньше 5% — почти любой метод даст хороший результат. Если больше 30% — данные в принципе будут сильно искажаться любыми методами импутации.
import numpy as np
import pandas as pd
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import KNNImputer, IterativeImputer
import matplotlib.pyplot as plt
from matplotlib.ticker import PercentFormatter
# Генерируем синтетический датасет
np.random.seed(42)
dates = pd.date_range('2025-05-01', periods=100, freq='D')
prices = 1000 + np.cumsum(np.random.randn(100)*20) + np.sin(np.arange(100)*0.3)*100 # тренд + сезонность
df = pd.DataFrame({
'Date': dates,
'Prices': prices
})
df.set_index('Date', inplace=True)
# Добавляем 15% NaN
nan_idx = np.random.choice(100, 15, replace=False)
df.loc[df.index[nan_idx], 'Prices'] = np.nan
print(f"Датасет для тестирования: {len(df)} строк, {df['Prices'].isna().sum()} NaN ({df['Prices'].isna().mean()*100:.0f}%)")
print(df.sample(10))
Датасет для тестирования: 100 строк, 15 NaN (15%)
Prices
Date
2025-07-03 760.109153
2025-05-12 1055.254704
2025-06-26 NaN
2025-07-24 814.347983
2025-08-08 693.358257
2025-05-16 894.105795
2025-06-09 748.892171
2025-07-04 805.648893
2025-05-13 NaN
2025-06-24 745.379006
Что создает этот код:
- Исскуственный временной ряд цен с гранулярностью по дням года — 100 наблюдений за период с мая по август 2025;
- 15 случайно выбранных значений заменены на NaN;
- Исходный ряд без пропусков сохранен для последующего сравнения;
- Данные проиндексированы по дате — важно для временных методов.
Обратите внимание на формулу генерации:
prices = 1000 + np.cumsum(np.random.randn(100)*20) + np.sin(np.arange(100)*0.3)*100
Здесь:
- 1000 — базовый уровень;
- np.cumsum(…) — генерация тренда;
- np.sin(…) — синусоидальная сезонность
Такая конструкция имитирует реальные финансовые или экономические временные ряды. Теперь давайте проанализируем визуально исходный временной ряд.
# График исходного ряда с пропусками
plt.figure(figsize=(8, 5))
plt.plot(df['Prices'], color='darkblue')
plt.show()

Рис. 1: График исходного ряда с пропусками
На графике видны разрывы — места, где данные отсутствуют. Pandas автоматически не соединяет линией точки через пропуски, что помогает увидеть масштаб проблемы.
Интересно также отметить, что пропуски распределены случайно по всему ряду. Это типичная ситуация для:
- Данных с датчиков (сбои оборудования);
- Данных веб-аналитики (проблемы с трекингом посещений);
- Финансовых данных (приостановка торгов в выходные и праздники).
Отмечу, что мы имеем дело с небольшими и нечастыми пробелами в данных. Если пропуски идут подряд большими блоками — это другой тип проблемы, требующий иных подходов (например, моделирование всего отсутствующего периода).
Базовая статистика: что мы пытаемся сохранить
Прежде чем заполнять пропуски, важно зафиксировать статистические свойства исходных данных (без NaN). Это наш «золотой стандарт» — именно к нему должны стремиться все методы импутации.
# Выводим исходные статистики
print(df.describe())
# И сохраняем их для сравнения
describe_original = df['Prices'].describe().to_frame('original')
Prices
count 85.000000
mean 870.554022
std 117.447765
min 689.871709
25% 773.402592
50% 871.363306
75% 922.144618
max 1170.186578
Ключевые метрики исходного датасета:
- count: 85 — видно что есть только 85 реальных наблюдений, без NaN;
- mean: 870.55 — средняя цена;
- std: 117.45 — волатильность (разброс);
- min/max: 689.87 / 1170.19 — диапазон значений;
- квартили (25%, 50%, 75%) — распределение данных.
Эти цифры мы будем использовать как бенчмарк. Идеальный метод заполнения должен:
- Сохранить похожее среднее и медиану — не сдвинуть центр распределения;
- Не увеличить / не уменьшить дисперсию — сохранить естественную волатильность;
- Не создать выбросов — оставаться в пределах min/max или близко к ним;
- Сохранить форму распределения — квартили должны остаться примерно на месте.
В чем тут может быть сложность? Методы заполнения работают по-разному: простые (mean/median) тянут значения к центру и уменьшают дисперсию, последовательные (forward/backward fill) копируют соседей и могут создавать плато, интерполяция сглаживает скачки, а методы машинного обучения чаще отдают предпочтения глобальным паттернам вместо локальных, плюс более время и вычислительно затратны.
Универсальная функция fill_na: швейцарский нож для импутации
Обычно аналитикам для каждого метода приходится писать отдельный код:
df['col'].fillna(df['col'].mean()) # для mean
df['col'].fillna(method='ffill') # для forward fill
df['col'].interpolate(method='linear') # для интерполяции
# и так далее
Это неудобно, особенно когда нужно быстро протестировать более десятка разных методов. Поэтому создадим единую функцию, которая принимает название метода, имеет под капотом все популярные способы импутации, легко вызывается, настраивается и легко масштабируется новыми методами.
# Универсальная функция замены NaN
def fill_na(df, col, method, inplace=False, constant_value=1000):
ma = {'ma5': 5, 'ma10': 10, 'ma20': 20} #Moving averages periods
poly_order = 2 #Polynomial regression order
knn_neighbors = 5 #KNN neighbors
rstate = 42 #Random state for Iterative Imputer
s = df[col]
if method == 'median':
filled = s.fillna(s.median())
elif method == 'mean':
filled = s.fillna(s.mean())
elif method == 'most_frequent':
mode = s.mode()
filled = s.fillna(mode[0] if not mode.empty else 0)
elif method == 'zero':
filled = s.fillna(0)
elif method == 'constant':
filled = s.fillna(constant_value)
elif method == 'ffill':
filled = s.fillna(method='ffill')
elif method == 'bfill':
filled = s.fillna(method='bfill')
elif method in ma:
filled = s.fillna(s.rolling(ma[method], min_periods=1).mean())
elif method == 'linear_interpol':
filled = s.interpolate(method='linear')
elif method == 'poly_interpol':
filled = s.interpolate(method='polynomial', order=poly_order)
elif method == 'knn':
imputer = KNNImputer(n_neighbors=knn_neighbors)
filled_array = imputer.fit_transform(df[[col]])
filled = pd.Series(filled_array.flatten(), index=df.index)
elif method == 'iterative':
imputer = IterativeImputer(random_state=rstate)
filled_array = imputer.fit_transform(df[[col]])
filled = pd.Series(filled_array.flatten(), index=df.index)
else:
raise ValueError(f"Unknown method: {method}")
if inplace:
df[col] = filled
return None
else:
return filled
# Список всех методов
FILL_METHODS = ['median', 'mean', 'most_frequent', 'zero', 'constant',
'ffill', 'bfill', 'ma5', 'ma10', 'ma20',
'linear_interpol', 'poly_interpol', 'knn', 'iterative']
print("Функция fill_na загружена успешно!")
print("Методы замены NaN:", FILL_METHODS)
Функция fill_na загружена успешно!
Методы замены NaN: ['median', 'mean', 'most_frequent', 'zero', 'constant', 'ffill', 'bfill', 'ma5', 'ma10', 'ma20', 'linear_interpol', 'poly_interpol', 'knn', 'iterative']
Вызывается функция очень просто:
fill_na(df, col, method, inplace=False, constant_value=1000)
В скобках параметры функции означают:
- df — название датафрейма;
- col — название столбца для заполнения пропусков;
- method — строка с названием метода (представлен в списке выше);
- inplace — надо ли изменять исходный датафрейм (True) или вернуть новый объект Series (False);
- constant_value — значение для метода ‘constant’ (замена NaN определенной константой).
Я не буду здесь полностью перечислять особенности каждого метода, лишь отмечу что это самые популярные методы работы с NaN сегодня, включающие не только среднюю, медиану, моду, замену нулями, но и методы заполнения пропуска предыдущим значением (ffill), последующим (bfill), разными скользящими средними (ma5-ma20), разными вариантами интерполяций и двумя методами из машинного обучения: k-nearest neighbors (knn) и iterarive (mice).
Отдельно отмечу, что функция защищена от некоторых частых ошибок:
- Метод most_frequent использует mode() с проверкой на отсутствие моды;
- Скользящие средние имеют ограничитель min_periods=1, что гарантирует расчет даже на краях ряда;
- Все константы: периоды средних, ордер полинома, число соседей knn, random_state вынесены отдельно, в начало функции для облегчения экспериментов и подбора лучших значений.
Функция гибкая. Хотите добавить новый метод? Просто добавьте еще один elif:
elif method == 'spline':
filled = s.interpolate(method='spline', order=3)
Нужны другие параметры? Вынесите их в аргументы функции или создайте словарь настроек.
Визуализация влияния: как разные методы импутации меняют данные
Теперь самое интересное — как понять, какой метод указать в функции fill_na? Какой метод будет лучшим выбором для конкретного набора данных?
Нельзя просто выбрать наобум. Нужно убедиться, что метод не исказил распределение, сохранил временные зависимости и не создал аномалий.
Для этого создадим функцию plot_fill_impact(), которая:
- Применит все 14 методов к копиям данных;
- Посчитает 5 ключевых статистических метрик до и после заполнения NaN;
- Визуализирует относительные изменения (Δ%) для каждого метода, где 0% — отсутствие изменений;
- Отсортирует методы от наименее искажающих данные к наиболее искажающим.
Почему будем сравнивать по относительным изменениям в %? Потому что абсолютные значения не передают масштаб расхождений разных метрик (среднее может быть 870, а автокорреляция 0.6).
Ниже код функции plot_fill_impact():
# Функция визуализации изменения статистик после замены NaN разными методами
def plot_fill_impact(df, col, methods=None):
if methods is None:
methods = FILL_METHODS
original = df[col]
stats = {'mean': [], 'median': [], 'std': [], 'autocorr': [], 'seasonal': []}
seasonal_cycle = 7 #Сезонный цикл (по умолчанию 7 периодов)
for m in methods:
try:
filled = fill_na(df, col, m, inplace=False)
stats['mean'].append((original.mean(), filled.mean()))
stats['median'].append((original.median(), filled.median()))
stats['std'].append((original.std(), filled.std()))
# Автокорреляция
stats['autocorr'].append((original.autocorr(lag=1) or 0, filled.autocorr(lag=1) or 0))
# Сезонность
stats['seasonal'].append((original.rolling(seasonal_cycle, center=True).std().std() or 0,
filled.rolling(seasonal_cycle, center=True).std().std() or 0))
except Exception:
for k in stats:
stats[k].append((np.nan, np.nan))
fig, axs = plt.subplots(5, 1, figsize=(9, 18))
titles = ['Mean', 'Median', 'Std', 'Autocorr', 'Seasonal']
stat_data = [stats['mean'], stats['median'], stats['std'],
stats['autocorr'], stats['seasonal']]
for i, (title, data) in enumerate(zip(titles, stat_data)):
before, after = zip(*data)
changes = np.array(after) - np.array(before)
rel_changes = np.divide(changes, np.abs(np.array(before)),
where=np.abs(np.array(before))>1e-10) * 100
valid_mask = ~np.isnan(rel_changes) & np.isfinite(rel_changes)
valid_changes = rel_changes[valid_mask]
valid_methods = np.array(methods)[valid_mask]
sort_idx = np.argsort(np.abs(valid_changes))
sorted_methods = valid_methods[sort_idx]
sorted_changes = valid_changes[sort_idx]
y = np.arange(len(sorted_methods))
positive = sorted_changes > 0
axs[i].bar(y[positive], sorted_changes[positive], color='#8B0000', alpha=0.6, width=0.6)
axs[i].bar(y[~positive], sorted_changes[~positive], color='#8B0000', alpha=0.6, width=0.6)
zero_idx = np.abs(sorted_changes) < 0.01
for j in np.where(zero_idx)[0]:
axs[i].hlines(0, y[j]-0.25, y[j]+0.25, colors='black', linewidth=3, alpha=0.9)
axs[i].set_xticks(y)
axs[i].set_xticklabels(sorted_methods, rotation=45, ha='right')
axs[i].set_title(f'{title} (Δ% от исходного)')
axs[i].yaxis.set_major_formatter(PercentFormatter())
axs[i].set_ylim(-15, 15) # Ограничиваем высоту шкалы y для удобства сравнения
axs[i].axhline(0, color='black', linewidth=1.5, alpha=0.8)
axs[i].grid(axis='y', linestyle='--', alpha=0.4)
axs[i].grid(True, axis='x', linestyle=':', color='gray', alpha=0.3, linewidth=0.8)
plt.tight_layout()
plt.show()
Технические особенности функции:
- Обработка ошибок через try-except для каждого метода — если какой-то сломается, остальные продолжат работу;
- Расчет сезонности через rolling(7, center=True).std().std() — стандартное отклонение от скользящих std (мера стабильности паттернов);
- Защита от деления на ноль: np.divide(…, where=…) — безопасное деление, избегаем inf;
- Фильтрация NaN через valid_mask убирает методы, в которых расчет не удался;
- Сортировка по модулю через np.argsort(np.abs(…)) — сначала показываем методы с минимальным влиянием;
- Ограничение Y-оси через ylim=(-15, 15) — если какой-то метод искажает статистики на 15% и более по модулю, то сравнивать их с другими смысла нет.
Теперь запустим функцию визуализации и внимательно изучим, как каждый из 14 методов влияет на данные. Это ключевой этап, который позволит принимать решения о выборе метода на основе фактов, а не просто потому, что так принято.
# Запуск функции визуализации изменений статистик
plot_fill_impact(df, 'Prices')

Рис. 2: Сравнительный анализ влияния 14 методов заполнения пропусков на ключевые статистические характеристики временного ряда
На графиках представлены относительные изменения (Δ%) пяти метрик после применения различных методов заполнения пропусков к временному ряду с 15% отсутствующих значений:
- Среднего значения (Mean);
- Медианы (Median);
- Стандартного отклонения (Std);
- Автокорреляции первого порядка (Autocorr);
- Сезонной волатильности (Seasonal).
Методы упорядочены по возрастанию абсолютного искажения каждой метрики. При этом столбцы близкие к нулевой линии указывают на минимальное влияние соответствующего метода на исходные статистические свойства данных.
Интерпретация результатов:
- Визуализация демонстрирует, что методы интерполяции (linear_interpol, poly_interpol) и машинного обучения (knn, iterative) показывают наименьшее искажение по всем 5 метрикам, в то время как простые статистические методы (mean, median, zero) и метод константного заполнения существенно изменяют дисперсию и автокорреляционную структуру временного ряда;
- Различия особенно заметны на графике стандартного отклонения, где глобальные методы заполнения (mean, median) систематически уменьшают волатильность данных на 5-10%, тогда как локальные методы (poly_interpol, linear_interpol) сохраняют исходную вариативность с отклонением менее 3%;
- Для автокорреляции наблюдается обратная картина: последовательные методы (ffill, bfill) и короткие скользящие средние (ma5, ma10) лучше сохраняют временную зависимость, в то время как глобальные методы приводят к ее снижению на 10-15%, что критично для задач прогнозирования и анализа причинно-следственных связей в динамических системах.
Вывод:
На основании комплексного анализа всех 5 метрик для данного временного ряда оптимальным методом является полиномиальная интерполяция (poly_interpol), демонстрирующая минимальное искажение по всем статистическим характеристикам одновременно — изменение среднего составило менее 1%, стандартного отклонения около 3%, при этом автокорреляционная структура и сезонные паттерны сохранились практически без изменений.
Альтернативой может служить линейная интерполяция (linear_interpol), уступающая poly_interpol только в способности улавливать нелинейные волнообразные паттерны, но превосходящая по вычислительной эффективности.
Применяем выбранный метод
Теперь, основываясь на результатах сравнительного анализа, заполним пропуски методом полиномиальной интерполяции. Используем inplace=True, чтобы изменения применились напрямую к датафрейму.
# Запускаем функцию заполнения пропусков, выбирая метод
# с наименьшим искажением "родных" статистик
fill_na(df, 'Prices', 'poly_interpol', inplace=True)
Теперь колонка df[‘Prices’] не содержит NaN, а заполненные значения оптимально вписываются в существующий ряд.
print(df.tail(15))
Date Prices
2025-07-25 833.497870
2025-07-26 877.783579
2025-07-27 904.808399
2025-07-28 907.308405
2025-07-29 922.144618
2025-07-30 919.724388
2025-07-31 926.192485
2025-08-01 891.856346
2025-08-02 859.430652
2025-08-03 822.449771
2025-08-04 778.322339
2025-08-05 741.496403
2025-08-06 723.396477
2025-08-07 706.743247
2025-08-08 693.358257
Важный момент: мы не можем проверить, насколько точно восстановлены именно эти 15 значений (мы не знаем «правильных» ответов), но мы знаем, что:
- Метод минимально исказил общие статистики;
- Значения находятся в правдоподобном диапазоне (689-1170);
- Временная структура сохранена.
В реальных проектах это лучшее, что можно сделать — выбрать метод, который лучше всего сохраняет известные свойства данных.
Проверка результата: сравниваем с исходными статистиками
Финальный шаг — убедиться, что наш метод действительно сработал хорошо. Сравним статистики заполненного датасета с исходным (помните, мы сохранили их в самом начале):
# Статистики изменененного датасета
describe_filled = df['Prices'].describe().to_frame('filled')
# Сравнение с исходным датасетом
comparison = pd.concat([describe_original, describe_filled], axis=1)
print(comparison)
original filled
count 85.000000 100.000000
mean 870.554022 874.959696
std 117.447765 121.188231
min 689.871709 689.871709
25% 773.402592 777.092402
50% 871.363306 874.063689
75% 922.144618 926.829211
max 1170.186578 1170.186578
Давайте построчно сравним, что изменилось и на сколько:

Рис. 3: Сравнительная таблица статистических метрик исходных данных до и после импутации пропусков
Все изменения исходных статистик минимальны. А теперь давайте посмотрим на график временного ряда:
# График временных рядов с заполненными пропусками
plt.figure(figsize=(8, 5))
plt.plot(df['Prices'], color='darkblue')
plt.show()

Рис. 4: График «восстановленного» временного ряда после заполнения 15% пропущенных значений
Мы теперь больше не видим разрывов линий и четко видим структуру ряда и все его тенденции. Корректное заполнение пропусков открывает новые возможности для работы с данными:
- Применение алгоритмов временных рядов: теперь можно использовать ARIMA, Prophet, LSTM и другие модели прогнозирования, которые требуют непрерывных данных без пропусков;
- Расчет производных метрик: теперь возможно корректно вычислять скользящие средние, экспоненциальное сглаживание, технические индикаторы без артефактов на границах пропусков;
- Сохранение временных зависимостей: автокорреляционная структура осталась неизменной, что важно для анализа причинно-следственных связей и выявления лагов между переменными;
- Статистическая надежность: минимальное искажение распределения (< 1% по среднему, < 3% по дисперсии) гарантирует корректность статистических тестов и доверительных интервалов;
- Обучение ML-моделей: заполненный датасет можно использовать для тренировки моделей машинного обучения без риска систематической ошибки из-за неправильно восстановленных значений;
- Кросс-валидация и бэктестинг: возможность проводить скользящую проверку моделей на всем диапазоне данных без необходимости разбивать выборку на фрагменты из-за пропусков;
- Визуализация и презентация: непрерывные графики выглядят профессионально и позволяют бизнес-пользователям видеть полную картину без «дыр» в данных.
Заключение
Работа с пропусками — важный этап в анализе данных. В этой статье мы рассмотрели как его можно автоматизировать: создали функцию с 14 методами импутации пропусков и научились визуально сравнивать, как каждый метод влияет на данные.
В нашем примере лучше всего справилась полиномиальная интерполяция. Однако данные бывают разными, и природа пропусков может быть тоже. На другом наборе данных этот метод может оказаться далеко не лучшим. Именно поэтому всегда важно проводить анализ и тестировать не один метод, а сразу десятки, чтобы определить оптимальный под вашу задачу.