pandas: таблицы как у профессионалов
Series и DataFrame, чтение CSV, JSONL и Parquet, фильтры, groupby, merge и pivot, даты, строки на разных языках, пропуски и память — на сквозном датасете логов LLM-сервиса.
Зачем это нужно
Заголовок раздела «Зачем это нужно»До сих пор вы держали данные в списках, словарях и массивах NumPy. Для одного признака это удобно. Но настоящие данные — это таблицы: у каждой строки десяток столбцов разных типов (даты, числа, категории, текст), где-то пропуски, а нужные ответы — «средняя задержка по моделям», «расходы по дням», «доля ошибок по языкам». Писать это циклами по словарям долго и чревато ошибками. Для таких задач есть pandas — стандартная библиотека для табличных данных в Python.
Инженеру LLM pandas нужен каждый день:
- SFT-датасеты и eval-наборы хранят в JSONL (урок 0.2.4). Посчитать длины примеров, найти дубликаты, разбить по языкам и темам — это три строки pandas.
- Результаты эвалов: 2000 вопросов × 5 моделей × 3 запуска. Сводная таблица «модель × категория» — одна команда
pivot_table. - Логи сервиса: задержки, токены, стоимость, ошибки. С таким датасетом мы и проработаем весь урок.
- Модуль 2.4 целиком: разведочный анализ (2.4.2), бустинг (2.4.3) и честная валидация (2.4.4) работают на таблицах pandas.
Series: столбец с подписями
Заголовок раздела «Series: столбец с подписями»SeriesSeries (столбец pandas)EN pandas SeriesОдномерный массив NumPy плюс индекс — подписи строк. Арифметика выравнивает значения по подписям. — одномерный массив NumPy (урок 0.3.1), к которому приклеены подписи — индекс. Задержки трёх запросов в миллисекундах:
import pandas as pd
lat = pd.Series([420.0, 1310.0, 980.0], index=["r1", "r2", "r3"], name="latency_ms")print(lat["r2"], lat.iloc[0], lat.mean()) # 1310.0 420.0 903.33... — по метке, по позиции, среднееprint((lat / 1000).round(2).tolist()) # [0.42, 1.31, 0.98] — векторная операция, как в NumPyВсё, что вы знаете о массивах, работает: арифметика над всем столбцом сразу, mean, max, маски. Новое одно —
выравнивание по индексу. Когда складываете две серии, pandas сопоставляет значения по подписям, а не по позициям:
a = pd.Series([1, 2, 3], index=["r1", "r2", "r3"])b = pd.Series([10, 20, 30], index=["r2", "r3", "r4"])print(a + b)# r1 NaN ← у b нет r1# r2 12.0 ← 2 + 10# r3 23.0 ← 3 + 20# r4 NaN ← у a нет r4NaN («not a number») — так pandas обозначает пропуск. Выравнивание защищает от тихой ошибки «сложили
разные запросы, потому что строки стояли в разном порядке». Но если вы ждали сложения по позициям, оно удивляет.
Тогда сравнивайте .to_numpy() — голые массивы без индекса.
DataFrame: словарь столбцов
Заголовок раздела «DataFrame: словарь столбцов»DataFrameDataFrame (таблица pandas)EN DataFrameТаблица из столбцов-Series с общим индексом; у каждого столбца свой тип. — таблица, где каждый столбец — Series, и у всех столбцов общий индекс. Удобно думать о нём как о словаре «название столбца → столбец»:
df = pd.DataFrame({ "model": ["qwen3-8b", "deepseek-v3", "qwen3-8b"], "lang": ["ru", "zh", "en"], "latency_ms": [420.0, 1310.0, 980.0],})print(df)# model lang latency_ms# 0 qwen3-8b ru 420.0# 1 deepseek-v3 zh 1310.0# 2 qwen3-8b en 980.0print(df.shape) # (3, 3) — строк, столбцовСлева — индекс по умолчанию: номера строк 0, 1, 2. У каждого столбца свой тип (тип элементов массиватип элементов массива (dtype)EN data type (dtype)float32, float16, int64, bool… Определяет точность и сколько байт занимает каждое число. из урока 0.3.1):
latency_ms — float64, а model и lang — строки, в pandas 3 их тип называется str.
Сквозной датасет: логи LLM-сервиса
Заголовок раздела «Сквозной датасет: логи LLM-сервиса»Возьмём синтетические логи: 2000 запросов за неделю к трём моделям на трёх языках. Модели, цены и числа выдуманы, поэтому вопроса лицензий нет, зато устройство как у настоящих логов: время, пользователь, модель, язык, текст запроса, токены, задержка и статус. Как генерируются данные, можно не вникать: важен результат.
import numpy as npimport pandas as pd
def make_logs(n=2000, seed=0): """Синтетические логи LLM-сервиса за неделю. Все модели, цены и числа — условные.""" rng = np.random.default_rng(seed) model = rng.choice(["qwen3-8b", "qwen3-32b", "deepseek-v3"], size=n, p=[0.5, 0.3, 0.2]) lang = rng.choice(["ru", "de", "en"], size=n, p=[0.5, 0.3, 0.2]) prompts = { "ru": ["Переведи на китайский: добрый вечер", "Объясни attention простыми словами", "Напиши SQL-запрос к таблице заказов"], "de": ["Übersetze diesen Satz ins Russische: Hallo", "Erkläre den Attention-Mechanismus in einfachen Worten", "Schreib ein Gedicht über den Herbst"], "en": ["Summarize this article", "Explain gradient boosting", "Translate to Russian: good night"], } prompt = [prompts[l][k] for l, k in zip(lang, rng.integers(0, 3, n))] prompt_tokens = rng.lognormal(5.5, 0.8, n).astype(int) + 1 # длины с «тяжёлым хвостом» completion_tokens = rng.lognormal(5.0, 0.7, n).astype(int) + 1 sec_per_token = pd.Series(model).map({"qwen3-8b": 0.010, "qwen3-32b": 0.025, "deepseek-v3": 0.035}).to_numpy() latency_ms = (200 + 1000 * sec_per_token * completion_tokens) * rng.lognormal(0, 0.2, n) status = rng.choice(["ok", "error", "timeout"], size=n, p=[0.94, 0.03, 0.03]) latency_ms[status == "timeout"] = np.nan # у таймаутов задержку не записали ts = pd.Timestamp("2026-09-07") + pd.to_timedelta(rng.integers(0, 7 * 24 * 3600, n), unit="s") df = pd.DataFrame({ "ts": ts, "user_id": [f"u{k:02d}" for k in rng.integers(1, 41, n)], "model": model, "lang": lang, "prompt": prompt, "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "latency_ms": latency_ms.round(1), "status": status, }) return df.sort_values("ts").reset_index(drop=True)
logs = make_logs()Первый взгляд: пять команд
Заголовок раздела «Первый взгляд: пять команд»Любую новую таблицу начинайте с одних и тех же команд. Они отвечают на вопросы «сколько строк», «какие типы», «где пропуски», «какие диапазоны» и «какие значения встречаются».
print(logs.shape) # (2000, 9)logs.info() # типы столбцов и сколько в каждом непустых значений # Column Non-Null Count Dtype--- ------ -------------- ----- 0 ts 2000 non-null datetime64[us] 1 user_id 2000 non-null str ... 7 latency_ms 1935 non-null float64 ← 65 пропусков 8 status 2000 non-null strprint(logs.head(3)) # первые строки — посмотреть глазамиprint(logs.describe().round(1)) # count, mean, std, min, квартили, max числовых столбцовprint(logs["model"].value_counts()) # qwen3-8b 1007, qwen3-32b 581, deepseek-v3 412print(logs["status"].value_counts(normalize=True)) # ok 0.938, timeout 0.032, error 0.030 — долиВ выводе describe у latency_ms среднее 4032 мс, медиана (строка 50%) 2802 мс, а максимум 51 208 мс. Среднее
заметно больше медианы — признак «тяжёлого хвоста»: редкие очень долгие запросы тянут среднее вверх. Как такие
распределения смотреть и чинить, разберём в следующем уроке.
Чтение и запись: CSV, JSONL, Parquet
Заголовок раздела «Чтение и запись: CSV, JSONL, Parquet»| Формат | Что это | Когда брать |
|---|---|---|
| CSV | текст, значения через запятую | обмен с людьми и Excel; типы теряются |
| JSONL | по JSON-записи на строку (урок 0.2.4) | датасеты LLM, вложенные поля, потоковая запись |
| ParquetParquetEN Apache ParquetДвоичный колоночный формат таблиц со сжатием и сохранением типов. Можно читать только нужные столбцы. | двоичный колоночный формат со сжатием | большие таблицы для себя и пайплайнов |
logs.to_csv("logs.csv", index=False) # index=False — не писать номера строкback = pd.read_csv("logs.csv")print(back["ts"].dtype) # str — CSV не помнит, что это дата!back = pd.read_csv("logs.csv", parse_dates=["ts"]) # просим разобрать датуprint(back["ts"].dtype) # datetime64[us]
logs.to_json("logs.jsonl", orient="records", lines=True, force_ascii=False, date_format="iso")j = pd.read_json("logs.jsonl", lines=True, convert_dates=["ts"]) # lines=True — это JSONL
logs.to_parquet("logs.parquet") # нужен pyarrow: pip install pyarrowp = pd.read_parquet("logs.parquet", columns=["model", "latency_ms"]) # только два столбцаforce_ascii=False — тот же смысл, что ensure_ascii=False в модуле json: кириллица и немецкие умлауты остаются
читаемыми, а не превращаются в \u043f. Для CSV из китайского Excel часто нужен encoding="gbk", а для файла, который откроют
в Excel у нас, пишите encoding="utf-8-sig", иначе кириллица превратится в кракозябры (урок 0.2.4).
Сравним форматы на 200 тысячах строк тех же логов (make_logs(200_000, seed=1); цифры с одной машины, на вашей
будут другими, но соотношение похожее):
| CSV | JSONL | Parquet | |
|---|---|---|---|
| Размер файла | 20.0 МБ | 41.9 МБ | 3.0 МБ |
| Чтение всей таблицы | 0.37 с | — | 0.05 с |
| Чтение двух столбцов | — | — | 0.01 с |
Parquet хранит данные по столбцам: значения одного типа лежат рядом и хорошо сжимаются, а ненужные столбцы не читаются вообще. Типы (даты, целые, строки) сохраняются. Поэтому большие корпуса для обучения LLM на Hugging Face обычно лежат именно в Parquet.
Выбор строк и столбцов
Заголовок раздела «Выбор строк и столбцов»logs["latency_ms"] # один столбец → Serieslogs[["model", "latency_ms"]] # список столбцов → DataFrame (двойные скобки!)logs.loc[0, "model"] # 'qwen3-8b' — по метке строки и имени столбцаlogs.loc[0:2, ["model", "lang"]] # строки с метками 0, 1, 2 — loc включает правый конецlogs.iloc[0:2, 2:4] # строки 0, 1 и столбцы 2, 3 — iloc по позициям, как срез в PythonЗапомните пару: loc — по меткам, iloc — по номерам. Пока индекс — это 0, 1, 2…, разница видна только на
срезах: loc[0:2] даёт три строки, iloc[0:2] — две.
Фильтры — это булевы маски из урока 0.3.1: сравнение столбца даёт столбец
True/False, и таблица оставляет строки, где True.
de_slow = logs[(logs["lang"] == "de") & (logs["latency_ms"] > 3000)]print(len(de_slow)) # 259print(len(logs[logs["model"].isin(["qwen3-8b", "qwen3-32b"])])) # 1588 — «одно из»print(len(logs.query("lang == 'de' and latency_ms > 3000"))) # 259 — то же через строку-запросДве ошибки, которые совершает каждый. Первая — and вместо &:
logs[(logs["lang"] == "de") and (logs["latency_ms"] > 3000)]# ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().Python-овский and хочет одно True или False, а получает целый столбец. Для масок — & (и), | (или), ~ (не).
Вторая — забытые скобки: logs["lang"] == "de" & logs["latency_ms"] > 3000. У & приоритет выше, чем у ==,
поэтому Python пытается вычислить "de" & logs["latency_ms"] и падает с TypeError. Каждое условие — в свои скобки.
Новые столбцы и векторные операции
Заголовок раздела «Новые столбцы и векторные операции»logs["total_tokens"] = logs["prompt_tokens"] + logs["completion_tokens"]logs["tokens_per_sec"] = logs["completion_tokens"] / (logs["latency_ms"] / 1000)logs["is_long"] = np.where(logs["prompt_tokens"] > 1000, "длинный", "обычный") # «если — то — иначе»print(logs["is_long"].value_counts()) # обычный 1910, длинный 90Где в latency_ms пропуск, там и tokens_per_sec получится NaN: пропуски распространяются через арифметику.
Циклы по строкам — почти всегда ошибка. Сравним на 200 тысячах строк:
total = [row["prompt_tokens"] + row["completion_tokens"] for _, row in big.iterrows()] # 7.4 сtotal = big["prompt_tokens"] + big["completion_tokens"] # 0.001 сРазница в тысячи раз — та же история, что с векторизацией в NumPy (урок 0.3.2):
iterrows создаёт для каждой строки отдельный объект Series, а сложение столбцов — один проход скомпилированного
кода по массиву. Если хочется написать for по строкам или apply с lambda построчно, сначала поищите
векторную операцию: арифметику, np.where, методы .str и .dt (о них ниже).
Ловушка: присваивание по цепочке
Заголовок раздела «Ловушка: присваивание по цепочке»Хотим обнулить задержку у таймаутов:
logs[logs["status"] == "timeout"]["latency_ms"] = 0# ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment.print(logs["latency_ms"].isna().sum()) # 65 — ничего не изменилось!logs[маска] создаёт новую таблицу, и ["latency_ms"] = 0 записывает ноль в неё, а она тут же исчезает. В pandas 3
действует правило Copy-on-Write: любая выборка ведёт себя как копия, поэтому такое присваивание не работает никогда
(в старых версиях оно иногда срабатывало, иногда нет — ещё хуже). Правильно — одной операцией через loc
(покажем на копии, чтобы пропуски пригодились нам ниже):
fixed = logs.copy()fixed.loc[fixed["status"] == "timeout", "latency_ms"] = 0 # строки по маске, столбец по имениprint(fixed["latency_ms"].isna().sum()) # 0 — сработалоА если нужна новая таблица, не трогая старую, используйте df.assign(новый_столбец=...) — так написано
решение задания.
groupby: разделить, посчитать, собрать
Заголовок раздела «groupby: разделить, посчитать, собрать»Главный вопрос аналитика: «а как это выглядит по группам?». Механика группировкагруппировка (groupby)EN group by / split-apply-combineРазбить строки на группы по ключу, посчитать сводку в каждой (agg) или вернуть значение каждой строке (transform), собрать результат. называется split-apply-combine:
flowchart LR A["Таблица логов"] --> B["split:<br/>разбить по model"] B --> C1["qwen3-8b<br/>1007 строк"] B --> C2["qwen3-32b<br/>581 строка"] B --> C3["deepseek-v3<br/>412 строк"] C1 --> D1["apply: mean"] C2 --> D2["apply: mean"] C3 --> D3["apply: mean"] D1 --> E["combine:<br/>таблица<br/>по строке на модель"] D2 --> E D3 --> E
print(logs.groupby("model")["latency_ms"].mean().round(0))# deepseek-v3 6874.0# qwen3-32b 5226.0# qwen3-8b 2169.0Несколько сводок сразу — именованная агрегация: новое_имя=("столбец", "функция").
stats = logs.groupby("model").agg( n=("latency_ms", "size"), # строк в группе n_lat=("latency_ms", "count"), # непустых задержек mean_ms=("latency_ms", "mean"), p95_ms=("latency_ms", lambda s: s.quantile(0.95)), # 95-й перцентиль error_rate=("status", lambda s: (s != "ok").mean()), # доля True = доля ошибок)print(stats.round(2)) n n_lat mean_ms p95_ms error_ratemodeldeepseek-v3 412 401 6874.35 15721.20 0.03qwen3-32b 581 562 5225.64 13633.05 0.06qwen3-8b 1007 972 2169.18 4929.76 0.07Разберём строку qwen3-8b. Запросов 1007, но задержек только 972: size считает все строки группы, а count —
только непустые значения. 95-й перцентиль 4930 мс значит: 95% запросов с записанной задержкой быстрее 4.9 секунды. Для сервиса это
важнее среднего: пользователи помнят медленные ответы. Доля ошибок — среднее булева столбца: True считается
как 1, False как 0, поэтому среднее — это доля True.
agg сворачивает группу в одно число. Иногда нужно другое: посчитать по группе и вернуть значение каждой строке.
Это transform. Например, заполнить пропуск медианой своей модели:
model_median = logs.groupby("model")["latency_ms"].transform("median") # Series длины 2000filled = logs["latency_ms"].fillna(model_median)Группировать можно по нескольким столбцам: logs.groupby(["model", "lang"]) даёт группу на каждую пару.
merge: присоединить справочник
Заголовок раздела «merge: присоединить справочник»Цены живут в отдельной таблице — справочнике. Соединить их с логами по общему столбцу — операция
соединение таблицсоединение таблиц (merge, join)EN join / mergeСклеить строки двух таблиц по общему ключу. Типы: inner, left, right, outer. Дубликат ключа в справочнике размножает строки. (в pandas — merge, в SQL — JOIN).
prices = pd.DataFrame({ "model": ["qwen3-8b", "qwen3-32b", "deepseek-v3"], "usd_in": [0.05, 0.20, 0.30], # условные цены, долл. за 1 млн входных токенов "usd_out": [0.20, 0.80, 1.10], # ... и за 1 млн токенов ответа})m = logs.merge(prices, on="model", how="left", validate="many_to_one")m["cost_usd"] = (m["prompt_tokens"] * m["usd_in"] + m["completion_tokens"] * m["usd_out"]) / 1e6print(len(logs), len(m), round(m["cost_usd"].sum(), 3)) # 2000 2000 0.309Проверим формулу на первой строке: 364 входных и 93 выходных токена у qwen3-8b стоят долл.
Параметр how решает, что делать со строками без пары:
how | Что остаётся |
|---|---|
"inner" | только строки, ключ которых есть в обеих таблицах |
"left" | все строки левой таблицы; у кого нет пары — NaN в новых столбцах |
"right", "outer" | все строки правой; все строки обеих |
Главная ловушка merge — дубликаты ключа. Если в справочнике модель случайно записана дважды, каждая её строка
в логах присоединится дважды. Логов станет не 2000, а 3007 (у qwen3-8b было 1007 запросов), и расходы удвоятся без
единой ошибки. Защита — validate="many_to_one": «слева ключ может повторяться, справа — нет». С дубликатом merge
упадёт с MergeError: Merge keys are not unique in right dataset. Второй помощник — indicator=True: он добавит
столбец _merge со значениями both и left_only, и строки без цены сразу видны.
pivot_table: сводная таблица
Заголовок раздела «pivot_table: сводная таблица»Средняя задержка по моделям и языкам удобнее в виде таблицы «строки × столбцы» — как сводная таблица в Excel:
pv = logs.pivot_table(index="model", columns="lang", values="latency_ms", aggfunc="median")print(pv.round(0))# lang de en ru# model# deepseek-v3 5496.0 6013.0 5445.0# qwen3-32b 3829.0 3870.0 4015.0# qwen3-8b 1730.0 2017.0 1692.0print(pd.crosstab(logs["model"], logs["lang"])) # сколько запросов в каждой клеткеЭто та же группировка по паре (model, lang), только второй ключ развёрнут в столбцы. Таблица «по строке на
наблюдение» называется длинной, развёрнутая — широкой. Для графиков и groupby удобна длинная, для
чтения глазами и отчётов — широкая. Обратно из широкой в длинную переводит melt:
pv.reset_index().melt(id_vars="model", var_name="lang", value_name="median_ms").
Даты и строки
Заголовок раздела «Даты и строки»Если столбец имеет тип datetime64, его части доступны через .dt:
logs["hour"] = logs["ts"].dt.hour # 0..23logs["day"] = logs["ts"].dt.day_name() # 'Monday', ...daily = m.groupby(m["ts"].dt.date)["cost_usd"].sum() # расходы по днямdaily = m.resample("D", on="ts")["cost_usd"].sum() # то же через resampleСтроки — через .str, и это снова векторные операции над всем столбцом:
print(logs["prompt"].str.len().groupby(logs["lang"]).mean().round(1)) # длина в символах# de 43.5# en 26.4# ru 34.7print(logs["prompt"].str.encode("utf-8").str.len().groupby(logs["lang"]).mean().round(1)) # длина в байтах UTF-8# de 44.5# en 26.4# ru 61.2is_translate = logs["prompt"].str.contains("übersetze|переведи|translate", case=False) # регулярное выражениеprint(round(is_translate.mean(), 3)) # 0.338str.len() считает символы Юникода, а не байты, и две эти длины расходятся по-разному. У английских запросов
они совпадают: латинская буква в UTF-8 занимает 1 байт. У немецких байтов чуть больше, чем символов: буквы ü и ä
занимают по 2 байта. А русский запрос в байтах почти вдвое длиннее, чем в символах: каждая буква кириллицы — 2 байта
(урок 0.0.2). Ещё нагляднее: pd.Series(["привет", "hi", "🙂"]).str.len() даёт [6, 2, 1], а в UTF-8 это 12, 2 и
4 байта — один эмодзи весит как четыре латинские буквы. Для LLM важна ещё третья длина — в токенах; её вы научитесь
считать на ступени 4. Долю кириллицы в тексте даёт регулярное выражение по диапазону букв:
s.str.count(r"[а-яёА-ЯЁ]") / s.str.len() — простой способ определить язык строки, пригодится при фильтрации
корпусов (урок 5.1.2).
Пропуски
Заголовок раздела «Пропуски»Пропускпропуск (пропущенное значение)EN missing value (NaN)Клетка без значения; в pandas — NaN (для дат — NaT). Агрегаты вроде mean пропуски пропускают. — клетка без значения. В pandas это NaN (для дат — NaT).
print(logs.isna().sum()[lambda s: s > 0]) # latency_ms 65, tokens_per_sec 65print(logs["latency_ms"].mean()) # 4032.0 — среднее по 1935 непустымprint(logs["latency_ms"].fillna(0).mean()) # 3900.9 — нули занизили среднее!Агрегаты (mean, sum, median) пропуски пропускают. Что с ними делать — решение, а не техника:
dropna()— выбросить строки. Честно, если пропусков мало и они случайны.fillna(значение)— заполнить: константой, медианой, медианой своей группы черезtransform.- Оставить как есть и добавить признак «был пропуск». Часто сам факт пропуска информативен: здесь
NaNв задержке означает таймаут.
Никогда не заполняйте нулём молча: средняя задержка упала со 4032 до 3901 мс, хотя ни один запрос не ускорился.
Ещё один сюрприз — целые числа: в столбце int64 не может быть NaN, поэтому один пропуск превращает весь столбец
в float64. Если нужны целые с пропусками, есть тип "Int64" с большой буквы.
Типы данных и память
Заголовок раздела «Типы данных и память»base = make_logs() # исходные 9 столбцов — без тех, что мы добавили вышеprint(round(base.memory_usage(deep=True).sum() / 1024), "КБ") # 794 КБcols = ["user_id", "model", "lang", "prompt", "status"]small = base.astype({c: "category" for c in cols})print(round(small.memory_usage(deep=True).sum() / 1024), "КБ") # 77 КБТип category хранит каждое повторяющееся значение один раз в словаре, а в самом столбце — маленькие целые коды:
у model три значения, и каждая клетка занимает 1 байт (int8) вместо целой строки. На 200 тысячах строк та же
таблица занимает 86 МБ, а с категориями — 7 МБ. (Если установлен pyarrow, pandas хранит строки компактнее, около
26 МБ, но категории всё равно выигрывают.) Правило: если различных значений мало по сравнению с числом строк — это
кандидат в category. Числа можно ужать через pd.to_numeric(s, downcast="integer"). Цифры получены на синтетике
этого урока и зависят от версии pandas.
Цепочки методов
Заголовок раздела «Цепочки методов»Каждый метод pandas возвращает новую таблицу, поэтому шаги можно выстроить в цепочкуцепочка методовEN method chainingЗапись преобразований таблицы подряд через точку: df.query(...).assign(...).groupby(...).agg(...). и читать её сверху вниз как рецепт:
report = ( logs .query("status == 'ok'") # только успешные запросы .merge(prices, on="model", how="left", validate="many_to_one") .assign(cost_usd=lambda d: (d["prompt_tokens"] * d["usd_in"] + d["completion_tokens"] * d["usd_out"]) / 1e6) .groupby("lang") .agg(requests=("cost_usd", "size"), cost_usd=("cost_usd", "sum"), p50_ms=("latency_ms", "median")) .sort_values("cost_usd", ascending=False))print(report.round(3))# requests cost_usd p50_ms# lang# ru 963 0.153 2796.40# de 550 0.083 2657.25# en 363 0.057 2995.00lambda d: внутри assign получает таблицу на текущем шаге цепочки (функции-«однострочники» — урок 0.2.2). Цепочка
не плодит промежуточных переменных, и каждый шаг легко закомментировать при отладке. Если цепочка выросла длиннее
10–12 шагов, разбейте её на функции с понятными именами — как в задании ниже.
Практика
Заголовок раздела «Практика»Шесть функций на маленьком наборе логов за три дня: чтение CSV с датами, новые столбцы без порчи исходной таблицы,
сводка по моделям, merge с защитой от дубликатов, pivot и заполнение пропусков по группе. Тесты сравнивают ваши
таблицы с ожидаемыми через pandas.testing.assert_frame_equal: совпасть должны значения, порядок столбцов и
подписи индекса. Решайте сверху вниз и нажимайте «Запустить» после каждой функции.
import io
import pandas as pd
# Логи LLM-сервиса за три дня (данные выдуманы). Пустое значение latency_ms — пропуск:
# у запросов со статусом timeout задержку не записали.
LOGS_CSV = """ts,user_id,model,lang,prompt_tokens,completion_tokens,latency_ms,status
2026-09-07 09:15:00,u01,qwen3-8b,ru,120,80,900.0,ok
2026-09-07 10:02:00,u02,deepseek-v3,zh,300,200,7200.0,ok
2026-09-07 11:30:00,u01,qwen3-32b,en,50,40,1500.0,ok
2026-09-07 18:45:00,u03,qwen3-8b,zh,200,100,,timeout
2026-09-08 08:10:00,u02,qwen3-8b,ru,80,60,700.0,ok
2026-09-08 12:00:00,u04,deepseek-v3,ru,500,300,9900.0,error
2026-09-08 13:20:00,u03,qwen3-32b,zh,150,120,3100.0,ok
2026-09-08 22:05:00,u01,qwen3-8b,en,60,50,600.0,ok
2026-09-09 07:40:00,u04,qwen3-32b,ru,400,250,5500.0,ok
2026-09-09 09:00:00,u02,deepseek-v3,en,100,90,3300.0,ok
2026-09-09 15:30:00,u03,qwen3-8b,ru,90,70,,timeout
2026-09-09 21:15:00,u01,qwen3-32b,zh,220,180,4300.0,ok
"""
# Условные цены: долларов за 1 млн входных токенов (usd_in) и токенов ответа (usd_out)
PRICES = pd.DataFrame({
"model": ["qwen3-8b", "qwen3-32b", "deepseek-v3"],
"usd_in": [0.05, 0.20, 0.30],
"usd_out": [0.20, 0.80, 1.10],
})
def parse_logs(csv_text):
"""Прочитать CSV из строки в DataFrame; столбец ts должен стать датой-временем (datetime64).
Подсказка: pd.read_csv умеет читать «файлоподобный» объект io.StringIO(csv_text).
"""
# TODO: ваш код
...
def add_features(df):
"""Вернуть НОВУЮ таблицу (исходную не менять!) с тремя новыми столбцами:
total_tokens — prompt_tokens + completion_tokens;
hour — час запроса из ts (0..23);
is_ok — True, если status == "ok".
"""
# TODO: ваш код
...
def model_summary(df):
"""Сводка по моделям: индекс — model (по алфавиту), столбцы ровно в таком порядке:
requests — число запросов (строк) модели;
mean_latency — средняя latency_ms (пропуски не учитываются);
error_rate — доля запросов со статусом не "ok".
"""
# TODO: ваш код
...
def add_cost(df, prices):
"""Присоединить цены к логам по столбцу model и посчитать стоимость запроса в долларах:
cost_usd = (prompt_tokens * usd_in + completion_tokens * usd_out) / 1_000_000
Число и порядок строк логов должны сохраниться. Если в prices модель встречается дважды,
функция должна упасть с pd.errors.MergeError (подсказка: параметр validate у merge).
"""
# TODO: ваш код
...
def latency_pivot(df):
"""Сводная таблица: строки — model, столбцы — lang, в клетках — средняя latency_ms."""
# TODO: ваш код
...
def fill_latency(df):
"""Вернуть копию df, где пропуски latency_ms заполнены МЕДИАНОЙ latency_ms той же модели."""
# TODO: ваш код
...
# «Запустить»: смотрим на результаты по шагам
pd.set_option("display.width", 120)
pd.set_option("display.max_columns", 12)
logs = parse_logs(LOGS_CSV)
if logs is None:
print("Начните с parse_logs — пока она ничего не возвращает.")
else:
print(logs.dtypes, "\n")
for f in (add_features, model_summary, latency_pivot, fill_latency):
out = f(logs)
if out is not None:
print(f"── {f.__name__}\n{out}\n")
cost = add_cost(logs, PRICES)
if cost is not None:
print("── add_cost: суммарно", round(cost["cost_usd"].sum(), 6), "долл.")
Проверь себя
Заголовок раздела «Проверь себя»- Series — массив с индексом, арифметика выравнивается по меткам. DataFrame — словарь столбцов с общим индексом.
Новую таблицу начинайте с
shape,info,head,describe,value_counts. - CSV теряет типы (нужен
parse_dates), JSONL — формат датасетов LLM (lines=True), Parquet — колоночный, сжатый и быстрый. - Строки выбирают
loc(метки) иiloc(позиции) и маски с&,|,~в скобках. Изменять — одной операциейdf.loc[маска, столбец] = …, новую таблицу — черезassign. Никаких циклов по строкам. groupby().agg()сворачивает группы,transformвозвращает значение каждой строке;mergeсvalidate="many_to_one"не размножит строки молча;pivot_tableразворачивает второй ключ в столбцы.- Пропуски агрегаты пропускают, заполнять их — осознанное решение.
categoryи Parquet экономят память и диск.
Дальше: разведочный анализ — как смотреть на данные до модели: распределения, выбросы, корреляции, утечки, графики в matplotlib и первые признаки.