Объединить столбцы, попадающие в диапазон

У меня есть два фрейма данных под названием df и ranges:

data = {
    'group': ['A', 'B', 'A', 'C', 'B'],
    'start': [10, 20, 15, 30, 25],
    'end': [50, 40, 60, 70, 45],
    'val1': [5, 10, 11, 12, 6],
    'val2': [5, 2, 1, 1, 0],
}


df = pd.DataFrame(data)
data = {
    'group': ['A', 'B', 'C'],
    'start': [0, 5, 25],
    'end': [50, 7, 35],
}


ranges = pd.DataFrame(data)

Моя цель — объединить строки в df вместе на основе того, попадают ли они в один и тот же диапазон, определенный в ranges. Я хотел бы объединить их вместе так, чтобы для каждого столбца val1, val2 я получал min, max, mean, sum этого столбца в контексте группы агрегации.

Загвоздка в том, что мне нужно сделать это примерно для 5000 диапазонов в ranges и 500 000 строк в df. Поэтому мне нужно быстрое, но эффективное (относительно) решение с точки зрения памяти. Я открыт для решений с использованием подобных фреймворков, таких как vaex.

Ожидаемый результат, где range_id — это просто способ идентификации групп, предполагая, что они не уникальны:

  range_id val1              val2             
            min max mean sum  min max mean sum
0        0    5   5  5.0   5    5   5  5.0   5

В чем преимущество range_id по сравнению с использованием столбца group для идентификации групп? Может ли одна и та же группа иметь несколько диапазонов?

ouroboros1 13.06.2024 08:21

Да, в моем примере этого не было.

donkey 13.06.2024 09:17

Под «да» вы имеете в виду: «да, одна и та же группа может иметь несколько диапазонов»? Например, входные данные для ranges также могут быть data = {'group': ['A', 'A'],'start': [0, 5],'end': [50, 7]}? Если да, то вы приняли ответ, который (по крайней мере, в его нынешней форме) не соответствует этому подходу (@mozway, в остальном отличный map подход)... И в этом случае, пожалуйста, обновите свой вопрос, указав пример ranges, который включает эту возможность, а также желаемый результат, основанный на этих скорректированных входных данных.

ouroboros1 13.06.2024 10:06

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

donkey 14.06.2024 08:28

@donkey Понятно, однако твой пример не совсем ясен. Не существует A, который соответствовал бы второй группе A из ranges. Поскольку на этот вопрос уже получено много хороших ответов на исходное описание проблемы, я бы рекомендовал открыть новый вопрос с хорошо продуманным примером.

mozway 14.06.2024 08:50

@mozway: Я поддерживаю это предложение. @donkey: пожалуйста, верните вопрос к предыдущей версии без дубликатов в ranges. Затем создайте новую публикацию для варианта с дубликатами, сделав ссылку на эту публикацию для справки. В новом сообщении четко покажите, как наличие дубликатов меняет желаемый результат, и включите этот результат в сообщение. Недавнее обновление здесь делает недействительными все существующие ответы и сводит на нет значительные усилия нескольких участников, а это не то, как SO должен работать.

ouroboros1 14.06.2024 09:38
Почему в 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 может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
4
6
166
4
Перейти к ответу Данный вопрос помечен как решенный

Ответы 4

Вот неэффективное решение с использованием памяти:

ranges['range_id'] = ranges.index
df_merged = pd.merge(df, ranges, on='group')
df_filtered = df_merged[(df_merged['start_x'] >= df_merged['start_y'])
                        & (df_merged['end_x'] <= df_merged['end_y'])]
aggregation_dict = {"val1": ['min', 'max', 'mean', 'sum'],
                    "val2": ['min', 'max', 'mean', 'sum']}
result = df_filtered.groupby('range_id').agg(aggregation_dict).reset_index()

Для эффективной работы вам необходимо использовать мощные возможности Pandas groupby и aggregation:

import pandas as pd
import numpy as np

data_df = {
    'group': ['A', 'B', 'A', 'C', 'B'],
    'start': [10, 20, 15, 30, 25],
    'end': [50, 40, 60, 70, 45],
    'val1': ['5', '10', '11', '12', '6'],
    'val2': ['5', '2', '1', '1', '0'],
}

data_ranges = {
    'group': ['A', 'B', 'C'],
    'start': [0, 5, 25],
    'end': [50, 7, 35],
}

df = pd.DataFrame(data_df)
ranges = pd.DataFrame(data_ranges)

df['val1'] = pd.to_numeric(df['val1'])
df['val2'] = pd.to_numeric(df['val2'])

