Эффективно удалять строки из pandas df на основе второго последнего времени в столбце

У меня есть Dataframe pandas, который выглядит примерно так:

Индекс ИДЕНТИФИКАТОР время_1 время_2 0 101 2024-06-20 14:32:22 2024-06-20 14:10:31 1 101 2024-06-20 15:21:31 2024-06-20 14:32:22 2 101 2024-06-20 15:21:31 2024-06-20 15:21:31 3 102 2024-06-20 16:26:51 2024-06-20 15:21:31 4 102 2024-06-20 16:26:51 2024-06-20 16:56:24 5 103 2024-06-20 20:05:44 2024-06-20 21:17:35 6 103 2024-06-20 22:41:22 2024-06-20 22:21:31 7 103 2024-06-20 23:11:56 2024-06-20 23:01:31

Для каждого идентификатора в моем df я хочу взять второе последнее время_1 (если оно существует). Затем я хочу сравнить это время с временными метками в time_2 и удалить все строки из моего df, где time_2 раньше, чем это время. Мой ожидаемый результат будет:

Индекс ИДЕНТИФИКАТОР время_1 время_2 1 101 2024-06-20 15:21:31 2024-06-20 14:32:22 2 101 2024-06-20 15:21:31 2024-06-20 15:21:31 3 102 2024-06-20 16:26:51 2024-06-20 15:21:31 4 102 2024-06-20 16:26:51 2024-06-20 16:56:24 7 103 2024-06-20 23:11:56 2024-06-20 23:01:31

Эта проблема выше моего уровня панд. Я спросил ChatGPT и получил следующее решение, которое в принципе делает то, что я хочу:

import pandas as pd

ids = [101, 101, 101, 102, 102, 103, 103, 103]
time_1 = ['2024-06-20 14:32:22', '2024-06-20 15:21:31', '2024-06-20 15:21:31', '2024-06-20 16:26:51', '2024-06-20 16:26:51', '2024-06-20 20:05:44', '2024-06-20 22:41:22', '2024-06-20 23:11:56']
time_2 = ['2024-06-20 14:10:31', '2024-06-20 14:32:22', '2024-06-20 15:21:31', '2024-06-20 15:21:31', '2024-06-20 16:56:24', '2024-06-20 21:17:35', '2024-06-20 22:21:31', '2024-06-20 23:01:31']


df = pd.DataFrame({
    'id': ids,
    'time_1': pd.to_datetime(time_1),
    'time_2': pd.to_datetime(time_2)
})

grouped = df.groupby('id')['time_1']
mask = pd.Series(False, index=df.index)

for id_value, group in df.groupby('id'):
    # Remove duplicates and sort timestamps
    unique_sorted_times = group['time_1'].drop_duplicates().sort_values()

    # Check if there's more than one unique time
    if len(unique_sorted_times) > 1:
        # Select the second last time
        second_last_time = unique_sorted_times.iloc[-2]
        # Update the mask for rows with time_2 greater than or equal to the second last time_1
        mask |= (df['id'] == id_value) & (df['time_2'] >= second_last_time)
    else:
        # If there's only one unique time, keep the row(s)
        mask |= (df['id'] == id_value)

filtered_data = df[mask]

Моя проблема с этим решением - цикл for. Это кажется довольно неэффективным, и мои реальные данные довольно велики. А еще мне любопытно, есть ли лучшее и более эффективное решение для этой проблемы.

Пожалуйста, не используйте id в качестве имени переменной, поскольку она уже назначена.

rpanai 08.07.2024 16:21

Никогда бы не сделал этого в реальном мире, но я подумал, что, например, это не имеет значения. Но, по сути, вы правы!

Frede 08.07.2024 16:27

Я добавил к вашим данным пример с одним индексом

rpanai 08.07.2024 16:39

@rpanai Второе последнее время для id=103 — 22:41:22. И я хочу удалить все строки, в которых time_2 для этого идентификатора находится раньше, чем эта временная метка, то есть строки 5 и 6. И ваше предложение по фильтру приемлемо.

Frede 08.07.2024 17:04

@rpanai Пожалуйста, не меняйте данные примера; это может изменить проблему.

wjandrea 08.07.2024 17:13

Это было согласно описанию. У нас не было примера с одним элементом внутри группы. Автор с этим согласился.

rpanai 08.07.2024 17:17

@rpanai Я не понимаю, где Фреде согласился с этим, но если они обязательно захотят добавить его обратно. А пока, если вы хотите включить это в ответ, это нормально, как способ продемонстрировать свои предположения о проблеме.

wjandrea 08.07.2024 17:19

@wjandrea, почему ты переключился обратно? Новые данные отлично подошли для примера.

rpanai 08.07.2024 17:19

@rpanai Потому что, как я уже сказал, это может изменить проблему

wjandrea 08.07.2024 17:20

Это не изменило проблему и соответствовало описанию автора.

