У меня есть два кадра данных Pandas. Назовем их df1
и df2
. Вот как выглядит df1
с фиктивными данными:
year,weight
2023,0.392
2022,0.382
2021,0.858
2020,0.135
И это df2
start_year,end_year
2022,2023
2021,2023
2020,2023
2020,2020
2021,2022
2022,2022
Ключевым фактом является то, что они не имеют одинаковой формы. Я хочу создать новый столбец в df2
, который представляет собой среднее значение весов в df1
, имеющих year
между start_year
и end_year
. Так, например, для первой строки это даст мне (0,382 + 0,392)/2 = 0,387. И для всей таблицы:
start_year,end_year,weight
2022,2023,0.387
2021,2023,0.544
2020,2023,0.44175
2020,2020,0.135
2021,2022,0.625
2022,2022,0.382
Вот упрощенная версия моей попытки решения, позволяющей сопоставить две мои фиктивные таблицы и фиктивные данные:
df2["weight"] = df1[df1["year"].between(df2["start_year"], df2["end_year"], inclusive = "both")["weight"].mean()
Это приводит к следующей ошибке.
ValueError: Can only compare identically-labeled Series objects
Я предполагаю, что это потому, что Pandas предполагает, что я пытаюсь сравнить их по строкам (т. е. я пытаюсь проверить, находится ли строка 1 из df1
между начальным и конечным годами строки 1 из df2
, строки 2 со строкой 2 и и т. д.), а не делать своего рода декартово произведение.
Я понимаю, что это можно сделать довольно просто с помощью цикла for, но это превратит задачу, занимающую доли секунды, в задачу, которая, вероятно, займет несколько секунд (поскольку df1
имеет около 110 строк, а df2
может иметь до Десятки тысяч строк в самых экстремальных случаях). Я хочу, чтобы это была векторизованная операция.
Пытаться:
tmp = df1.set_index("year").sort_index()
df2["weight"] = df2.apply(
lambda x: tmp.loc[x["start_year"] : x["end_year"], "weight"].mean(), axis=1
)
print(df2)
Распечатки:
start_year end_year weight
0 2022 2023 0.38700
1 2021 2023 0.54400
2 2020 2023 0.44175
3 2020 2020 0.13500
4 2021 2022 0.62000
5 2022 2022 0.38200
Вы можете использовать методы pd.merge и groupby.
Сначала разверните df2, чтобы включить все годы между start_year и end_year.
df2_expanded = df2.apply(lambda x: pd.Series(range(x['start_year'], x['end_year'] + 1)), axis=1).stack().reset_index(level=1, drop=True).to_frame('year')
df2_expanded = df2[['start_year', 'end_year']].join(df2_expanded)
Слияние с df1 по году
merged = pd.merge(df2_expanded, df1, on='year', how='left')
Сгруппируйте по исходным значениям start_year и end_year и рассчитайте средний вес.
result = merged.groupby(['start_year', 'end_year'])['weight'].mean().reset_index()
Затем объедините результат обратно в df2.
df2['weight'] = pd.merge(df2, result, on=['start_year', 'end_year'], how='left')['weight']
print(df2)
Это соединение диапазона — соединение неравенства, при котором один столбец из левого фрейма данных находится между столбцами из правого фрейма данных. Один из способов решить эту проблему — использовать Conditional_join от pyjanitor — я сотрудничаю с библиотекой. Под капотом он использует двоичный поиск для повышения производительности, особенно если условия подходящие. Бинарный поиск срабатывает, если оба столбца справа монотонно увеличиваются — таким образом он может пропускать несовпадающие фрагменты без прохождения линейного поиска:
# pip install pyjanitor
import pandas as pd
import janitor
(df2
.conditional_join(
df1,
('start_year', 'year', '<='),
('end_year', 'year', '>='),
how='inner')
.groupby(['start_year', 'end_year'],sort=False)
.weight
.mean()
)
start_year end_year
2022 2023 0.38700
2021 2023 0.54400
2020 2023 0.44175
2020 0.13500
2021 2022 0.62000
2022 2022 0.38200
Name: weight, dtype: float64
Вы можете вернуться к исходному фрейму данных, если хотите сохранить все строки. Также обратите внимание, что df1
находится справа — таким образом мы получаем монотонное увеличение обоих правых столбцов и, следовательно, используем преимущества двоичного поиска.
На данный момент это расточительно, поскольку мы материализуем фрейм данных перед группировкой. В будущем я надеюсь, что смогу выполнять агрегацию без необходимости материализации всего кадра данных (это должно обеспечить лучшую производительность), однако, скорее всего, это будет реализовано в numba.
Попробуйте и поделитесь своими результатами, а также производительностью.
Векторизованный подход:
# get full range of years in df1
year_range = np.array(range(df1["year"].min(), df1["year"].max() + 1))
# create boolean matrix to check years in year_range that fall between start_year and end_year in df2
m = (df2["start_year"].values <= year_range[:, None]) & (
year_range[:, None] <= df2["end_year"].values
)
# align weights from df1 with year_range
w = df1.set_index("year").reindex(year_range)["weight"].values[:, None]
# get weighted average for each row
df2["weight"] = (w * m).sum(axis=0) / m.sum(axis=0)
start_year end_year weight
0 2022 2023 0.38700
1 2021 2023 0.54400
2 2020 2023 0.44175
3 2020 2020 0.13500
4 2021 2022 0.62000
5 2022 2022 0.38200
Я думаю, что это эффективный ответ.
булева матрица — это декартово соединение, верно?
@sammywemmy Может быть, в каком-то смысле, поскольку он сочетается каждый год в year_range
с каждым диапазоном в df2
.
Спасибо! Это работает очень хорошо. Это намного быстрее, чем решения, которые мне удалось создать с момента публикации этого поста. Чтобы уточнить (прежде чем я приму ваш ответ), означает ли это ваше решение, что вы не думаете, что это возможно сделать векторизованным способом? Или каким-то более быстрым способом, чем pd.DataFrame.apply?