Python: от данных до своей сводки Урок 24 из 56
Зачем pandas, если есть списки: что показал замер
Двадцать четвёртый урок курса по Python. Модуль про pandas начинается с вопроса, с которого и должен: стоит ли он того. Отвечает замер, а не вера. Где pandas быстрее в сорок раз, где всего вдвое, где он медленнее обычного цикла, сколько стоит памяти — и правило, когда таблица уже нужна.
Зачем это нужно
Сводка умеет брать данные из сети, класть их в базу и отвечать запросами. Дальше начинаются вопросы, которые задают не по одному: сколько по каждой категории, как это менялось по месяцам, что будет, если пересчитать в другой валюте, а теперь то же самое, но без выходных. На списках каждый такой вопрос — новый цикл и новый словарь, и через десяток вопросов программа состоит из них.
Про pandas принято говорить, что он быстрый. Это половина правды, и вторая половина полезнее первой. Поэтому модуль начинается не с обещания, а с секундомера: один и тот же вопрос, заданный двумя способами.
Сразу целиком
Сначала установка — одна на весь модуль. Окружение мы завели во втором уроке, библиотека ставится в него:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install pandas==3.0.5
Версия закреплена намеренно. pandas печатает таблицу по-своему, и в другой версии вывод урока может отличаться от вашего — не потому, что вы ошиблись.
Файл zamer.py. Он задаёт один вопрос — средний чек по категориям — сначала руками, потом через pandas, и сверяет ответы.
"""Урок 24: зачем pandas, если есть списки.
Один вопрос — средний чек по категориям — задан дважды: руками по спискам и
через pandas. Сначала ответы обязаны совпасть, и только потом есть смысл
спорить, чего каждый способ стоит.
"""
import pandas as pd
KINDS = ["еда", "топливо", "аренда", "связь", "прочее"]
BASE = {"еда": 2500, "топливо": 9000, "аренда": 60000, "связь": 4000, "прочее": 1500}
N = 300_000
# Таблица построена по счётчику, без случайных чисел: у всех она одинаковая.
rows = [(KINDS[i % 5], BASE[KINDS[i % 5]] + (i * 37) % 900 - 450) for i in range(N)]
print("строк:", len(rows))
print()
print("== руками: два словаря и один проход")
total, count = {}, {}
for kind, amount in rows:
total[kind] = total.get(kind, 0) + amount
count[kind] = count.get(kind, 0) + 1
by_hand = {kind: total[kind] / count[kind] for kind in sorted(total)}
for kind, average in by_hand.items():
print(f"{kind:9} {average:9.2f}")
print()
print("== pandas: одна строка")
df = pd.DataFrame(rows, columns=["kind", "amount"])
by_pandas = df.groupby("kind")["amount"].mean()
print(by_pandas)
print()
print("== ответы совпадают:", all(round(by_hand[k], 6) == round(by_pandas[k], 6) for k in by_hand))
print("таблица:", type(df).__name__, "| столбцы:", list(df.columns))
print("типы столбцов:", dict(df.dtypes.astype(str)))
print("ответ:", type(by_pandas).__name__, "— не список, а столбец с подписями")
print()
print("== столбец целиком, без цикла")
with_vat = df["amount"] * 1.12
print("pandas:", [round(x, 2) for x in with_vat.head(3)])
print("руками:", [round(amount * 1.12, 2) for _, amount in rows[:3]])
Выводит:
строк: 300000
== руками: два словаря и один проход
аренда 60001.50
еда 2497.49
прочее 1500.48
связь 3998.49
топливо 8999.49
== pandas: одна строка
kind
аренда 60001.495
еда 2497.485
прочее 1500.480
связь 3998.490
топливо 8999.490
Name: amount, dtype: float64
== ответы совпадают: True
таблица: DataFrame | столбцы: ['kind', 'amount']
типы столбцов: {'kind': 'str', 'amount': 'int64'}
ответ: Series — не список, а столбец с подписями
== столбец целиком, без цикла
pandas: [2296.0, 9617.44, 66778.88]
руками: [2296.0, 9617.44, 66778.88]
Разбор
Сначала одинаковый ответ, потом секундомер
Сравнивать скорость двух программ, которые считают разное, — самая обычная ошибка замера. Поэтому первое, что делает zamer.py, — доказывает, что оба способа дали одно и то же. Быстро посчитать не то, что нужно, умеет кто угодно.
Сравнение идёт через round(..., 6), а не через == напрямую: это дробные числа, и почему их нельзя сравнивать в лоб, показал четвёртый урок.
Что такое DataFrame на самом деле
Список кортежей хранит строки: триста тысяч маленьких объектов, у каждого свой заголовок и свои ссылки. DataFrame хранит столбцы: триста тысяч сумм лежат одним блоком чисел одного типа, подряд, без обёрток. Отсюда всё остальное.
Поэтому df["amount"] * 1.12 не обходит триста тысяч питоновских объектов по одному. Она отдаёт блок целиком коду, написанному не на Python, и умножение идёт там. Цикл никуда не делся — он просто перестал быть питоновским.
Отсюда же и Series. Ответ группировки — не список чисел, а столбец с подписями: слева категории, справа значения, и они держатся вместе. Список после groupby пришлось бы подписывать самому — ровно тем словарём, который мы писали руками.
Замер
Та же таблица, но на миллион строк, четыре вопроса и timeit. Программа ничего не печатает про правильность — правильность уже доказана выше:
# замер: тот же вопрос, заданный двумя способами. Числа будут ваши, не мои.
import timeit
import pandas as pd
KINDS = ["еда", "топливо", "аренда", "связь", "прочее"]
BASE = {"еда": 2500, "топливо": 9000, "аренда": 60000, "связь": 4000, "прочее": 1500}
rows = [(KINDS[i % 5], BASE[KINDS[i % 5]] + (i * 37) % 900 - 450) for i in range(1_000_000)]
df = pd.DataFrame(rows, columns=["kind", "amount"])
def hand_group():
total, count = {}, {}
for kind, amount in rows:
total[kind] = total.get(kind, 0) + amount
count[kind] = count.get(kind, 0) + 1
return {k: total[k] / count[k] for k in total}
def best(work):
# Пять замеров, берём лучший: медленные — это когда машина отвлеклась.
return min(timeit.repeat(work, number=1, repeat=5))
print("средний чек: руками %.3f с, pandas %.3f с"
% (best(hand_group), best(lambda: df.groupby("kind")["amount"].mean())))
print("новый столбец: руками %.3f с, pandas %.3f с"
% (best(lambda: [a * 1.12 for _, a in rows]), best(lambda: df["amount"] * 1.12)))
print("сумма по одной категории: руками %.3f с, pandas %.3f с"
% (best(lambda: sum(a for k, a in rows if k == "еда")),
best(lambda: df.loc[df["kind"] == "еда", "amount"].sum())))
print("сортировка: руками %.3f с, pandas %.3f с"
% (best(lambda: sorted(rows, key=lambda r: r[1])), best(lambda: df.sort_values("amount"))))
На моей машине — Python 3.14.5, pandas 3.0.5, миллион строк:
| Что считаем | Руками | pandas |
|---|---|---|
| средний чек по категориям | 0.124 с | 0.062 с |
| столбец с наценкой 12 % | 0.045 с | 0.001 с |
| сумма по одной категории | 0.021 с | 0.045 с |
| сортировка по сумме | 0.173 с | 0.089 с |
| память под ту же таблицу | 100 МБ | 155 МБ |
У вас числа будут другие: другая машина, другая версия pandas, другие типы столбцов, другая нагрузка. Меняются и расстояния между колонками — где-то сильнее, где-то слабее. Устойчиво здесь другое: на какой операции разница уходит в порядки, а на какой остаётся в разы. Это и стоит запомнить, а числа — перемерить у себя.
Что в этих числах видно
Сорок раз — там, где считает столбец. df["amount"] * 1.12 против списочного выражения: 0.001 против 0.045. Миллион умножений сделан один раз блоком, а не миллион раз по одному. Это и есть главный аргумент за pandas, и он единственный, где счёт идёт на порядки.
Вдвое — там, где всё равно надо пройти всё. Группировка и сортировка выигрывают в два раза, не в сорок. Работа та же самая, просто выполняет её код побыстрее нашего. Вдвое — это хорошо, но это не та разница, ради которой ставят библиотеку.
Медленнее — там, где мы попросили лишнего. df.loc[df["kind"] == "еда", "amount"].sum() сначала строит маску: миллион ответов «да/нет», целый новый столбец, — и только потом складывает. А sum(a for k, a in rows if k == "еда") проходит один раз и не строит ничего. На одном вопросе список выигрывает. Выигрыш появляется, когда маску сохраняют и переиспользуют: eda = df["kind"] == "еда", а дальше df.loc[eda, "amount"].sum(), df.loc[eda, "amount"].mean(), df.loc[eda] — миллион сравнений сделан один раз на десять вопросов. Если каждый раз писать условие заново, pandas будет строить маску заново тоже.
Память не бесплатна. Та же таблица: сто мегабайт списком, сто пятьдесят пять таблицей. Виноваты не числа, а слова: в столбце kind пять разных значений, но лежат они по одному на строку. df["kind"].astype("category") превращает эти 155 МБ в 9 МБ — пять слов хранятся один раз, а в столбце остаются номера. Это тот же приём, что и в уроке про множества: не хранить одинаковое дважды.
Импорт стоит треть секунды. Для программы, которая работает минуту, это ничто. Для той, что запускается раз в минуту по расписанию, это её главный расход — и повод не тащить pandas в маленький скрипт.
Настоящая причина, по которой его берут
Восемь строк руками против одной — вот что решает на самом деле. Не секунды, а то, что второй, третий и десятый вопрос к той же таблице стоят по строке каждый, и что ответ остаётся таблицей, которой можно задать следующий вопрос.
Тот же довод вы уже видели в уроке про SQL: GROUP BY не потому лучше цикла, что быстрее, а потому, что вопрос написан вопросом. pandas — это тот же ход, только таблица лежит в памяти вашей программы, а не в базе.
Когда его брать не надо
- Пятьсот строк и один вопрос: цикл проще, а импорт длиннее самой работы.
- Данные — не таблица: вложенный JSON или дерево сначала придётся уговаривать стать плоскими.
- Считаете один раз в жизни: зависимость, которую надо ставить и закреплять, переживёт задачу.
Правило, которое работает: таблица и больше одного вопроса к ней — берите pandas; один ответ и мало данных — не берите.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему программа сначала доказывает, что ответы совпали, и только потом меряет время?
- Почему умножение столбца быстрее цикла в сорок раз, а группировка — только вдвое?
- В каком случае pandas оказался медленнее обычного списка и почему?
Разминка
Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.
1. Предскажите. Что напечатает эта программа?
import pandas as pd
cheques = pd.Series([1200, 800, 4000], index=["еда", "еда", "связь"])
print(cheques * 1.12)
print("сумма:", cheques.sum(), "| средний:", cheques.mean())
2. Заполните пропуск. Вместо ... поставьте столько замеров, чтобы len(times) напечатал 5.
# сколько раз мерить: один замер — это не замер
import timeit
times = timeit.repeat(lambda: sum(range(100_000)), number=1, repeat=...)
print("замеров:", len(times), "| лучший не больше худшего:", min(times) <= max(times))
3. Почините. Два способа считают строки одной и той же таблицы и расходятся на одну. Найдите лишнюю строку и уберите её.
# руками получается на строку больше, чем у pandas
import csv
import io
import pandas as pd
TEXT = "kind,amount\nеда,1200\nеда,800\nсвязь,4000\n"
reader = csv.reader(io.StringIO(TEXT))
print("руками строк:", len(list(reader)))
print("pandas строк:", len(pd.read_csv(io.StringIO(TEXT))))
Задание
Обязательное. Возьмите ту же таблицу на 300 000 строк из урока. По каждой категории посчитайте три числа: сумму, средний чек и долю в общей сумме. Сначала руками — словарями за один проход, — потом через pandas: df.groupby("kind")["amount"].agg(["sum", "mean"]) и столбец с долей.
Напечатайте таблицу, отсортированную по сумме сверху вниз, строку «всего» и строку сверки. Сверка обязана сойтись до шестого знака.
Ожидаемый вывод:
категория сумма средний доля
аренда 3600089700 60001.50 77.9%
топливо 539969400 8999.49 11.7%
связь 239909400 3998.49 5.2%
еда 149849100 2497.49 3.2%
прочее 90028800 1500.48 1.9%
всего 4619846400
совпадают: да
Готово, когда: вывод совпадает построчно; доли считаются от общей суммы, а не от суммы средних; сверка сравнивает округлённые числа, а не дробные напрямую; таблица отсортирована по сумме, а не по алфавиту.
На своих данных. Возьмите свой файл — выгрузку из банка, таблицу расходов, что угодно на несколько тысяч строк — и задайте ему один вопрос двумя способами. Замерьте timeit. Запишите два числа и ответьте себе: стоила ли библиотека этой разницы именно на ваших данных.
По желанию.
- Замерьте, сколько на вашей машине занимает сам
import pandas:python -X importtime -c "import pandas". - Увеличьте таблицу в десять раз и посмотрите, какие из четырёх разниц выросли, а какие остались прежними.
- Переведите столбец
kindвastype("category")и сравнитеdf.memory_usage(deep=True).sum()до и после.
Куда это встанет в проекте
Пока никуда — и это осознанно. Урок отвечает на вопрос «стоит ли», а не «как»; сводка меняется со следующего урока, где её таблица становится DataFrame. Единственное, что прибавляется сейчас, — строка pandas==3.0.5 в requirements.txt проекта: версия закреплена, потому что вывод в уроках напечатан именно этой.
Долги. Библиотека — это зависимость: её надо ставить, обновлять и однажды чинить после обновления. Пока сводка считает на курсах валют, где строк тысячи, весь выигрыш pandas в ней — не скорость, а короткий код. Честно будет это признать, а не делать вид, что мы ускорились.
Ответы
Показать ответы
На вопросы
- Потому что скорость двух программ сравнима, только если они дают один и тот же ответ. Иначе замер измеряет не то: программа, которая считает неправильно, обычно ещё и быстрее — она делает меньше работы.
- Умножение столбца целиком уходит в код вне Python и идёт по блоку памяти подряд, поэтому выигрывает порядок. Группировка всё равно обязана посмотреть на каждую строку и разложить её по корзинам: работа та же, просто выполняется быстрее — отсюда «вдвое», а не «в сорок раз».
- На сумме по одной категории.
df["kind"] == "еда"строит целый новый столбец из миллиона «да/нет» и только потом складывает, а генератор проходит данные один раз и ничего не создаёт. Как только вопросов к таблице становится много, эта плата окупается.
К разминке
- Умножение применяется к каждому значению, подписи остаются на месте, и тип столбца становится дробным.
sum()иmean()считают по всему столбцу сразу.
еда 1344.0
еда 896.0
связь 4480.0
dtype: float64
сумма: 6000 | средний: 2000.0
repeat=5.timeit.repeatвозвращает список из стольких замеров, сколько попросили; из них берут минимум, а не среднее: лишнее время — это всегда чужая работа на той же машине, а не ваша программа.
import timeit
times = timeit.repeat(lambda: sum(range(100_000)), number=1, repeat=5)
print("замеров:", len(times), "| лучший не больше худшего:", min(times) <= max(times))
замеров: 5 | лучший не больше худшего: True
- Лишняя строка — заголовок.
csv.readerотдаёт его как обычную строку данных, аread_csvпонимает, что это имена столбцов. Пропустите его черезnext(reader).
import csv
import io
import pandas as pd
TEXT = "kind,amount\nеда,1200\nеда,800\nсвязь,4000\n"
reader = csv.reader(io.StringIO(TEXT))
next(reader)
print("руками строк:", len(list(reader)))
print("pandas строк:", len(pd.read_csv(io.StringIO(TEXT))))
руками строк: 3
pandas строк: 3
Заодно это ответ на вопрос, зачем нужен read_csv, когда есть модуль csv из двенадцатого урока: он знает про заголовок, про типы столбцов и про пропуски — то есть про всё, о чём в цикле приходится помнить самому.
К заданию
Считать долю нужно от суммы сумм, а не от суммы средних: категории разного размера, и среднее по средним даёт не тот ответ. В pandas это table["sum"] / table["sum"].sum() * 100, а руками — деление на sum(total.values()), посчитанное один раз до цикла вывода.
Сортировка по сумме — table.sort_values("sum", ascending=False). Без неё таблица выйдет по алфавиту, потому что groupby сортирует по ключу.
Источники
- 10 минут до pandas — официальное введение, из которого стоит прочитать первые два раздела.
- Что нового в pandas 3.0 — версия, на которой напечатан вывод этого урока.
- Модуль timeit — как мерить время так, чтобы измерять программу, а не машину.
- pandas: большие данные — что делать, когда таблица перестала помещаться в память.
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.