Learning Platform
Урок 09.01 · 22 мин
Начальный
pandasDataFrameSeriesdtypesPyArrow backendread_csv
Колоночное хранение vs построчное: как pandas использует memory layout Числовые типы SQL: INT, BIGINT, NUMERIC и деньги не как FLOAT

Зачем pandas в карьере Junior DE

Каждый день инженер данных получает табличку. CSV из выгрузки, JSON Lines из лога, ответ SQL-запроса, Parquet из data lake. Затем эту табличку нужно посмотреть глазами, отфильтровать, склеить с другой, посчитать агрегат, выгрузить дальше. Можно делать это голым Python — open, csv.reader, циклы, словари — но на 200 тысячах строк это занимает минуты вместо миллисекунд. И в код заглядывать стыдно: вместо одной строки df.groupby("user_id")["amount"].sum() будет двадцать строк с defaultdict.

pandas
— это рабочая лошадка Data Engineer. Не модный polars, не cluster-mode Spark — а старый добрый pandas, который установлен на каждой машине каждого DE на планете. В этом модуле мы научимся ему так, чтобы не бояться 500-мегабайтных CSV, делать groupby уверенно и понимать, где у pandas границы.

Курс не превратит вас в эксперта по pandas — для этого нужны месяцы практики. Но после четырёх уроков вы спокойно прочитаете чужой код с pandas и напишете свой ETL-скрипт, который не упадёт на проде.

DataFrame и Series — что есть что

В pandas две главные структуры.

Series
— это одномерная колонка с именованным индексом.
DataFrame
— это таблица, собранная из нескольких Series, привязанных к общему индексу строк.

import pandas as pd

# Series — одномерная штука
prices = pd.Series([100, 250, 99], index=["a", "b", "c"], name="price")
print(prices)
# a    100
# b    250
# c     99
# Name: price, dtype: int64

# DataFrame — таблица из Series
df = pd.DataFrame({
    "product": ["apple", "banana", "cherry"],
    "price":   [100, 250, 99],
    "in_stock": [True, False, True],
})
print(df)
#    product  price  in_stock
# 0    apple    100      True
# 1   banana    250     False
# 2   cherry     99      True

У DataFrame две оси:

index
(строки) и columns (колонки). Каждая колонка — отдельная Series, у которой свой
dtype
.

Анатомия DataFrame

DataFrame = index + набор Series-колонок. Каждая Series со своим dtype.

indexметки строк
0
1
2
productdtype=object
apple
banana
cherry
pricedtype=int64
100
250
99
in_stockdtype=bool
True
False
True

Почему это важно. На голом Python таблица — это list of dicts: [{"product": "apple", "price": 100}, ...]. Чтобы посчитать сумму цен, вы пишете цикл. В pandas цена — это df["price"], Series типа int64, и сумма — df["price"].sum(). Под капотом это

vectorized operation
: pandas обходит NumPy-массив в C-цикле, не выходя обратно в Python.

Первый просмотр данных

Допустим, вам прислали CSV transactions.csv. Стандартный набор команд для знакомства:

import pandas as pd

df = pd.read_csv("transactions.csv")

df.shape       # (число_строк, число_колонок)
df.head()      # первые 5 строк (head(10) — первые 10)
df.tail(3)     # последние 3 строки
df.columns     # имена колонок
df.dtypes      # тип каждой колонки
df.info()      # размер в памяти, типы, non-null counts
df.describe()  # min/max/mean/std/quartiles для числовых колонок

Это первое, что вы делаете с любым датасетом. Не идите в groupby, пока не выяснили: сколько строк, какие типы, есть ли пропуски, разумные ли значения. Тридцать секунд на info() и describe() спасают часы отладки.

df.info()
# <class 'pandas.core.frame.DataFrame'>
# RangeIndex: 1000 entries, 0 to 999
# Data columns (total 4 columns):
#  #   Column        Non-Null Count  Dtype
# ---  ------        --------------  -----
#  0   transaction_id  1000 non-null    object
#  1   amount          997 non-null     float64
#  2   currency        1000 non-null    object
#  3   created_at      1000 non-null    object
# dtypes: float64(1), object(3)
# memory usage: 31.4+ KB

Здесь сразу видно две проблемы. Первая: created_at — это object, то есть строка, хотя там даты. Вторая: amount имеет 997 non-null из 1000 — три пропуска. Обе проблемы хорошо решать на этапе чтения файла, а не после.