rpanai 08.07.2024 17:21

@rpanai Как я уже сказал, если они захотят добавить его обратно, это нормально, но, пожалуйста, не кладите им слова в рот.

wjandrea 08.07.2024 17:24

Стоит отметить, что grouped не используется в коде ChatGPT.

wjandrea 08.07.2024 17:41

@Фреде, просто для ясности: вы сохраняете строки 3 и 4, поскольку предпоследней даты нет?

mozway 08.07.2024 19:20

@mozway да, это правильно. Я знаю, это выглядит странно. Причина в том, что я использую это для очистки данных, и в случаях, когда есть только один раз_1, с данными все в порядке.

Frede 09.07.2024 10:11

@wjandrea Я проголосовал за ввод дополнительных данных, потому что это соответствует моей проблеме и действительно является хорошим дополнительным случаем для тестирования.

Frede 09.07.2024 10:13

@Frede О, голоса не являются публичными

wjandrea 09.07.2024 18:21
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
3
16
95
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

Вот возможное решение, используя groupby

Я добавил пример с одним элементом в группе.

import pandas as pd

ids = [101, 101, 101, 102, 102, 103, 103, 103, 104]
time_1 = [
    '2024-06-20 14:32:22', '2024-06-20 15:21:31', '2024-06-20 15:21:31',
    '2024-06-20 16:26:51', '2024-06-20 16:26:51', '2024-06-20 20:05:44',
    '2024-06-20 22:41:22', '2024-06-20 23:11:56', '2024-06-20 23:11:56']
time_2 = [
    '2024-06-20 14:10:31', '2024-06-20 14:32:22', '2024-06-20 15:21:31',
    '2024-06-20 15:21:31', '2024-06-20 16:56:24', '2024-06-20 21:17:35',
    '2024-06-20 22:21:31', '2024-06-20 23:01:31', '2024-06-20 23:01:31']


df = pd.DataFrame({
    'id': ids,
    'time_1': pd.to_datetime(time_1),
    'time_2': pd.to_datetime(time_2)
})

Мы определяем функцию, которая учитывает логику внутри группы

def fun(x):
    if len(x) > 1:
        unique_times = x['time_1'].unique()
        if len(unique_times) >= 2:
            second_last_time = unique_times[-2]
        else:
            second_last_time = unique_times[0]
        x = x[x['time_2'].ge(second_last_time)]
    return x
df.groupby('id').apply(lambda x: fun(x)).reset_index(drop=True)
    id              time_1              time_2
0  101 2024-06-20 15:21:31 2024-06-20 14:32:22
1  101 2024-06-20 15:21:31 2024-06-20 15:21:31
2  102 2024-06-20 16:26:51 2024-06-20 16:56:24
3  103 2024-06-20 23:11:56 2024-06-20 23:01:31
4  104 2024-06-20 23:11:56 2024-06-20 23:01:31

При таком подходе вы увидите выгоду, если ваш df станет больше. С фреймом данных из 90 000 строк я увидел улучшение на 25%.

lambda x: fun(x) можно упростить до fun. (Кстати, это называется эта-редукция)
wjandrea 08.07.2024 18:07

Ожидаемый результат OP сохраняет индекс. ИДК, если это важно или просто для демонстрации, но в любом случае вы тоже можете сохранить его, удалив reset_index и выполнив df.groupby('id', group_keys=False).apply(fun)

wjandrea 08.07.2024 18:08

id=102 должен иметь две строки. Похоже if not len(unique_times) >= 2, вам следует return x вместо second_last_time = unique_times[0].

wjandrea 08.07.2024 18:15

В связи с этим вы могли бы использовать операторы защиты, чтобы сделать этот код намного чище: сначала if len(x) < 2: return x, затем if len(unique_times) < 2: return x, затем остальное находится на верхнем уровне функции, и, по моему мнению, вам не нужно переназначать x, просто сделайте return x[x['time_2'].ge(second_last_time)]

wjandrea 08.07.2024 18:15

Кстати, вы могли бы использовать имя получше, чем x, например, group

wjandrea 08.07.2024 18:17

@rpanai Мне нравится, что вы используете apply, потому что, на мой взгляд, это делает код более читабельным. Но, как отметил wjandrea, ваш код удаляет строку 3, а это не то, что мне нужно. В случаях, когда для идентификатора имеется только один time_1, все строки для этого идентификатора должны быть сохранены.

Frede 09.07.2024 10:37

IIUC, вы можете использовать groupby.transform с drop_duulates , чтобы удалить повторяющиеся значения, и .iloc[-2:-1].squeeze(), чтобы получить предпоследние значения, если таковые имеются, иначе NaT. Затем выполните логическое индексирование:

out = df.loc[df.sort_values(by='time_1') # optional, if not already sorted
               .groupby('id')['time_1']
               .transform(lambda x: x.drop_duplicates().iloc[-2:-1].squeeze())
               .fillna(pd.Timestamp(0))
               .le(df['time_2'])
            ]

