Все курсы > Анализ и обработка данных > Занятие 6 (часть 2)
Перейдем к многомерным методам заполнения пропусков.
Продолжим работу в том же ноутбуке⧉
Про многомерные методы
Более продвинутый подход — многомерные методы (Multivariate Imputation), заполнение пропусков одной переменной на основе данных других признаков. Другими словами, мы строим модель машинного обучения для заполнения пропусков.

Такой моделью может быть линейная регрессия для количественных признаков или логистическая — для категориальных.
Обратите внимание, что хотя технически мы могли бы использовать исходную целевую переменную в качестве одного из признаков для заполнения пропусков, делать этого не стоит, потому что в этом случае мы создадим между ними взаимосвязь, которой изначально могло не быть.
Рассмотрим пример линейной регрессии и сразу два подхода к ее реализации, детерминированный и стохастический.
Линейная регрессия
Детерминированный подход
Детерминированный подход (deterministic approach) предполагает, что мы заполняем пропуски строго теми значениями, которые будут предсказаны линейной регрессией.
Подготовка данных
Теперь давайте подготовим данные.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# сделаем копию датасета lr = titanic.copy() # импортируем класс StandardScaler модуля Preprocessing библиотеки sklearn from sklearn.preprocessing import StandardScaler # создаем объект этого класса scaler = StandardScaler() # применяем метод .fit_transform() и сразу помещаем результат в датафрейм lr = pd.DataFrame(scaler.fit_transform(lr), columns = lr.columns) # посмотрим на результат lr.head(3) |

В тестовую выборку мы поместим те наблюдения, в которых в столбце Age есть пропуски.
1 2 3 |
# создадим маску из пустых значений в столбце Age с помощью метода .isnull() test = lr[lr['Age'].isnull()].copy() test.head(3) |

1 2 |
# посмотрим на количество таких строк test.shape |
1 |
(177, 7) |
В обучающей выборке напротив окажутся те строки, где в Age пропусков нет.
1 2 3 4 5 |
# используем метод .dropna(), чтобы избавиться от пропусков train = lr.dropna().copy() # оценим количество строк без пропусков train.shape |
1 |
(714, 7) |
Вместе обучающая и тестовая выборки должны дать 891 наблюдение.
1 |
len(train) + len(test) |
1 |
891 |
Из датафрейма train выделим столбец Age. Это будет наша целевая переменная.
1 2 3 4 5 6 7 8 |
# целевая переменная может быть в формате Series y_train = train['Age'] # также не забудем удалить столбец Age из датафрейма признаков X_train = train.drop('Age', axis = 1) # в test столбец Age не нужен в принципе X_test = test.drop('Age', axis = 1) |
Оценим результат.
1 2 |
# на этих признаках мы будем учить нашу модель X_train.head(3) |

1 2 |
# это будет нашей целевой переменной y_train.head(3) |
1 2 3 4 |
0 -0.530377 1 0.571831 2 -0.254825 Name: Age, dtype: float64 |
1 2 |
# на этих данных мы будем строить прогноз (заполнять пропуски) X_test.head(3) |

Мы готовы к обучению модели и заполнению пропусков.
Обучение модели и заполнение пропусков
Обучать модель линейной регрессии и строить прогноз мы уже умеем.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# импортируем класс LinearRegression from sklearn.linear_model import LinearRegression # создадим объект этого класса lr_model = LinearRegression() # обучим модель lr_model.fit(X_train, y_train) # применим обученную модель к данным, в которых были пропуски в столбце Age y_pred = lr_model.predict(X_test) # посмотрим на первые три прогнозных значения y_pred[:3] |
1 |
array([-0.04328349, 0.02819002, -0.499594 ]) |
Пропущенные значения заполнены. Остается обратно «собрать» датафрейм.
1 2 3 |
# присоединим прогнозные значения возраста к датафрейму test test['Age'] = y_pred test.head(3) |

Теперь у нас есть два датафрейма train и test со столбцом Age с заполненными пропусками.
1 2 |
# еще раз взглянем на датафрейм train train.head(3) |

Соединим их методом «один на другой» с помощью функции pd.concat().
1 2 |
lr = pd.concat([train, test]) lr.head(7) |

Как вы видите, по сравнению с изначальным датафреймом порядок строк нарушился. После четвертого индекса сразу идет шестой, а строка с пятым индексом оказалась где-то в середине датафрейма.
1 2 3 |
# восстановим изначальный порядок строк, отсортировав их по индексу lr.sort_index(inplace = True) lr.head(7) |

Остается вернуть исходный масштаб.
1 2 3 4 5 6 |
# вернем исходный масштаб с помощью метода .inverse_transform() lr = pd.DataFrame(scaler.inverse_transform(lr), columns = lr.columns) # округлим столбец Age и выведем результат lr.Age = lr.Age.round(1) lr.head(7) |

