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 прерывают реальные данные, а остальные не трогать.