Чтение CSV без боли

pd.read_csv — это монстр-функция с пятьюдесятью параметрами. Junior DE использует ровно эти:

df = pd.read_csv(
    "transactions.csv",
    dtype={
        "transaction_id": "string",
        "currency": "category",
    },
    parse_dates=["created_at"],
    na_values=["", "-", "N/A", "NULL"],
    encoding="utf-8",
)

Что делает каждый параметр:

  • dtype — задаёт типы колонок явно. Без этого pandas угадывает по содержимому, и догадка бывает неточной.
    category
    для колонок с малым числом уникальных значений (валюты, статусы, страны) экономит память в десятки раз.
  • parse_dates — список колонок, которые pandas сразу превратит в datetime64. Без этого даты остаются строкой, и вы не сможете делать dt.year или сравнения с датой.
  • na_values — что считать пропуском. По умолчанию pandas понимает пустую строку и NaN, но реальные CSV содержат -, N/A, NULL, unknown, n/a, прочерки в разных регистрах. Лучше перечислить явно.
  • encoding — кодировка файла. По умолчанию utf-8, но если файл из Excel — обычно cp1251 (Windows-1251) или cp1252. См. урок 02 модуля 3 про кодировки.

После такого чтения dtypes уже правильные:

df.dtypes
# transaction_id        string[python]
# amount                       float64
# currency              category
# created_at        datetime64[ns]

DE-кейс: грязные числа

Реальный кейс: вам прислали выгрузку из старой ERP-системы, и в колонке amount встречается такое:

amount
1234.56
2 500.00
1 000,75
-
N/A
12345

То есть числа с пробелами как разделителями тысяч (русский формат), запятая вместо точки, прочерк вместо null. По умолчанию pandas прочитает всю колонку как object (строки) — потому что цифры с пробелами не парсятся в float.

Что делать. Вариант 1 — препроцессинг строк, потом to_numeric:

df = pd.read_csv("erp_export.csv", na_values=["-", "N/A"])

# чистим строку, потом конвертируем
df["amount"] = (
    df["amount"]
    .str.replace(" ", "", regex=False)     # убираем пробелы тысяч
    .str.replace(",", ".", regex=False)    # запятую → точку
    .pipe(pd.to_numeric, errors="coerce")  # парсим в float, мусор → NaN
)

errors="coerce"
— режим «прости что простишь»: то, что не парсится, превращается в NaN. После этого можно посмотреть df["amount"].isna().sum() — сколько строк потеряли, и решить, выкинуть их или попытаться спасти.

Типы dtypes — какой когда

dtypeЧто хранитКогда брать
int64целые числа без пропусковсчётчики, ID, без NaN
Int64 (с большой)целые с поддержкой pd.NAсчётчики, где бывают пропуски
float64вещественные с NaNсуммы, дроби, метрики
boolTrue/False без пропусковфлаги, без NaN
booleanTrue/False с pd.NAфлаги, где может быть unknown
objectPython-объекты (обычно строки)по умолчанию для текста; медленно
stringстроки nullableпредпочтительнее object для текста
categoryстроки с малым cardinalityстраны, валюты, статусы
datetime64[ns]datetimeдаты-времена
timedelta64[ns]разница временидлительности

Главное правило:

избегайте object
, когда это возможно. Колонка строк? Делайте dtype="string" или dtype="category". Колонка чисел? Принудительно astype("Int64") или pd.to_numeric. Object — это медленные Python-объекты в Python-цикле, в pandas от такого нет ускорения.

PyArrow backend — производительность 2.x

С версии 2.0 pandas умеет хранить данные не только в

NumPy
, но и в
Apache Arrow
. Это включается флагом dtype_backend="pyarrow":

df = pd.read_csv("data.csv", dtype_backend="pyarrow")
df.dtypes
# transaction_id    string[pyarrow]
# amount            double[pyarrow]
# created_at        timestamp[ns][pyarrow]

Зачем. Три причины:

  1. Скорость. Особенно для строк и категориальных колонок — arrow быстрее NumPy в 2-10 раз.
  2. Память. Arrow эффективнее упаковывает строки и nullable-числа.
  3. Nullable everywhere. В NumPy-backend int64 не поддерживает NaN — нужно использовать Int64 отдельно. В Arrow-backend все типы nullable из коробки. Колонка целых с пропусками — обычное дело, ничего особенного.