В данном случае, если метод .fit_transform() вычитает из каждого значения среднее и делит на СКО,
$$ z = \frac{x-\bar{x}}{\sigma} $$
то метод .inverse_transform() в обратном порядке умножает каждое число на СКО и прибавляет среднее арифметическое.
$$ x = z * \sigma + \bar{x} $$
Проверим на наших данных. Подставим в формулу выше отмасштабированное значение возраста первого наблюдения (индекс 0).
1 |
(-0.530377 * titanic.Age.std() + titanic.Age.mean()).round() |
1 |
22.0 |
Убедимся в отсутствии пропусков и посмотрим на размеры получившегося датафрейма.
1 |
lr.Age.isna().sum(), lr.shape |
1 |
(0, (891, 7)) |
Оценка результата
Вначале построим гистограмму.
1 2 3 |
# посмотрим на распределение возраста после заполнения пропусков sns.histplot(lr['Age'], bins = 20) plt.title('Распределение Age после заполнения с помощью линейной регрессии (дет.)'); |

Как вы видите, распределение чуть больше похоже на нормальное, чем при заполнении медианой.
Впрочем возникла одна проблема. Линейная регрессия предсказала нам отрицательные значения возраста, который разумеется должен быть только положительным. Как поступить? Просто удалить строки с неположительными значениями нам бы не хотелось, чтобы не терять с таким трудом восстановленные данные.
Воспользуемся методом .clip(), который установит минимальную границу значений столбца.
1 2 |
# установим минимальное значение на уровне 0,5 (полгода) lr.Age.clip(lower = 0.5, inplace = True) |
Остается посмотреть на новые средние показатели.
1 |
lr.Age.mean().round(1), lr.Age.median() |
1 |
(29.3, 28.3) |
Среднее арифметическое и медиана практически не изменились.
Особенность детерминированного подхода
В детерминированном подходе сохраняется та же особенность, которую мы наблюдали при заполнении пропусков медианой, а именно доминирование одного значения (в случае медианы) или узкого диапазона (в случае линейной регрессии).
Для того чтобы лучше это увидеть, во-первых, пометим изначальные (назовем их actual) и заполненные (imputed) значения столбца Age.
1 2 3 4 5 6 7 8 9 10 11 12 |
# сделаем копию датафрейма, которую используем для визуализации lr_viz = lr.copy() # создадим столбец Age_type, в который запишем actual, если индекс наблюдения есть в train, # и imputed, если нет (т.е. он есть в test) lr_viz['Age_type'] = np.where(lr.index.isin(train.index), 'actual', 'imputed') # вновь "обрежем" нулевые значения lr_viz.Age.clip(lower = 0.5, inplace = True) # посмотрим на результат lr_viz.head(7) |

Во-вторых, создадим точечную диаграмму, где по оси x будет индекс датафрейма, по оси y — возраст, а цветом мы обозначим изначальное это значение, или заполненное.
1 2 |
sns.scatterplot(data = lr_viz, x = lr_viz.index, y = 'Age', hue = 'Age_type') plt.title('Распределение изначальных и заполненных значений (лин. регрессия, дет. подход)'); |

На графике видно, что заполненные значения гораздо ближе к среднему значению (а зачастую просто равны ему), чем исходные данные. Аналогичную картину мы увидим, если рассчитаем соответствующие СКО.
1 2 |
lr_viz[lr_viz['Age_type'] == 'actual'].Age.std().round(2), \ lr_viz[lr_viz['Age_type'] == 'imputed'].Age.std().round(2) |
1 |
(14.53, 8.33) |
Можно предположить, что детерминированный подход переоценивает корреляцию между признаками, и, как следствие, преувеличивает точность прогноза пропущенных значений.
Попробуем преодолеть этот недостаток через внесение в заполняемые данные элемента случайности (дополнительных колебаний).
Стохастический подход
При применении стохастического подхода (Stochastic Regression Imputation) мы будем использовать гауссовский шум (Gaussian noise), то есть такой шум (элемент случайности), который следует нормальному распределению. Объявим соответствующую функцию.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# объявим функцию для создания гауссовского шума # на входе эта функция будет принимать некоторый массив значений x, # среднее значение mu, СКО std и точку отсчета для воспроизводимости результата def gaussian_noise(x, mu = 0, std = 1, random_state = 42): # вначале создадим объект, который позволит получать воспроизводимые результаты rs = np.random.RandomState(random_state) # применим метод .normal() к этому объекту для создания гауссовского шума noise = rs.normal(mu, std, size = x.shape) # добавим шум к исходному массиву return x + noise |
Среднее значение шума, равное нулю, мы взяли потому, что не хотим искажать колебания в ту или иную сторону. СКО равное, по умолчанию, единице указано исходя из того, что в линейной регрессии мы стремимся к работе со стандартизированными данными, отклонение которых как раз равно этой величине.
Заменим заполненные значения теми же значениями, но с добавлением шума.
1 2 3 4 |
test['Age'] = gaussian_noise(x = test['Age']) # посмотрим, как изменились заполненные значения test.head(3) |