def aggregate_range(df, ranges):
    results = []
    for idx, row in ranges.iterrows():
        mask = (
            (df['group'] == row['group']) &
            (df['start'] >= row['start']) &
            (df['end'] <= row['end'])
        )
        filtered_df = df[mask]
        if not filtered_df.empty:
            agg_dict = {
                'val1_min': filtered_df['val1'].min(),
                'val1_max': filtered_df['val1'].max(),
                'val1_mean': filtered_df['val1'].mean(),
                'val1_sum': filtered_df['val1'].sum(),
                'val2_min': filtered_df['val2'].min(),
                'val2_max': filtered_df['val2'].max(),
                'val2_mean': filtered_df['val2'].mean(),
                'val2_sum': filtered_df['val2'].sum(),
            }
            agg_dict['range_id'] = idx
            results.append(agg_dict)
    return pd.DataFrame(results)

aggregated_df = aggregate_range(df, ranges)

aggregated_df = aggregated_df.set_index('range_id')
aggregated_df.columns = pd.MultiIndex.from_tuples(
    [('val1', 'min'), ('val1', 'max'), ('val1', 'mean'), ('val1', 'sum'),
     ('val2', 'min'), ('val2', 'max'), ('val2', 'mean'), ('val2', 'sum')]
)

print(aggregated_df)

Iterrows конечно не быстрый

sammywemmy 13.06.2024 07:45

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

mozway 13.06.2024 09:48

хороший! молодец @mozway

sammywemmy 13.06.2024 11:14

если подумать об этом сейчас, то iterrows будет эффективно использовать память, потому что он выполняет одну строку за раз, и это имеет смысл. производительность, однако, я ожидаю, что она будет плохой (я думаю, все относительно), поскольку размер данных (диапазонов растет), чем больше ranges становится, тем медленнее обычно он становится

sammywemmy 13.06.2024 11:30

Вероятно, это правда, я использовал до 20 групп в своих тестах, для большего количества групп ситуация может стать хуже (хотя groupby тоже повлияет)

mozway 13.06.2024 11:48
Ответ принят как подходящий

IIUC, я бы предварительно отфильтровал фрейм данных с помощью карты и логического индексирования , а затем выполнил бы классический groupby.agg. При этом маски и промежуточный (отфильтрованный) DataFrame должны быть минимальными для повышения эффективности использования памяти, а также минимизировать размер входных данных для groupby.

# columns to aggregate
cols = ['val1', 'val2']

# ensure data is numeric
df[cols] = df[cols].astype(int)

# optional, just to avoid having to `set_index` twice
tmp = ranges.set_index('group')

# pre-filter the rows for memory efficiency
# then perform a groupby.agg
out = (df[( df['start'].ge(df['group'].map(tmp['start']))
           &df['end'].le(df['group'].map(tmp['end'])))]
       .groupby('group', as_index=False)[cols].agg(['min', 'max', 'mean', 'sum'])
      )

Выход:

  group val1              val2             
         min max mean sum  min max mean sum
0     A    5   5  5.0   5    5   5  5.0   5

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

  group  start  end  val1  val2
0     A     10   50     5     5

вариант

@sammywemmy предложил вариант моего решения. Вместо того, чтобы вычислять все агрегаты одновременно в groupby.agg, вы можете вычислить их по отдельности и объединить их с помощью concat. Это быстрее и потенциально немного более эффективно с точки зрения памяти.

from itertools import product

cols = ['val1', 'val2']
tmp = ranges.set_index('group')

grouped = (df[( df['start'].ge(df['group'].map(tmp['start']))
               &df['end'].le(df['group'].map(tmp['end'])))]
          ).groupby('group')

aggs = ['min','mean','max','sum']

bunch = product(cols, aggs)
contents = []
for col, _agg in bunch:
    outcome = grouped[col].agg(_agg)
    outcome.name = (col,_agg)
    contents.append(outcome)

out = pd.concat(contents,axis=1)

тайминги

пример производящей функции

def init(N):
    df = pd.DataFrame({'group': np.random.randint(0, 20, N),
                       'start': np.random.randint(0, 100, N),
                       'end': np.random.randint(0, 100, N),
                       'val1': np.random.randint(0, 100, N),
                       'val2': np.random.randint(0, 100, N),
                      })
    
    # ensure start <= end
    df[['start', 'end']] = np.sort(df[['start', 'end']], axis=1)

    group = df['group'].unique()
    ranges = pd.DataFrame({'group': group,
                           'start': np.random.randint(0, 110, len(group)),
                           'end': np.random.randint(0, 110, len(group)),
                          })
    ranges[['start', 'end']] = np.sort(ranges[['start', 'end']], axis=1)
    
    return df, ranges