import numpy as np

# Старый стиль: смесь
df["count"]    # int64, без NaN
df["amount"]   # float64, с NaN

# Новый стиль с PyArrow
df["count"]    # int64[pyarrow], с pd.NA
df["amount"]   # double[pyarrow], с pd.NA

В новых проектах берите PyArrow backend сразу. В легаси-проектах оставьте классику — поведение чуть отличается, и менять backend в готовом коде опасно.

pd.NA vs np.nan

Маленький, но важный момент. В NumPy null — это np.nan (float). Поэтому колонка целых не может иметь пропусков — np.nan это float, а в int64 он не помещается. Старая pandas решала это превращением колонки в float (int64float64), а недостающее значение становилось NaN.

В pandas 2.x появился

pd.NA
— настоящий null, работающий одинаково для int, float, bool, string. С PyArrow backend пропуски — всегда pd.NA.

import pandas as pd
import numpy as np

# Старый стиль с NumPy
s = pd.Series([1, 2, np.nan])
print(s.dtype)   # float64 — целые превратились в float
print(s[2])      # nan

# Новый стиль с nullable Int64
s = pd.Series([1, 2, pd.NA], dtype="Int64")
print(s.dtype)   # Int64 — остаются целые
print(s[2])      # <NA>

Поведение pd.NA в логике: pd.NA == pd.NA возвращает pd.NA, а не True. Это правильно (как в SQL: NULL = NULL → NULL). Учитывайте при условиях — df[df["x"] == 1] корректно отбрасывает строки с NA.

Конвертация типов после чтения

Иногда dtype нельзя поставить при чтении (например, числа нужно сначала почистить). Тогда конвертим после:

# Строка → число (можно с coerce для грязных данных)
df["amount"] = pd.to_numeric(df["amount"], errors="coerce")

# Строка → datetime
df["created_at"] = pd.to_datetime(df["created_at"], format="%Y-%m-%d", utc=True)

# object → category для экономии памяти
df["currency"] = df["currency"].astype("category")

# float64 → Int64 (nullable целые)
df["count"] = df["count"].astype("Int64")

pd.to_datetime с utc=True — важная привычка. Все timestamps хранить в UTC, переводить в локальную зону только для отображения. Подробнее в уроке 03 этого модуля.

TIP

Когда задаёте dtype при read_csv, pandas умеет конвертировать на лету, не загружая колонку как object и потом не пересчитывая. Это быстрее. Но не все конвертации возможны при чтении — особенно для грязных чисел. Иногда сначала читаем как object, потом чистим, потом конвертим.

Маленький DE-чеклист после чтения файла

Сразу после pd.read_csv всегда:

  1. df.shape — столько ли строк, сколько ждали?
  2. df.dtypes — нет ли всё-object?
  3. df.isna().sum() — где пропуски, сколько?
  4. df.head() — данные похожи на правду?
  5. df.duplicated().sum() — есть ли полные дубли строк?

Это шаги, которые делает каждый DE первым делом. Привыкайте.

Упражнение

Возьмите этот CSV (создайте файл dirty.csv с содержимым):

id,amount,currency,created
1,1234.56,USD,2025-01-15
2,2 500.00,USD,2025-01-16
3,1 000,75,EUR,2025-01-17
4,-,USD,2025-01-18
5,N/A,EUR,2025-01-19
6,9999.00,RUB,2025-01-20

Напишите скрипт clean.py, который:

  1. Читает файл через pd.read_csv с na_values.
  2. Конвертирует amount в float64, разбираясь с пробелами и запятыми. Грязные значения → NaN.
  3. Конвертирует created в datetime64.
  4. Конвертирует currency в category.
  5. Печатает df.info() и df.head().

Критерии приёмки:

  • После очистки df["amount"].dtype == "float64".
  • df["created"].dtype начинается с datetime64.
  • df["currency"].dtype.name == "category".
  • В колонке amount ровно 2 NaN (строки 4 и 5).
  • df["amount"].sum() примерно равна 14734.31 (если не учитываете строку 3 — потому что 1 000,75 после правил должно дать 1000.75).

В следующем уроке мы возьмём этот же DataFrame и будем его фильтровать, группировать и склеивать с другими таблицами.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4