Теперь соединим два датасета исходных и заполненных значений и оценим результат.
1 2 3 4 5 6 7 8 9 10 |
# соединим датасеты и обновим индекс lr_stochastic = pd.concat([train, test]) lr_stochastic.sort_index(inplace = True) # вернем исходный масштаб с помощью метода .inverse_transform() lr_stochastic = pd.DataFrame(scaler.inverse_transform(lr_stochastic), columns = lr_stochastic.columns) # округлим столбец Age и выведем результат lr_stochastic.Age = lr_stochastic.Age.round(1) lr_stochastic.head(7) |

1 2 3 4 |
# посмотрим на распределение возраста # после заполнения пропусков с помощью стохастического подхода sns.histplot(lr_stochastic['Age'], bins = 20) plt.title('Распределение Age после заполнения с помощью линейной регрессии (стох.)'); |

Как мы видим, распределение еще больше похоже на нормальное.
1 2 |
# обрежем нулевые и отрицательные значения lr_stochastic.Age.clip(lower = 0.5, inplace = True) |
Оценим среднее арифметическое и медиану.
1 |
lr_stochastic.Age.mean().round(1), lr_stochastic.Age.median() |
1 |
(29.3, 28.0) |
Медиана при стохастическом подходе вернулась к значению изначального распределения. Теперь с помощью точечной диаграммы оценим, как изменился разброс заполненных значений.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# сделаем копию датафрейма, которую используем для визуализации lr_st_viz = lr_stochastic.copy() # создадим столбец Age_type, в который запишем actual, если индекс наблюдения есть в train, # и imputed, если нет (т.е. он есть в test) lr_st_viz['Age_type'] = np.where(lr_stochastic.index.isin(train.index), 'actual', 'imputed') # вновь "обрежем" нулевые значения lr_st_viz.Age.clip(lower = 0.5, inplace = True) # создадим график, где по оси x будет индекс датафрейма, # по оси y - возраст, а цветом мы обозначим изначальное это значение, или заполненное sns.scatterplot(data = lr_st_viz, x = lr_st_viz.index, y = 'Age', hue = 'Age_type') plt.title('Распределение изначальных и заполненных значений (лин. регрессия, стох. подход)'); |

Как мы видим разброс заполненных значений существенно приблизился к разбросу изначальных данных. Сравним СКО.
1 2 |
lr_st_viz[lr_st_viz['Age_type'] == 'actual'].Age.std().round(2), \ lr_st_viz[lr_st_viz['Age_type'] == 'imputed'].Age.std().round(2) |
1 |
(14.53, 14.34) |
Забегая вперед скажу, что хотя у нас были основания для разработки стохастического подхода, детерминированный подход будет существенно более точно предсказывать пропуски, поскольку случайные колебания могут как улучшить, так и ухудшить качество заполненных пропусков.
Также замечу, что модель логистической регрессии для заполнения пропусков в категориальных данных строится аналогичным образом.
MICE / IterativeImputer
Описанный выше алгоритм регрессии используется в алгоритме MICE или IterativeImputer. MICE расшифровывается как Multiple Imputation by Chained Equations, многомерный способ заполнения пропущенных данных с помощью цепных уравнений.
Принцип алгоритма MICE
Рассмотрим MICE на несложном примере⧉. Предположим, что у нас есть данные о заемщиках, а именно их возраст, стаж и уровень заработной платы, а также факт возвращения или невозвращения кредита. В этих данных есть пропущенные значения.

Кроме того, для целей оценки качества результата предположим, что нам известны истинные значения пропусков.

Теперь перейдем непостредственно к алгоритму MICE. Это итерационный алгоритм, другими словами, мы будем раз за разом повторять определенный набор действий до тех пор, пока не придем к нужному результату.
Шаг 1. Заполняем данные с помощью среднего арифметического (целевую переменную мы отбросим). Это наша отправная точка.

Эти значения очевидно далеко не оптимальны и еще раз демонстрируют ограниченность одномерных методов. В 25 лет сложно иметь семилетний стаж, три года стажа вряд ли обеспечат уровень заработной платы в 90 тысяч рублей (исходя из имеющихся данных), а человеку с 11 годами опыта скорее всего будет больше 29 лет.
Попробуем улучшить этот результат.
Шаг 2. Уберем заполненное только что значение возраста. Остальные заполненные значения трогать не будем.

Шаг 3. Заполним пропуск с помощью линейной регрессии так, как мы это делали выше.

