2026, Jan 03 15:03
Как удалить строки с внутренними NaN в pandas без циклов
Показываем, как находить и удалять строки с внутренними NaN в pandas: векторизованные маски isna+cummin и вариант с interpolate(limit_area='inside'). Быстро.
Очистка временных рядов с пропусками в начале и в конце — привычная задача, а вот редкие внутренние значения np.nan обрабатывать сложнее. Когда nan появляется между корректными числами, он нарушает сплошной блок данных и часто требует удаления. Цель — просканировать каждый столбец, обнаружить такие внутренние разрывы и удалять всю строку, когда nan находится внутри непрерывного участка данных, а не в заполнении по краям.
Постановка задачи
Рассмотрим столбец, который начинается и заканчивается значениями np.nan, содержит непрерывный блок чисел, но внутри встречается неожиданный разрыв. Нам нужно найти np.nan между 9 и 13 и удалить соответствующую строку тогда и только тогда, когда выполнены три условия: есть как минимум одно корректное значение до, как минимум одно корректное значение после, и текущая ячейка — nan.
[np.nan, np.nan, np.nan, 1, 4, 6, 6, 9, np.nan, 13, np.nan, np.nan]
Базовый подход (работает, но медленно)
Следующий скрипт выполняет требования, проверяя каждую ячейку столбца по трём условиям. Строка сохраняется, если ячейка не является внутренним nan; однако решение опирается на циклы и срезы в Python, что делает его медленным на больших данных.
import pandas as pd
import numpy as np
payload = {
'A': [np.nan, np.nan, np.nan, 1, 4, 6, 6, 9, np.nan, 13, np.nan, np.nan],
'B': [np.nan, np.nan, np.nan, 11, 3, 16, 13, np.nan, np.nan, 12, np.nan, np.nan]
}
frame = pd.DataFrame(payload)
def mark_valid_rows(series_obj):
kept = []
for pos, val in series_obj.items():
has_before = not series_obj[:pos].isnull().all()
has_after = not series_obj[pos + 1:].isnull().all()
is_empty = np.isnan(val)
kept.append(not (has_before and has_after and is_empty))
return kept
for col_name in frame.columns:
frame = frame[mark_valid_rows(frame[col_name])]
print(frame)
Что на самом деле происходит
Задуманная логика корректна: внутренний nan — это nan, который не относится к ведущему или замыкающему блоку nan. Проблема с производительностью возникает из‑за итераций на стороне Python по каждому элементу и повторяющихся срезов Series для проверки «есть ли что‑то до/после», что затратно.
Ту же идею можно выразить векторизованными масками. Сначала находим все nan. Затем помечаем те nan, которые относятся к внешней «подкладке» — то есть всё от верха до первого не‑nan и от низа до первого не‑nan. Любой nan вне этих областей — внутренний разрыв. В финале оставляем строки, где для каждого столбца ячейка либо не nan, либо относится к внешним nan.
Векторизованное решение с isna и cummin
Начнём с одного столбца, чтобы явно увидеть маски. Отмечаем nan, затем используем накопительный минимум по прямому и обратному направлениям, чтобы пометить «внешние» nan. Сохраняем значения, которые либо не nan, либо внешние nan.
import pandas as pd
import numpy as np
payload = {
'A': [np.nan, np.nan, np.nan, 1, 4, 6, 6, 9, np.nan, 13, np.nan, np.nan],
'B': [np.nan, np.nan, np.nan, 11, 3, 16, 13, np.nan, np.nan, 12, np.nan, np.nan]
}
table = pd.DataFrame(payload)
nan_mask = table['A'].isna()
edge_mask = (nan_mask.cummin() | nan_mask[::-1].cummin())
result_col = table.loc[edge_mask | ~nan_mask, 'A']
print(result_col)
Вывод:
0 NaN
1 NaN
2 NaN
3 1.0
4 4.0
5 6.0
6 6.0
7 9.0
9 13.0
10 NaN
11 NaN
Name: A, dtype: float64
Применим ту же идею ко всему DataFrame. Единственный дополнительный шаг — агрегировать маски по столбцам через all(axis=1), чтобы оставить только строки, прошедшие правило во всех столбцах.
import pandas as pd
import numpy as np
payload = {
'A': [np.nan, np.nan, np.nan, 1, 4, 6, 6, 9, np.nan, 13, np.nan, np.nan],
'B': [np.nan, np.nan, np.nan, 11, 3, 16, 13, np.nan, np.nan, 12, np.nan, np.nan]
}
matrix = pd.DataFrame(payload)
nan_mask_df = matrix.isna()
edge_mask_df = (nan_mask_df.cummin() | nan_mask_df[::-1].cummin())
filtered = matrix.loc[(edge_mask_df | ~nan_mask_df).all(axis=1)]
print(filtered)
Вывод:
A B
0 NaN NaN
1 NaN NaN
2 NaN NaN
3 1.0 11.0
4 4.0 3.0
5 6.0 16.0
6 6.0 13.0
9 13.0 12.0
10 NaN NaN
11 NaN NaN
Альтернатива: interpolate с limit_area='inside'
Есть и компактный путь: использовать interpolate для выявления внутренних разрывов. После интерполяции с limit_area='inside' nan остаются только снаружи. Это позволяет построить ту же маску для отбора.
import pandas as pd
import numpy as np
payload = {
'A': [np.nan, np.nan, np.nan, 1, 4, 6, 6, 9, np.nan, 13, np.nan, np.nan],
'B': [np.nan, np.nan, np.nan, 11, 3, 16, 13, np.nan, np.nan, 12, np.nan, np.nan]
}
block = pd.DataFrame(payload)
present_mask = block.notna()
outer_nan_mask = block.interpolate(limit_area='inside').isna()
filtered_alt = block[(present_mask | outer_nan_mask).all(axis=1)]
print(filtered_alt)
Почему это важно
Построчные проверки качества данных часто становятся узким местом в конвейерах. Детектирование «внутренних разрывов» через векторизованные маски не только точно соответствует задумке, но и естественно масштабируется на широкие таблицы и длинные ряды. Логика остаётся прозрачной: находим nan, отделяем краевую «подкладку» от внутренних дыр и сохраняем строки только когда каждый столбец безопасен.
Итоги
Если правило зависит от относительного положения в столбце, сначала попробуйте алгебру масок, а не циклы Python. Связка isna и направленного cummin чётко отделяет краевую «подкладку» от внутренних дыр. Если в вашем процессе допустима интерполяция, параметр limit_area='inside' даёт ещё один компактный способ выявить внутренние разрывы, оставляя внешние без изменений. Оба подхода хорошо сочетаются с булевым индексированием и обеспечивают ровно то поведение, которое требуется: удалять строки, где nan прерывают реальные данные, а остальные не трогать.