Выход:

    id              time_1              time_2
1  101 2024-06-20 15:21:31 2024-06-20 14:32:22
2  101 2024-06-20 15:21:31 2024-06-20 15:21:31
3  102 2024-06-20 16:26:51 2024-06-20 15:21:31
4  102 2024-06-20 16:26:51 2024-06-20 16:56:24
7  103 2024-06-20 23:11:56 2024-06-20 23:01:31

Промежуточные продукты:

    id              time_1              time_2           transform              fillna
0  101 2024-06-20 14:32:22 2024-06-20 14:10:31 2024-06-20 14:32:22 2024-06-20 14:32:22
1  101 2024-06-20 15:21:31 2024-06-20 14:32:22 2024-06-20 14:32:22 2024-06-20 14:32:22
2  101 2024-06-20 15:21:31 2024-06-20 15:21:31 2024-06-20 14:32:22 2024-06-20 14:32:22
3  102 2024-06-20 16:26:51 2024-06-20 15:21:31                 NaT 1970-01-01 00:00:00
4  102 2024-06-20 16:26:51 2024-06-20 16:56:24                 NaT 1970-01-01 00:00:00
5  103 2024-06-20 20:05:44 2024-06-20 21:17:35 2024-06-20 22:41:22 2024-06-20 22:41:22
6  103 2024-06-20 22:41:22 2024-06-20 22:21:31 2024-06-20 22:41:22 2024-06-20 22:41:22
7  103 2024-06-20 23:11:56 2024-06-20 23:01:31 2024-06-20 22:41:22 2024-06-20 22:41:22

У меня получилось что-то похожее :) Вы должны использовать pd.Timestamp.min вместо pd.Timestamp(0) в случае дат до эпохи Unix.

wjandrea 08.07.2024 21:36

Было бы более эффективно сортировать по группам? У меня такое ощущение, но я не знаю почему. Хотя в своем ответе я использовал .nlargest(), чтобы не сортировать.

wjandrea 08.07.2024 21:41

@wjandrea Да, было бы. Если вы принимаете n log n за временную сложность и думаете о 100 примерах с 10 группами по 10, то изменится часть log n. log 10 меньше log 100, поэтому групповая сортировка будет немного быстрее.

Frede 09.07.2024 10:54

@wjandrea это зависит от точных данных, я думаю, это не обязательно. Вызов sort_values несколько раз также добавляет значительные накладные расходы, а сортировка Python в среднем составляет O(n*logn), но может быть близка к O(n), если данные уже почти отсортированы. Что касается pd.Timestamp(0), это хорошее замечание, на самом деле вы могли бы также fillna(df['time_1']).

mozway 09.07.2024 11:00

@mozway, я понимаю. Ты имеешь в виду fillna(df['time_2']), да? Тогда вы будете сравнивать эти time_2 ценности с самими собой (чтобы они удовлетворяли .le самим себе).

wjandrea 09.07.2024 18:27

Да time_2, действительно, для сравнения.

mozway 09.07.2024 18:47
Ответ принят как подходящий

Вы можете использовать .transform() для создания маски.

Сортировка не обязательна, если вы можете просто использовать .nlargest() и выбрать второй, если он существует. Или, если time_1 уже отсортировано, вы можете вообще пропустить .nlargest() (или сортировку).

Тогда вам просто нужно заменить NaT на наименьшее возможное значение Timestamp, чтобы time_2 не могло быть раньше этого значения при сравнении.

second_last_times = df.groupby('id')['time_1'].transform(
    lambda s: s.drop_duplicates().nlargest(2).iloc[1:].squeeze())
mask = second_last_times.fillna(pd.Timestamp.min).le(df['time_2'])
df[mask]

Результат:

    id              time_1              time_2
1  101 2024-06-20 15:21:31 2024-06-20 14:32:22
2  101 2024-06-20 15:21:31 2024-06-20 15:21:31
3  102 2024-06-20 16:26:51 2024-06-20 15:21:31
4  102 2024-06-20 16:26:51 2024-06-20 16:56:24
7  103 2024-06-20 23:11:56 2024-06-20 23:01:31

Для справки second_last_times:

0   2024-06-20 14:32:22
1   2024-06-20 14:32:22
2   2024-06-20 14:32:22
3                   NaT
4                   NaT
5   2024-06-20 22:41:22
6   2024-06-20 22:41:22
7   2024-06-20 22:41:22
Name: time_1, dtype: datetime64[ns]

Если вы хотите обобщить это, замените .nlargest(2).iloc[1:] на .nlargest(n).iloc[n-1:].


P.S. Это похоже на решение Mozway, но на самом деле я написал код до того, как они его опубликовали, за исключением техники squeeze — спасибо за это.

Другие вопросы по теме