В данном случае «Опыт» и «ЗП» (кроме последней строки) будут признаками обучающей выборки (X_train), возраст (кроме последней строки) — целевой переменной обучающей выборки (y_train), «Опыт» и «ЗП» последней строки — признаками тестовой выборки (X_test), а возраст последней строки — прогнозируемым значением (y_pred).
Шаги 4 и 5. Сделаем то же самое для двух других пропущенных значений.


Итак, мы завершили первую итерацию (цикл) работы алгоритма.
Шаг 6. Теперь давайте вычтем заполненные после первого цикла значения из исходного датасета, в котором пропуски представляют собой среднее арифметическое по столбцам.

Получившиеся разницы — $-5,99, 6,02, 20$ — это критерий качества работы алгоритма.
Наша задача повторять шаги с 3 по 5 (каждый раз используя новые заполненные значения в качестве отправной точки) до тех пор, пока разницы между двумя последними пропущенными значениями не будут близки к нулю.
Например, уже на второй итерации мы получим следующий результат.

Перейдем к практике.
Реализация на Питоне через класс IterativeImputer
1 2 |
# сделаем копию датасета для работы с методом MICE mice = titanic.copy() |
Изначально алгоритм MICE был создан на языке R⧉, но сегодня доступен в качестве экспериментального класса IterativeImputer⧉ в библиотеке sklearn.
1 2 3 4 |
# предварительно нам нужно "включить" класс IterativeImputer, from sklearn.experimental import enable_iterative_imputer # затем импортировать его from sklearn.impute import IterativeImputer |
Теперь импортируем классы моделей, которые мы можем использовать внутри алгоритма MICE.
1 2 3 4 |
# в сегодняшнем примере ограничимся использованием линейной регрессии from sklearn.linear_model import LinearRegression from sklearn.linear_model import BayesianRidge from sklearn.ensemble import RandomForestRegressor |
Так как в конечном счете мы снова имеем дело с линейной регрессией, будет разумно стандартизировать данные.
1 2 3 4 5 |
# создадим объект класса StandardScaler scaler = StandardScaler() # стандартизируем данные и сразу поместим их в датафрейм mice = pd.DataFrame(scaler.fit_transform(mice), columns = mice.columns) |
Создадим объект класса IterativeImputer и заполним пропуски.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# создадим объект класса IterativeImputer и укажем необходимые параметры mice_imputer = IterativeImputer(initial_strategy = 'mean', # вначале заполним пропуски средним значением estimator = LinearRegression(), # в качестве модели используем линейную регрессию random_state = 42 # добавим точку отсчета ) # используем метод .fit_transform() для заполнения пропусков в датасете mice mice = mice_imputer.fit_transform(mice) # вернем данные к исходному масштабу и округлим столбец Age mice = pd.DataFrame(scaler.inverse_transform(mice), columns = titanic.columns) mice.Age = mice.Age.round(1) mice.head(7) |

1 2 |
# убедимся, что пропусков не осталось mice.Age.isna().sum(), mice.shape |
1 |
(0, (891, 7)) |
Оценим качество получившегося распределения.
1 2 3 |
# посмотрим на гистограмму возраста после заполнения пропусков sns.histplot(mice['Age'], bins = 20) plt.title('Распределение Age после заполнения с помощью MICE'); |

Так как мы заполняли пропуски линейной регрессией, у нас снова появились отрицательные значения.
1 2 |
# обрежем нулевые и отрицательные значения mice.Age.clip(lower = 0.5, inplace = True) |
Количественно оценим получившееся распределение.
1 2 |
# оценим среднее арифметическое и медиану mice.Age.mean().round(1), mice.Age.median() |
1 |
(29.3, 28.3) |
1 2 |
# сравним СКО исходного датасета и данных после алгоритма MICE titanic.Age.std().round(2), mice.Age.std().round(2) |
1 |
(14.53, 13.54) |
Рассмотрим принципиально другой алгоритм для заполнения пропусков, а именно метод ближайших соседей.
Метод k-ближайших соседей
Мы уже использовали алгоритм k-ближайших соседей (k-nearest neighbors algorithm, k-NN) для создания рекомендательной системы. Еще раз рассмотрим работу этого алгоритма на примере задачи классификации.

