Перейти к содержимому
Ступень 22.4 Практический ML на табличных данных

pandas: таблицы как у профессионалов

Series и DataFrame, чтение CSV, JSONL и Parquet, фильтры, groupby, merge и pivot, даты, строки на разных языках, пропуски и память — на сквозном датасете логов LLM-сервиса.

УРОКstage-2/2-4-tabular-practice/01-pandas.mdx2 чУрок 1 из 4
После урока вы сможете
  • Загружать таблицы из CSV, JSONL и Parquet и за минуту понимать, что в них лежит (shape, dtypes, describe, value_counts)
  • Выбирать строки и столбцы через loc, iloc и булевы маски и добавлять новые столбцы без циклов и без ловушки chained assignment
  • Считать сводки через groupby-agg, присоединять справочники через merge с проверкой ключей и строить pivot-таблицы
  • Работать с датами, строками (символы против байтов UTF-8), пропусками и типами данных, чтобы таблица помещалась в память

До сих пор вы держали данные в списках, словарях и массивах 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.

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 нет r4

NaN («not a number») — так pandas обозначает пропуск. Выравнивание защищает от тихой ошибки «сложили разные запросы, потому что строки стояли в разном порядке». Но если вы ждали сложения по позициям, оно удивляет. Тогда сравнивайте .to_numpy() — голые массивы без индекса.

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.0
print(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.

Возьмём синтетические логи: 2000 запросов за неделю к трём моделям на трёх языках. Модели, цены и числа выдуманы, поэтому вопроса лицензий нет, зато устройство как у настоящих логов: время, пользователь, модель, язык, текст запроса, токены, задержка и статус. Как генерируются данные, можно не вникать: важен результат.

import numpy as np
import 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 str
print(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 412
print(logs["status"].value_counts(normalize=True)) # ok 0.938, timeout 0.032, error 0.030 — доли

В выводе describe у latency_ms среднее 4032 мс, медиана (строка 50%) 2802 мс, а максимум 51 208 мс. Среднее заметно больше медианы — признак «тяжёлого хвоста»: редкие очень долгие запросы тянут среднее вверх. Как такие распределения смотреть и чинить, разберём в следующем уроке.

ФорматЧто этоКогда брать
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 pyarrow
p = 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); цифры с одной машины, на вашей будут другими, но соотношение похожее):

CSVJSONLParquet
Размер файла20.0 МБ41.9 МБ3.0 МБ
Чтение всей таблицы0.37 с—0.05 с
Чтение двух столбцов——0.01 с

Parquet хранит данные по столбцам: значения одного типа лежат рядом и хорошо сжимаются, а ненужные столбцы не читаются вообще. Типы (даты, целые, строки) сохраняются. Поэтому большие корпуса для обучения LLM на Hugging Face обычно лежат именно в Parquet.

logs["latency_ms"] # один столбец → Series
logs[["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)) # 259
print(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)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
Split-apply-combine: groupby('model')['latency_ms'].mean()
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_rate
model
deepseek-v3 412 401 6874.35 15721.20 0.03
qwen3-32b 581 562 5225.64 13633.05 0.06
qwen3-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 длины 2000
filled = logs["latency_ms"].fillna(model_median)

Группировать можно по нескольким столбцам: logs.groupby(["model", "lang"]) даёт группу на каждую пару.

Цены живут в отдельной таблице — справочнике. Соединить их с логами по общему столбцу — операция соединение таблицсоединение таблиц (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"]) / 1e6
print(len(logs), len(m), round(m["cost_usd"].sum(), 3)) # 2000 2000 0.309

Проверим формулу на первой строке: 364 входных и 93 выходных токена у qwen3-8b стоят (364⋅0.05+93⋅0.20)/106=(18.2+18.6)/106≈3.7⋅10−5(364 \cdot 0.05 + 93 \cdot 0.20) / 10^6 = (18.2 + 18.6) / 10^6 \approx 3.7 \cdot 10^{-5} долл.

Параметр 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, и строки без цены сразу видны.

Средняя задержка по моделям и языкам удобнее в виде таблицы «строки × столбцы» — как сводная таблица в 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.0
print(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..23
logs["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.7
print(logs["prompt"].str.encode("utf-8").str.len().groupby(logs["lang"]).mean().round(1)) # длина в байтах UTF-8
# de 44.5
# en 26.4
# ru 61.2
is_translate = logs["prompt"].str.contains("übersetze|переведи|translate", case=False) # регулярное выражение
print(round(is_translate.mean(), 3)) # 0.338

str.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 65
print(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.00

lambda d: внутри assign получает таблицу на текущем шаге цепочки (функции-«однострочники» — урок 0.2.2). Цепочка не плодит промежуточных переменных, и каждый шаг легко закомментировать при отладке. Если цепочка выросла длиннее 10–12 шагов, разбейте её на функции с понятными именами — как в задании ниже.

Шесть функций на маленьком наборе логов за три дня: чтение CSV с датами, новые столбцы без порчи исходной таблицы, сводка по моделям, merge с защитой от дубликатов, pivot и заполнение пропусков по группе. Тесты сравнивают ваши таблицы с ожидаемыми через pandas.testing.assert_frame_equal: совпасть должны значения, порядок столбцов и подписи индекса. Решайте сверху вниз и нажимайте «Запустить» после каждой функции.

pandas: сводки по логам LLM-сервисаpandas · нужен интернет7 тестов
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), "долл.")
Проверь себя6 вопросов
1 Что выведет (pd.Series([1, 2], index=["a", "b"]) + pd.Series([10, 20], index=["b", "c"])).tolist()?
2 В таблице строки с индексом 0, 1, 2, …, 9. Сколько строк вернёт df.loc[2:5]?
3 Какие способы обнулят latency_ms у таймаутов в самой таблице df?Можно выбрать несколько ответов
4 В логах 1000 строк: 600 запросов к модели A и 400 к модели B. В справочнике цен модель A по ошибке записана дважды. Сколько строк вернёт logs.merge(prices, on="model", how="left") без validate?
5 В группе 10 строк, у 3 из них latency_ms пустой. Что вернут size и count для этой группы?
6 Почему вместо for _, row in df.iterrows() лучше писать df["a"] + df["b"]?
  • 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 и первые признаки.