использование памяти

по 10 млн строк

# mozway_pre_filter
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
    49    711.2 MiB    711.2 MiB           1   def mozway_pre_filter(df, ranges):
    50                                             # columns to aggregate
    51    711.2 MiB      0.0 MiB           1       cols = ['val1', 'val2']
    52                                             
    53                                             # ensure data is numeric
    54    863.9 MiB    152.6 MiB           1       df[cols] = df[cols].astype(int)
    55                                             
    56                                             # optional, just to avoid having to `set_index` twice
    57    863.9 MiB      0.0 MiB           1       tmp = ranges.set_index('group')
    58                                             
    59                                             # pre-filter the rows for memory efficiency
    60                                             # then perform a groupby.agg
    61    950.9 MiB     11.2 MiB           4       return (df[( df['start'].ge(df['group'].map(tmp['start']))
    62    881.6 MiB      9.5 MiB           1                  &df['end'].le(df['group'].map(tmp['end'])))]
    63    950.7 MiB    -66.6 MiB           2              .groupby('group', as_index=False)[cols].agg(['min', 'max', 'mean', 'sum'])


# donkey_merge
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
    66    884.4 MiB    884.4 MiB           1   def donkey_merge(df, ranges):
    67    884.4 MiB      0.0 MiB           1       ranges = ranges.assign(range_id=ranges.index)
    68   1484.4 MiB    600.1 MiB           1       df_merged = pd.merge(df, ranges, on='group')
    69   1602.8 MiB    109.0 MiB           2       df_filtered = df_merged[(df_merged['start_x'] >= df_merged['start_y'])
    70   1494.0 MiB      9.4 MiB           1                               & (df_merged['end_x'] <= df_merged['end_y'])]
    71   1602.8 MiB      0.0 MiB           2       aggregation_dict = {"val1": ['min', 'max', 'mean', 'sum'],
    72   1602.8 MiB      0.0 MiB           1                           "val2": ['min', 'max', 'mean', 'sum']}
    73   1585.3 MiB    -17.6 MiB           1       return df_filtered.groupby('range_id').agg(aggregation_dict).reset_index()


# Nayem_aggregate_range
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
    19    905.9 MiB    905.9 MiB           1   def Nayem_aggregate_range(df, ranges):
    20    905.9 MiB      0.0 MiB           1       results = []
    21    961.5 MiB      0.0 MiB          21       for idx, row in ranges.iterrows():
    22    961.5 MiB      0.0 MiB          20           mask = (
    23    961.5 MiB     55.6 MiB          60               (df['group'] == row['group']) &
    24    961.5 MiB      0.0 MiB          20               (df['start'] >= row['start']) &
    25    961.5 MiB      0.0 MiB          20               (df['end'] <= row['end'])
    26                                                 )
    27    961.5 MiB      0.0 MiB          20           filtered_df = df[mask]
    28    961.5 MiB      0.0 MiB          20           if not filtered_df.empty:
    29    961.5 MiB      0.0 MiB          20               agg_dict = {
    30    961.5 MiB      0.0 MiB          20                   'val1_min': filtered_df['val1'].min(),
    31    961.5 MiB      0.0 MiB          20                   'val1_max': filtered_df['val1'].max(),
    32    961.5 MiB      0.0 MiB          20                   'val1_mean': filtered_df['val1'].mean(),
    33    961.5 MiB      0.0 MiB          20                   'val1_sum': filtered_df['val1'].sum(),
    34    961.5 MiB      0.0 MiB          20                   'val2_min': filtered_df['val2'].min(),
    35    961.5 MiB      0.0 MiB          20                   'val2_max': filtered_df['val2'].max(),
    36    961.5 MiB      0.0 MiB          20                   'val2_mean': filtered_df['val2'].mean(),
    37    961.5 MiB      0.0 MiB          20                   'val2_sum': filtered_df['val2'].sum(),
    38                                                     }
    39    961.5 MiB      0.0 MiB          20               agg_dict['range_id'] = idx
    40    961.5 MiB      0.0 MiB          20               results.append(agg_dict)
    41    961.5 MiB      0.0 MiB           1       aggregated_df = pd.DataFrame(results)
    42    961.5 MiB      0.0 MiB           1       aggregated_df = aggregated_df.set_index('range_id')
    43    961.5 MiB      0.0 MiB           2       aggregated_df.columns = pd.MultiIndex.from_tuples(
    44    961.5 MiB      0.0 MiB           1           [('val1', 'min'), ('val1', 'max'), ('val1', 'mean'), ('val1', 'sum'),
    45                                                  ('val2', 'min'), ('val2', 'max'), ('val2', 'mean'), ('val2', 'sum')]
    46                                             )
    47    961.5 MiB      0.0 MiB           1       return aggregated_df


# user24714692_merge_agg_
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     3    879.9 MiB    879.9 MiB           1   def user24714692_merge_agg_(df, ranges):
     4   1429.1 MiB    549.2 MiB           1       mdf = pd.merge(df, ranges, on='group', suffixes=('', '_range'))
     5                                         
     6   1527.7 MiB     70.3 MiB           2       fdf = mdf[
     7   1457.4 MiB     19.1 MiB           2           (mdf['start'] >= mdf['start_range']) &
     8   1438.3 MiB      9.2 MiB           1           (mdf['end'] <= mdf['end_range'])
     9                                             ]
    10                                         
    11   1527.9 MiB      0.3 MiB           3       res = fdf.groupby(['group', 'start_range', 'end_range']).agg({
    12   1527.7 MiB      0.0 MiB           1           'val1': ['min', 'max', 'mean', 'sum'],
    13   1527.7 MiB      0.0 MiB           1           'val2': ['min', 'max', 'mean', 'sum']
    14   1527.9 MiB      0.0 MiB           1       }).reset_index()
    15                                         
    16   1527.9 MiB      0.0 MiB          14       res.columns = ['_'.join(col).strip() if col[1] else col[0] for col in res.columns.values]
    17   1527.9 MiB      0.0 MiB           1       return res

Максимальное использование памяти

вы могли бы получить больше производительности, вычислив агрегацию индивидуально перед объединением - но я думаю, OP справится с этим

sammywemmy 13.06.2024 11:23

Не уверен, что подписан на тебя @sammywemmy, ты имеешь в виду pd.concat в цикле в группах?

mozway 13.06.2024 11:29

поэтому создайте сгруппированный объект, создайте каждую агрегацию, затем объедините список агрегированных значений через pd.concat. странный маршрут, но мог бы быть быстрее (исходя из личного опыта... YMMV)

sammywemmy 13.06.2024 11:31

@sammywemmy IIUC, тогда вам понадобится шаг unstack, чтобы иметь тот же формат. Кажется, это менее эффективно

mozway 13.06.2024 11:40

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

sammywemmy 13.06.2024 11:48

@sammywemmy Понятно (ты забыл grouped = out.groupby('group')), это действительно быстрее

mozway 13.06.2024 11:54

удален. надеюсь, это имело смысл

sammywemmy 13.06.2024 11:54

@sammywemmy Вы можете оставить это (хорошо отформатировать), и я обновлю тайминги. Или добавьте свой ответ.

mozway 13.06.2024 11:54

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

sammywemmy 13.06.2024 11:56

@sammywemmy обновлено, спасибо за ваш отзыв :)