На рисунке выше мы рассчитали расстояние от зеленой точки (наблюдение или вектор, класс которого мы хотим предсказать) до ближайших соседей.
- Если мы возьмем k равное трем, то в число соседей войдут точки внутри меньшей окружности (сплошная линия).
- Если пяти — внутри большей окружности (прерывистая линия)
При k = 3 большая часть соседей — красные треугольники. Именно к этой категории мы и относем зеленую точку. Если взять k = 5, зеленая точка будет классифицирована как синий квадрат.
Для количественной целевой переменной мы можем найти, например, среднее арифметическое k-ближайших соседей.
В задаче заполнения пропусков мы сначала найдем соседей наблюдения с пропущенным значением (например, других пассажиров «Титаника»), а затем заполним пропуск (в частности, возраст) средним арифметическим значений этого столбца наблюдений соседей (т.е. средним возрастом других пассажиров).
Также не будем забывать, что так как речь идет об алгоритме, учитывающем расстояние, нам обязательно нужно масштабировать данные.
Перейдем к практике.
Sklearn KNNImputer
Вначале рассмотрим реализацию этого алгоритма в библиотеке Sklearn. Скопируем и масштабируем данные.
1 2 3 4 5 6 7 8 |
# сделаем копию датафрейма knn = titanic.copy() # создадим объект класса StandardScaler scaler = StandardScaler() # масштабируем данные и сразу преобразуем их обратно в датафрейм knn = pd.DataFrame(scaler.fit_transform(knn), columns = knn.columns) |
Теперь воспользуемся классом KNNImputer для заполнения пропусков.
1 2 3 4 5 6 7 8 9 10 11 12 |
# импортируем класс KNNImputer from sklearn.impute import KNNImputer # создадим объект этого класса с параметрами: # пять соседей и однаковым весом каждого из них knn_imputer = KNNImputer(n_neighbors = 5, weights = 'uniform') # заполним пропуски в столбце Age knn = pd.DataFrame(knn_imputer.fit_transform(knn), columns = knn.columns) # проверим отсутствие пропусков и размеры получившегося датафрейма knn.Age.isna().sum(), knn.shape |
1 |
(0, (891, 7)) |
Вернем исходный масштаб данных.
1 2 3 4 5 6 7 |
knn = pd.DataFrame(scaler.inverse_transform(knn), columns = knn.columns) # округлим значение возраста knn.Age = knn.Age.round(1) # посмотрим на результат knn.head(7) |

Осталось взглянуть на получившееся распределение.
1 2 3 |
# посмотрим на распределение возраста после заполнения пропусков sns.histplot(knn['Age'], bins = 20) plt.title('Распределение Age после заполнения с помощью KNNImputer'); |

Распределение близко к нормальному.
impyute fast_knn
Особенности метода ближайших соседей
Метод ближайших соседей прост и в то же время эффективен.
При этом в базовом варианте его реализации у него есть один недостаток — долгое время работы или, как правильнее сказать, высокая временная сложность (time complexity) алгоритма.
Давайте рассмотрим механику этого метода чуть подробнее. В первую очередь, для лучшего понимания, введем несколько неформальных терминов:
- назовем вектором запроса (query vector) то новое наблюдение, для которого мы хотим найти ближайшие к нему вектора (зеленая точка на изображении выше)
- вектором сравнения (reference vector) будет то наблюдение, которое уже содержит разметку (класс или числовое значение) или в котором отсутствует пропуск (все остальные точки)
Алгоритм k-ближайших соседей потребует двух циклов.
- В первом цикле мы будем поочередно брать по одному вектору запроса
- Во втором вложенном в него цикле мы будем для каждого вектора запроса находить расстояние до всех векторов сравнения.
- Наконец, найдя и отсортировав вектора сравнения по расстоянию, выберем для каждого вектора запроса k-ближайших.
- Далее приступим к решению задачи классификации, регрессии (хотя термин регрессия здесь не вполне корректно применять) или заполнению пропуска.
При большом количестве наблюдений (как векторов запроса, так и векторов сравнения) такая работа может занять очень много времени. Такой вариант реализации алгоритма, его еще называют методом перебора или методом опробования (brute force method), является возможным, но не единственным решением поставленной задачи.
Другим возможным решением является преобразование векторов сравнения в такую структуру данных, по которой поиск соседей будет происходить быстрее (то есть мы оптимизируем второй, вложенный цикл, с первым мы сделать ничего не можем, нам в любом случае нужно перебрать все вектора запроса).
Такой структурой данных может быть, в частности, k-мерное дерево или k-d-дерево (k-diminsional tree, k-d tree).

