Pandas с нуля: DataFrame, Series и чтение CSV
Первый практический урок по pandas: как читать CSV, понимать DataFrame и Series, фильтровать строки, выбирать столбцы и проверять пропуски.
Содержание статьи
Pandas нужен не для того, чтобы заменить SQL любой ценой. Он удобен, когда нужно быстро взять файл или результат запроса, проверить структуру, почистить данные и собрать небольшой воспроизводимый анализ рядом с графиком. Начнём с главной модели: DataFrame — таблица, Series — один её столбец.
Коротко
Хороший первый сценарий в pandas выглядит так: прочитать данные, проверить форму и типы, выбрать нужные строки и столбцы, обработать пропуски, получить сводку и сохранить результат. Каждое действие лучше делать явно, чтобы через неделю можно было восстановить, откуда взялась цифра.
DataFrameхранит строки и столбцы,Series— одну именованную колонку.head,shape,infoиdescribe— обязательная проверка перед расчётами.- Фильтр должен отвечать на вопрос аналитика, а не просто оставлять «интересные» строки.
- Пропуск, пустая строка и ноль — разные состояния.
DataFrame и Series на одной картинке
Возьмём маленькую таблицу заказов. В ней одна строка — один заказ, user_id — покупатель, channel — канал, revenue — сумма заказа. Когда пишем orders["revenue"], получаем Series. Когда выбираем несколько колонок, остаёмся в DataFrame.
| Объект | Что хранит | Пример |
|---|---|---|
| DataFrame | таблицу со строками и колонками | orders |
| Series | одну колонку с индексом | orders["revenue"] |
| Index | метки строк | orders.index |
| dtype | тип данных колонки | orders["revenue"].dtype |
import pandas as pd
orders = pd.DataFrame({
'order_id': [101, 102, 103, 104],
'user_id': [1, 2, 1, 3],
'channel': ['organic', 'paid', 'organic', 'email'],
'revenue': [1200, 800, 450, 1500],
})
revenue = orders['revenue'] # Series
order_slice = orders[['channel', 'revenue']] # DataFrameПрочитать CSV и сразу проверить данные
read_csv загружает файл, но не сообщает, правильно ли вы поняли его структуру. Поэтому чтение всегда заканчивайте коротким профилем: количество строк и колонок, типы, пример строк, пропуски и диапазон дат. Это занимает минуту и ловит ошибки разделителя, кодировки и типов.
orders = pd.read_csv(
'data/orders.csv',
parse_dates=['created_at'],
)
print(orders.shape)
print(orders.dtypes)
display(orders.head())
display(orders.isna().mean().sort_values(ascending=False).head())CSV может быть разделён запятой, точкой с запятой или табуляцией. Если таблица выглядит как один широкий столбец, сначала проверьте sep, encoding и кавычки, а не начинайте чистить строки вручную.
Выбрать строки и колонки
Для понятного анализа разделяйте выборку на два шага: какие строки относятся к вопросу и какие поля нужны для ответа. loc работает с условиями и именами колонок, query иногда делает длинный фильтр читабельнее. Не меняйте исходный DataFrame без явного присваивания или copy() — так меньше неожиданных предупреждений.
paid_orders = orders.loc[
(orders['status'] == 'paid')
& (orders['created_at'] >= '2026-07-01')
& (orders['created_at'] < '2026-08-01'),
['order_id', 'user_id', 'channel', 'revenue'],
].copy()
paid_orders = paid_orders.sort_values('revenue', ascending=False)- Для нескольких условий используйте круглые скобки вокруг каждого сравнения.
- В pandas нужен
&для «и» и|для «или», а не обычныеandиor. - Правая граница периода
< next_startне включает следующий месяц.
Пропуски: сначала смысл, потом fillna
Пропуск в channel может означать неизвестный источник, а пропуск в discount — отсутствие скидки. В первом случае нужна явная категория unknown, во втором ноль может быть корректен. Универсальная замена всех пропусков нулём делает таблицу аккуратной только на вид.
orders['channel'] = orders['channel'].fillna('unknown')
orders['discount'] = orders['discount'].fillna(0)
missing_revenue = orders['revenue'].isna().sum()
if missing_revenue > 0:
raise ValueError('Revenue is missing: check source data')Мини-профиль перед первой метрикой
До группировки проверьте, что дата действительно дата, сумма — число, а идентификатор заказа уникален там, где это ожидается. Если одна строка — позиция, нельзя считать количество строк количеством заказов. Python не знает смысл бизнес-полей без вашей проверки.
orders.shape— размер таблицы.orders.nunique()— количество уникальных значений по колонкам.orders.duplicated("order_id").sum()— контроль повторных заказов.orders["created_at"].min()и.max()— фактическое окно дат.orders.describe(include="all")— быстрый обзор распределений.
Индексы, копии и предупреждения pandas
Индекс pandas — это не обязательно идентификатор строки из базы. Он помогает выровнять Series и DataFrame по меткам, но после фильтра может сохранять старые номера. Для экспорта или сравнения иногда нужен reset_index(drop=True), а для присоединения по бизнес-ключу — явный merge. Не полагайтесь на индекс как на order_id, если вы не создавали такой контракт.
После фильтра используйте .copy(), когда собираете независимый слой. Это делает намерение явным и уменьшает риск SettingWithCopyWarning. Предупреждение нельзя просто отключить: сначала решите, хотите ли изменить исходную таблицу или создать новую. Большинство непонятных результатов в учебных ноутбуках начинается именно с неявного изменения среза.
Проверяйте результат присваивания на двух-трёх строках. После нормализации канала выведите исходное и новое значение, после создания даты — тип и границы. Маленькая визуальная проверка быстрее, чем искать ошибку после десяти последующих операций.
paid_orders = orders.loc[orders['status'].eq('paid')].copy()
paid_orders['revenue'] = pd.to_numeric(
paid_orders['revenue'], errors='coerce'
)
print(paid_orders.index[:3].tolist())
paid_orders = paid_orders.reset_index(drop=True)loc меняет выбранный объект, а индекс выравнивает значения по меткам
Вернёмся к четырём заказам 101–104. После фильтра revenue >= 1000 останутся строки с исходными метками 0 и 3. Вызов iloc[1] выберет вторую оставшуюся строку, то есть заказ 104. Вызов loc[1] будет искать метку 1, которой в этом срезе нет. Разница особенно заметна после сортировки: позиция строки меняется, а метка сохраняется. Для просмотра по порядку используйте iloc, для изменения по условию или известному ключу — loc.
Присваиваемая Series тоже имеет метки. Если срезу с индексом [0, 3] присвоить pd.Series([10, 20]), pandas совместит метку 0 с 0, а для метки 3 не найдёт значения. Вместо ожидаемых двух скидок появится пропуск. Создайте Series с индексом выбранных заказов или вычислите скидку непосредственно из их revenue. Список и массив присваиваются по позиции: это допустимо только тогда, когда их порядок действительно совпадает с порядком строк.
Индекс по order_id удобен для адресной проверки заказа. Создайте его через set_index("order_id", verify_integrity=True): повторный ключ остановит операцию. Тогда loc[104] обращается к заказу, а не к четвёртой строке. Если таблица хранит несколько позиций заказа, такой индекс уже не уникален и одна метка может выбрать несколько строк. Для соединения этих сущностей понятнее явно указать бизнес-ключи и ожидаемую кратность merge.
Упоминание SettingWithCopyWarning выше относится к прежнему поведению pandas. В pandas 3.0 Copy-on-Write стал единственным режимом: изменение полученного среза не должно менять родительскую таблицу. Цепочка orders[mask]["revenue"] = 0 не является способом обновить orders. Если цель — исходная таблица, запишите orders.loc[mask, "revenue"] = 0 одним присваиванием. Если цель — самостоятельная выборка, работайте с ней явно; copy остаётся понятным указанием намерения и совместимым приёмом для старого кода.
Проверяйте именно тот объект, который должен измениться. В примере скидки добавляются только к large, а суммы orders остаются прежними. Затем одна запись через loc намеренно меняет исходный заказ 104. Проверка обоих состояний помогает различить ошибку выравнивания и ошибку адресата присваивания. Исчезнувшее предупреждение само по себе не доказывает, что нужная таблица получила новые значения.
import pandas as pd
orders = pd.DataFrame({'order_id': [101, 102, 103, 104],
'revenue': [1200, 800, 450, 1500]})
large = orders.loc[orders['revenue'].ge(1000)].copy()
assert large.index.tolist() == [0, 3]
assert large.iloc[1]['order_id'] == 104
large['discount'] = pd.Series([10, 20], index=large.index)
assert large['discount'].tolist() == [10, 20]
assert orders['revenue'].tolist() == [1200, 800, 450, 1500]
orders.loc[orders['order_id'].eq(104), 'revenue'] = 0
by_order = orders.set_index('order_id', verify_integrity=True)
assert by_order.loc[104, 'revenue'] == 0Фильтр должен быть переводом вопроса в код
Перед loc сформулируйте выборку словами: «оплаченные заказы в полном июле, кроме тестовых аккаунтов». Затем превратите каждую часть в отдельное условие. Такой код легче проверить и изменить, чем одна строка с несколькими вложенными скобками. Имена is_paid, is_in_period, is_real_user показывают бизнес-логику прямо в ноутбуке.
Границы периода задавай полуинтервалом: больше или равно началу и меньше следующего начала. Это работает для timestamp и не зависит от того, сколько секунд в последнем дне месяца. Если написать <= 31 июля 23:59:59, события с миллисекундами можно потерять.
После фильтра сравните размер базы и долю каждой причины исключения. Если добавление условия уменьшило таблицу в десять раз, это может быть реальный сигнал, а может быть неправильный статус или часовой пояс. Аналитик должен увидеть изменение до первой агрегации.
| Проверка | Что она показывает |
|---|---|
| len(orders) | размер исходной базы |
| is_paid.mean() | доля оплаченных строк |
| is_in_period.mean() | доля строк в окне дат |
| is_real_user.mean() | доля после исключения тестовых аккаунтов |
| len(filtered) | итоговый размер выборки |
Чтение CSV: кодировка и разделитель — часть контракта
Если CSV открылся одной широкой колонкой, проблема обычно в sep, а не в pandas. В русскоязычных выгрузках встречается точка с запятой, десятичная запятая и разные кодировки. Перед расчётами проверьте несколько сырых строк и явно укажите параметры чтения. Не исправляйте разделитель заменой символов внутри строк: так можно повредить текстовые поля.
Денежное поле иногда приходит как "1 234,50". Сначала зафиксируйте локаль и очистите пробелы, затем приведите значение к числу. Если в одном файле смешаны форматы, не скрывайте ошибку через errors=coerce: посчитайте число непарсящихся строк и покажите их владельцу источника.
Для повторных загрузок сохраняйте список колонок и типы в одном месте. Тогда новый столбец не сломает код, а исчезнувший обязательный столбец вызовет понятную ошибку до расчёта. Это первый маленький шаг от ноутбука к воспроизводимому pipeline.
orders = pd.read_csv(
'data/orders.csv',
sep=';',
encoding='utf-8-sig',
usecols=['order_id', 'status', 'created_at', 'revenue'],
)
orders['revenue'] = (
orders['revenue'].astype('string')
.str.replace(' ', '', regex=False)
.str.replace(',', '.', regex=False)
.pipe(pd.to_numeric, errors='coerce')
)dtype сохраняет ведущие нули до первой очистки
У заказов из учебной таблицы короткие числовые ключи, но выгрузка может прислать order_id как 00101. Если read_csv распознает это поле числом, получится 101, а последующее astype("string") не вернёт потерянные нули. Задайте строковый dtype прямо при чтении. Это относится и к user_id, если источник определяет его как код, а не величину для арифметики. Одинаково выглядящие ключи должны иметь согласованный тип ещё до первого merge.
В примере ниже разделитель — точка с запятой, дробная часть отделена запятой, а тысячи — обычным пробелом. Параметры decimal и thousands позволяют распознать 1 234,50 числом без ручной замены символов. Это работает для заранее известного формата. Неразрывный пробел или смесь разных локалей потребуют другого правила и отдельного контроля ошибок; нельзя считать любой пробел равнозначным только потому, что строки визуально похожи.
Строковое поле тоже может неожиданно стать пропуском. По умолчанию read_csv распознаёт ряд текстовых маркеров отсутствия, включая NA. Если NA — настоящий код канала, настрой keep_default_na=False и укажите допустимые маркеры через na_values, при необходимости отдельно по колонкам. В учебном CSV пустая строка считается пропуском, а NA сохраняется как текст. Это осознанный контракт конкретного источника, а не настройка для всех файлов компании.
parse_dates просит reader разобрать дату, но после чтения всё равно проверьте dtype. Нераспознанные значения могут оставить колонку строковой. Для регулярного формата удобно указать date_format, а затем проверить, что все обязательные даты присутствуют. В примере календарная дата не содержит часового пояса: не объявляйте её UTC без договорённости об источнике. Если нужны моменты операций, формат timestamp и зона становятся отдельной частью контракта.
Кодировка решает, как байты превращаются в символы, и не определяет ни тип ключа, ни локаль суммы. utf-8-sig полезна для UTF-8 с BOM, но не исправляет файл в Windows-1251. При неверных буквах выясни кодировку у отправителя; режим игнорирования ошибок способен удалить часть идентификатора. После чтения сверяйте контрольный заказ по ключу, его сумму и дату с исходной строкой. Такой пример проверяет смысл параметров лучше, чем отсутствие исключения при загрузке.
from io import StringIO
import pandas as pd
raw = StringIO('order_id;channel;created_at;revenue\n00101;NA;2026-07-01;1 234,50\n')
orders = pd.read_csv(
raw, sep=';', dtype={'order_id': 'string', 'channel': 'string'},
parse_dates=['created_at'], date_format='%Y-%m-%d',
thousands=' ', decimal=',', keep_default_na=False, na_values=[''],
)
assert orders.loc[0, 'order_id'] == '00101'
assert orders.loc[0, 'channel'] == 'NA'
assert orders.loc[0, 'revenue'] == 1234.5
assert pd.api.types.is_datetime64_any_dtype(orders['created_at'])Практика: от первой таблицы к проверенному срезу
Возьмите CSV с заказами и пройдите короткий цикл: осмотрите первые строки, проверьте форму и типы, опишите зерно, выберите период, обработайте пропуски и посчитайте три контрольных показателя. Для старта подойдут количество заказов, уникальные покупатели и общая выручка. Сохраните их до и после очистки, чтобы видеть цену каждого решения.
Затем измените один параметр намеренно: подставьте другой месяц или уберите фильтр статуса. Хороший ноутбук должен показать предсказуемое изменение, а не требовать ручной перестановки ячеек. Если результат меняется неожиданно, вернитесь к индексу, типам и границам дат.
После этого переходите к groupby и merge. У вас уже будет таблица с понятным контрактом, а не случайный набор строк. Так основы pandas связываются с реальной работой аналитика: сначала понять данные, потом считать.
- Сначала проверить разделитель, кодировку и заголовок.
- Записать, что означает одна строка.
- Проверить типы, пропуски, дубли и даты.
- Собрать фильтр из именованных условий.
- Сохранить контрольные числа до первой метрики.
Материалы по теме

DuckDB для аналитика: SQL по CSV и Parquet без сервера
Что такое DuckDB и как выполнять SQL по CSV, Parquet и pandas DataFrame без сервера: примеры в Python, задача от выгрузки до ответа, отличия от PostgreSQL и ограничения.

Как тестировать аналитические расчёты на Python и pandas
Практический гайд по тестам для аналитика: проверить метрики на маленьком датасете, поймать регрессию и защитить расчёт от тихих изменений.

Как ускорить pandas: память, типы и обработка больших файлов
Что делать, если pandas медленно работает или не помещает файл в память: категории, downcast, chunksize, Parquet и контроль размера данных.