mozway 13.06.2024 12:13

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

donkey 14.06.2024 08:28
import numpy as np
import pandas as pd

df = pd.DataFrame({
    'group': ['A', 'A', 'A', 'B', 'B', 'B'],
    'start': [10, 20, 30, 15, 25, 35],
    'end': [50, 60, 70, 55, 65, 75],
    'val1': [5, 10, 15, 20, 25, 30],
    'val2': [5, 6, 7, 8, 9, 10]
})
print(df)
'''
  group  start  end  val1  val2
0     A     10   50     5     5
1     A     20   60    10     6
2     A     30   70    15     7
3     B     15   55    20     8
4     B     25   65    25     9
5     B     35   75    30    10
'''
ranges = pd.DataFrame({
    'group': ['A', 'B'],
    'start': [5, 10],
    'end': [40, 60]
})

# Convert ranges to a dictionary for quick lookup
range_dict = {group: (start, end) for group, start, end in ranges.values}
print(range_dict)#{'A': (5, 40), 'B': (10, 60)}

m1 = df['start'] >= df['group'].map(range_dict).str[0].values

m2 = df['end'] <= df['group'].map(range_dict).str[1].values

filtered_indices = np.where(m1 & m2)[0]
print(filtered_indices)#[3]

filtered_df = df.iloc[filtered_indices]
'''
 group  start  end  val1  val2
3     B     15   55    20     8
'''
# Aggregate using groupby and strings for functions
cols= ['val1', 'val2']
agg_functions = ['min','max','mean','sum']

res = filtered_df.groupby('group')[cols].agg({'val1':agg_functions,'val2':agg_functions}).reset_index()
print(res)
'''
 group val1               val2             
         min max  mean sum  min max mean sum
0     B   20  20  20.0  20    8   8  8.0   8
'''

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