Во многом сказанное выше перекликается с различиями между алгоритмами линейного и бинарного поиска, которые мы рассмотрели ранее.
В дополнительных материалах к сегодняшнему занятию мы отдельно поговорим про временную сложность алгоритмов. Реализацию же различных вариантов метода k-ближайших соседей и создания k-мерных деревьев мы отложим до курса ML для продолжающих.
А сейчас давайте обратимся к библиотеке impyute и классу fast_knn, который как раз реализует быстрый поиск ближайших соседей по k-мерному дереву (отсюда и название этого класса).
Функция fast_knn()
Вначале подготовим данные.
1 2 3 4 5 6 7 |
# сделаем копию датафрейма и масштабируем данные impyute = titanic.copy() scaler = StandardScaler() impyute = scaler.fit_transform(impyute) # на выходе в переменной impyute содержится массив Numpy type(impyute) |
1 |
numpy.ndarray |
Установим библиотеку impyute.
1 |
!pip install impyute |
Теперь воспользуемся фунцией fast_knn() для заполнения пропусков.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# импортируем функцию fast_knn from impyute.imputation.cs import fast_knn # передаем этой функции массив Numpy и количество соседей # (если данные содержатся в формате датафрейма, нужно использовать атрибут .values) impyute = fast_knn(impyute, k = 5) # возвращаем исходный масштаб и преобразуем в датафрейм impyute = pd.DataFrame(scaler.inverse_transform(impyute), columns = titanic.columns) # округляем столбец возраста impyute.Age = impyute.Age.round(1) # проверим отсутствие пропусков и размеры получившегося датафрейма impyute.Age.isna().sum(), impyute.shape |
1 |
(0, (891, 7)) |
1 2 |
# оценим результат impyute.head(7) |

Остается взглянуть на распределение.
1 2 |
sns.histplot(impyute['Age'], bins = 20) plt.title('Распределение Age после заполнения с помощью fast_knn()'); |

DataWig
DataWig (буквально — «парик для данных») — библиотека, которая использует нейронную сеть для заполнения пропусков. Она была разработана специалистами подразделения Amazon Science⧉.
Пример использования этой библиотеки я поместил в отдельный ноутбук.
Откроем ноутбук с библиотекой Datawig⧉
Установка и импорт библиотеки
Зачем нужен отдельный ноутбук? Дело в том, что эта библиотека требует установки более ранних версий основных библиотек, что нарушает работу остального кода. Например, на момент написания статьи в Google Colab стояла версия Pandas 1.3.5.
1 2 3 4 5 |
# импортируем библиотеку Pandas import pandas as pd # проверим версию библиотеки Pandas по умолчанию pd.__version__ |
1 |
'1.3.5' |
Установим библиотеку Datawig.
1 2 3 |
# напомню, что в Google Colab для обращения к Python Package Index # мы импользуем команду !pip !pip install datawig |
После установки Google Colab попросит перезапустить среду выполнения.

Datawig установил более раннюю версию Pandas 0.25.3 (и так со многими другими библиотеками).
1 2 3 4 5 6 7 8 9 10 |
# после перезапуска среды заново установим Pandas import pandas as pd # а также установим другие необходимые нам библиотеки (в том числе и Datawig) import numpy as np import matplotlib.pyplot as plt import datawig # продемонстрируем, что Datawig установил более раннюю версию Pandas pd.__version__ |
1 |
'0.25.3' |
Если вы захотите вернуть ноутбук Google Colab к исходным настройкам, нажмите Среда выполнения → Отключиться от среды выполнения и удалить ее.
Импорт и подготовка данных
Подгрузим и импортируем датасет «Титаник», выберем нужные признаки, закодируем Sex и масштабируем данные.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# импортируем файл из сессионного хранилища (предварительно его нужно туда подгрузить) titanic = pd.read_csv('/content/train.csv') # выберем нужные нам столбцы titanic = titanic[['Survived', 'Pclass', 'Sex', 'SibSp', 'Parch', 'Fare', 'Age']] # создадим "карту" для кодирования столбца Sex map_dict = {'male' : 0, 'female' : 1} # применим ее с помощью функции map() titanic['Sex'] = titanic['Sex'].map(map_dict) # импортируем класс StandardScaler и стандартизируем данные from sklearn.preprocessing import StandardScaler scaler = StandardScaler() titanic = pd.DataFrame(scaler.fit_transform(titanic), columns = titanic.columns) # посмотрим на результат titanic.head() |

Теперь сделаем следующее. В качестве обучающей выборки мы возьмем те строки, в которых в столбце Age нет пропусков.
1 2 3 4 |
# воспользуемся .dropna() для удаления строк с пропусками в столбце Age # (напомню, что в других столбцах пропусков нет) train = titanic.dropna().copy() train.head(3) |

Делать прогноз мы будем на данных, в которых в столбце Age есть пропуски.
1 2 3 4 5 6 7 |
# применим метод .isnull() (т.е. "есть пропуски") к столбцу Age и # используем результат в качестве фильтра для датафрейма test = titanic[titanic['Age'].isnull()].copy() # удалим столбец Age, ведь в нем остались только пропуски test.drop('Age', axis = 1, inplace = True) test.head(3) |

