Один заказ в двух категориях: распределяем выручку после many-to-many merge
У заказа две тематические категории. После присоединения справочника он законно занимает две строки, но итоговая выручка удваивается. Удалить одну категорию нельзя: отчёт должен показывать обе. Нужно решить, какая часть суммы относится к каждой связи.
Определить смысл отчёта до соединения
Отчёт «заказы, в которых встречалась категория» вправе отнести полную сумму к каждой категории. Его строки пересекаются. Отчёт «распределение выручки по категориям» требует весов: вклад одного заказа в совокупности составляет единицу. Эти отчёты отвечают на разные вопросы, хотя используют одинаковый merge.
Для примера распределим сумму поровну между уникальными тегами заказа. В рабочей задаче вес может зависеть от стоимости позиций или согласованной модели атрибуции. Равные доли — прозрачное соглашение, но сами по себе не доказывают экономический вклад категории.
Проверить таблицу связей
Одна пара order_id и tag должна встречаться один раз. Повтор пары искусственно изменит веса. После проверки считаем количество тегов через transform. Заказ без тегов получает отдельную категорию unassigned и весь свой вес. Так отчёт сохраняет неразмеченную выручку.
Увеличение числа строк здесь ожидаемо. Поэтому проверка количества строк до и после не может быть единственным критерием успеха. Контролировать нужно сумму весов по заказу и распределённые деньги.
import pandas as pd
orders = pd.DataFrame({"order_id": [1, 2], "amount": [120.0, 80.0]})
links = pd.DataFrame({"order_id": [1, 1], "tag": ["home", "gift"]})
assert not links.duplicated(["order_id", "tag"]).any()
x = orders.merge(links, on="order_id", how="left", validate="one_to_many")
x["tag"] = x["tag"].fillna("unassigned")
x["weight"] = 1 / x.groupby("order_id")["tag"].transform("size")
x["allocated"] = x["amount"] * x["weight"]
report = x.groupby("tag")["allocated"].sum()
print(report)
assert report.to_dict() == {"gift": 60.0, "home": 60.0, "unassigned": 80.0}
assert abs(report.sum() - orders["amount"].sum()) < 1e-9
assert x.groupby("order_id")["weight"].sum().eq(1).all()Получились 60 для home, 60 для gift и 80 нераспределённой выручки. Число строк выросло, а сумма осталась 200. Заказ 1 по-прежнему приносит 120, просто его вклад виден в двух категориях.
Сохранить возможность объяснить итог
Сохраните таблицу вкладов с order_id, категорией, весом и суммой. По ней можно восстановить любую строку агрегата. Отдельно найдите ссылки на несуществующие заказы: левое соединение их не покажет. Если веса приходят из источника, проверьте отсутствие отрицательных и пропущенных значений. Сумма весов, равная единице, ещё допускает -1 и 2, которые здесь бессмысленны.
Для финансового распределения с точностью до копейки требуется правило остатка. Можно считать целые копейки и распределять остаток по стабильному порядку категорий. Независимое округление каждой доли может изменить общий итог. В аналитическом графике допустимы дробные суммы, но политика округления нужна при показе.
Категория без разметки должна оставаться заметной. Если убрать её и нормировать остальные категории до ста процентов, бизнес не увидит ухудшение покрытия. При сравнении месяцев храните версию таблицы связей: новая разметка меняет картину даже при тех же заказах.
Проверьте три случая: заказ с одним тегом, с несколькими тегами и без тегов. Затем переставьте строки связей и убедитесь, что распределение не меняется. Так проверяется правило расчёта, а не случайный порядок выгрузки.
Закрепить на практике
В бесплатном уроке merge и concat вы отработаете соединение таблиц и отношение ключей. После базовых задач вернитесь к примеру, где число строк растёт законно, а сумма сохраняется через веса. Откройте бесплатный урок и задачи. Полная последовательность тем — в бесплатном курсе.
Продолжить на практике
- NumPy и pandas: практический тренажёр ↗
Бесплатный тренажёр NumPy и pandas для тех, кто знает основы Python. Научитесь очищать таблицы, объединять данные и собирать отчёты: 31 урок и 124 задачи с кодом и автоматической проверкой.