Обучение модели и прогноз
Создадим объект модели и обучим ее через метод .fit().
1 2 3 4 5 6 7 8 9 10 11 |
# создадим объект класса SimpleImputer imputer = datawig.SimpleImputer( # в параметр input_columns передадим признаки (столбцы, на которых будем обучать модель) input_columns = ['Survived', 'Pclass', 'Sex', 'SibSp', 'Parch', 'Fare'], # в output_column передадим целевую переменную output_column = 'Age', # output_path - это путь, куда будет установлена модель output_path = 'imputer_model') # обучим модель заполнения пропусков (по умолчанию, 100 эпох) imputer.fit(train_df = train) |
1 |
<datawig.simple_imputer.SimpleImputer at 0x7f380345b810> |
Обратите внимание, с сессионном хранилище в Google Colab появилась папка imputer_model из прописанного выше параметра output_path. В этой папке хранятся файлы обученной модели.

Мы подробнее рассмотрим файлы json и pickle в дополнительных материалах.
Теперь воспользуемся методом .predict() для заполнения пропусков (по сути, создания столбца Age в тестовом датасете).
1 2 3 4 |
test = imputer.predict(test) # посмотрим на результат test.head(3) |

В столбец Age_imputed модель поместила заполненные значения.
Очевидно, что напрямую оценить качество модели в данном случае мы не можем, у нас изначально нет целевой переменной в тестовой выборке. Мы проведем косвенное сравнение методов в следующем разделе.
Восстановление датафрейма
Теперь восстановим датафрейм, соединив тестовую и обучающую метрики, а также внесем некоторые другие изменения.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
# переименуем столбец Age_imputed в Age test.rename(columns = {'Age_imputed': 'Age'}, inplace = True) # соединим датафреймы "один на другой" с помощью функции pd.concat() titanic = pd.concat([train, test]) # отсортируем строки по индексу (по сути, вернем им изначальное положение) titanic.sort_index(inplace = True) # восстановим исходный масштаб titanic = pd.DataFrame(scaler.inverse_transform(titanic), columns = titanic.columns) # округлим значение возраста titanic.Age = titanic.Age.round(1) # выведем первые семь значений, чтобы убедиться, что пропусков больше нет titanic.head(7) |

Еще раз убедимся в отсутствии пропусков и проверим размеры датафрейма.
1 |
titanic.Age.isna().sum(), titanic.shape |
1 |
(0, (891, 7)) |
Посмотрим на распределение возраста после заполнения с помощью нейросети.
1 2 3 4 |
plt.figure(figsize = (8,5)) plt.hist(titanic['Age'], bins = 20) plt.title('Распределение Age после заполнения с помощью Datawig') plt.grid(); |

Сохранение результата
Остается сохранить результат в сессионное хранилище в формате .csv.
1 2 |
# индекс сохранять не будем titanic.to_csv('datawig.csv', index = False) |
Скачать на жесткий диск можно либо через интерфейс сессионного хранилища, либо исполнив код ниже.
1 2 |
from google.colab import files files.download('/content/datawig.csv') |
Теперь вернемся в основной ноутбук, подгрузим файл datawig.csv и импортием его.
1 |
datawig = pd.read_csv('/content/datawig.csv') |
Примечание. При повторном обучении модели и заполнении пропусков вы получите результат, который будет немного отличаться от представленного выше.
Если по какой-либо причине у вас не получилось исполнить код с библиотекой Datawig, ниже вы можете скачать файл datawig.csv с уже заполненными пропусками и подгрузить его в сессионное хранилище.
Сравнение методов
Пришло время оценить качество алгоритмов заполнения пропусков. Для сравнения рассмотренных выше методов сделаем следующее:
- Возьмем получившиеся в результате применения каждого из методов одинаковые датасеты (различаются только значения, которыми были заполнены пропуски)
- Используем модель логистической регрессии для построения прогноза выживания пассажиров. Тот метод заполнения пропусков, с которым модель логистической регрессии покажется наилучший результат и будет считаться победителем
Вначале создадим два списка:
- в первый поместим все датасеты с заполненным столбцом Age
- во второй, соответствующие названия методов
1 2 |
datasets = [const_imputer, median_imputer, lr, lr_stochastic, mice, knn, impyute, datawig] methods = ['constant', 'median', 'linear regression', 'stochastic linear regression', 'MICE','KNNImputer', 'fast_knn', 'Datawig'] |
Возьмем целевую переменную из исходного файла, так как мы не использовали ее при заполнении пропусков.
1 |
y = pd.read_csv('/content/train.csv')['Survived'] |
Импортируем класс LogisticRegression и функцию accuracy_score() для оценки качества модели.
1 2 |
from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score |
Теперь в цикле обучим модель на каждом из датасетов, сделаем прогноз, оценим и выведем результат.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# в цикле пройдемся по датасетам с заполненными пропусками # и списком названий соответствующих методов for X, method in zip(datasets, methods): # масштабируем признаки X = StandardScaler().fit_transform(X) # для каждого датасета построим и обучим модель логистической регрессии model = LogisticRegression() model.fit(X, y) # сделаем прогноз y_pred = model.predict(X) # выведем название использованного метода и достигнутую точность print(f'Method: {method}, accuracy: {np.round(accuracy_score(y, y_pred), 3)}') |
1 2 3 4 5 6 7 8 9 |
Method: constant, accuracy: 0.79 Method: median, accuracy: 0.795 Method: binned median, accuracy: 0.808 Method: linear regression, accuracy: 0.808 Method: stochastic linear regression, accuracy: 0.796 Method: MICE, accuracy: 0.808 Method: KNNImputer, accuracy: 0.802 Method: fast_knn, accuracy: 0.798 Method: Datawig, accuracy: 0.808 |
Мы видим, что, используя более сложные методы заполнения пропусков, мы в среднем добились более высокой точности финальной модели.
Отдельно обратите внимание на хорошие результаты заполнения пропусков внутригрупповой медианой (binned median) и наоборот невысокую точность алгоритма стохастической линейной регрессии. Во втором случае, снижение точности объясняется как раз тем, что мы чаще не угадывали куда должна двигаться вариативность, нежели оказывались правы.
Уточнение. Хотя в классификации выше метод заполнения внутригрупповым показателем отнесен к одномерным методам, де факто это метод многомерный, поскольку среднее или медиана столбца с пропусками рассчитываются на основе группировки по другим столбцам датасета.
Дополнение. Еще одним способом работы с пропусками является создание переменной-индикатора (indicator method), которая принимает значение 1, если пропуск присутствует, и 0, если отсутствует.
На отдельной странице приведены дополнительные материалы к этому занятию.
Подведем итог
Сегодня мы рассмотрели теоретические основы, а также практические способы заполнения пропусков в перекрестных данных.
В частности мы узнали, что пропуски можно заполнить, используя одномерные или многомерные методы. Сравнение этих методов показало, что многомерные методы в среднем показывают более высокие результаты.
Вопросы для закрепления
Как связаны три группы данных (пропущенные, наблюдаемые или отсутствующие значения) с тремя видами пропусков (MCAR, MAR, MNAR)?
Посмотреть правильный ответ
Ответ: в зависимости от того, влияют ли наблюдаемые или отсутствующие значения на пропуски, последние можно разделить на три вида:
- если ни наблюдаемые, ни отсутствующие значения не влияют на пропуски, то это MCAR
- если влияют наблюдаемые, то MAR
- если отсутствующие — MNAR
Вопрос. Чем многомерные методы заполнения пропусков отличаются от одномерных?
Посмотреть правильный ответ
Ответ: одномерные методы заполняют пропуски значениями, рассчитанными на основе данных того же столбца (признака), многомерные методы — основываются на данных других столбцов.
Рассмотрим способы заполнения пропусков во временных рядах.
Ответы на вопросы
Вопрос. Что такое hot-deck imputation?
Ответ. Hot-deck imputation или метод горячей колоды предполагает, что мы используем данные того же датасета для заполнения пропусков. Внутри hot-deck imputation есть несколько различных способов заполнения пропусков:
- в простейшем варианте мы случайным образом берем непропущенное наблюдение и используем его для заполнения пропуска
- кроме этого, мы можем отсортировать данные по имеющимся признакам и заполнить пропуски предыдущим значением (last observation carried forward, LCOF). Считается, что таким образом мы заполняем пропуски наиболее близкими значениями.
- подход LCOF можно модифицировать и предварительно разбить данные на подгруппы.
Помимо этого существует и cold-deck imputation или метод холодной колоды, когда данные для заполнения пропусков берутся из другого датасета.
Терминологически «колодой» (deck of cards) называли стопку⧉ перфокарт (punched cards), на которые записывались данные или программа. При этом горячей называлась используемая сейчас «колода», холодной — та, которая была отложена.
Вопрос. Что делать, если пропуски заполнены каким-либо символом, а не NaN? Например, знаком вопроса.
Ответ. Вначале нужно превратить этот или эти символы в NaN, а дальше работать как со стандартными пропусками.
1 2 3 4 5 |
df = pd.DataFrame([[1, 2, 3], ['?', 5, 6], [7, '?', 9]]) df |

1 2 |
df[df == '?'] = np.nan df |

Вопрос. Чем метод .isnull() отличается от метода .isna()?
Ответ. Это одно и то же.
Вопрос. Некоторые авторы указывают, что пропуски типа MNAR зависят только от отсутствующих значений. Другими словами,
Р (П | Н, О) = $f$(O)
Ответ. Да, действительно, встречаются разные определения. Здесь важно, что пропуски типа MNAR в любом случае в какой-то степени зависят именно от отсутствующих значений, о которых мы по определению ничего не знаем, а значит не знаем как предсказывать такие пропуски.
Вопрос. Какие библиотеки для заполнения пропусков существуют на R?
Ответ. Посмотрите imputeTS⧉.