← Программа курса
Занятие 01 · Модуль 0 · Старт · 40 мин чтения

Python и данные с нуля

NumPy и pandas на практике: векторизация и форма массивов, DataFrame, признаки и таргет, X и y, describe() и первый feature engineering — фундамент, на котором стоит весь курс.

numpypandasвекторизацияпризнаки и таргетfeature engineering
Скачать ноутбук урока ↓

Прежде чем обучать модели, нужно научиться держать данные в руках. Любая модель машинного обучения — это, по сути, функция, которая ест числа и выдаёт числа. Но реальные данные приходят к нам не идеальными матрицами, а таблицами с пропусками, строками, разными единицами измерения. Между «сырыми данными» и «моделью» стоит слой инструментов: в Python это две библиотеки, NumPy (быстрые массивы и вычисления) и pandas (таблицы). Этот урок закладывает практический фундамент: дальше мы пользуемся NumPy и pandas в каждом занятии, поэтому потратить на них полтора часа сейчас — лучшая инвестиция в курс.

Урок неспешный и с примерами, которые стоит запускать руками. Если ты уже уверенно крутишь DataFrame и понимаешь, чем (3,) отличается от (3, 1) — пролистай по диагонали и переходи к разделу про признаки и таргет. Если нет — устраивайся, начинаем с самого начала.

Где запускать код. Удобнее всего — в Jupyter Notebook или Google Colab: там результат последней строки ячейки (например, data.head()) виден сразу, без print. Если запускаешь обычный .py-файл — оборачивай вывод в print(...), иначе экран останется пустым. В уроке я иногда опускаю print ради краткости, но в комментарии # → всегда показываю, что именно выведется.

Зачем вообще отдельная библиотека для чисел

Казалось бы, в Python и так есть списки: [1, 2, 3]. Зачем какой-то NumPy? Представь, что у тебя миллион цен квартир, и все нужно перевести из рублей в тысячи рублей — поделить на 1000. Со списком придётся писать цикл: пройти по каждому числу, поделить, сложить результат в новый список. Это и многословно, и медленно: на каждом шаге интерпретатор Python делает кучу служебной работы.

NumPy даёт массив (ndarray) — структуру, с которой математика работает «целиком», без видимых циклов. Поделить миллион чисел на 1000 — это одна строчка, и выполнится она в десятки раз быстрее цикла.

import numpy as np   # as np — короткое имя; дальше пишем np. вместо numpy. Так принято у всех

a = np.array([1, 2, 3, 4])
print(a * 2)        # → [2 4 6 8]   — умножились все элементы сразу
print(a + 10)       # → [11 12 13 14]
print(a.mean())     # → 2.5         — среднее
print(a.sum())      # → 10          — сумма

Обрати внимание: a * 2 не «повторяет список дважды» (как сделал бы обычный Python-список [1,2]*2 → [1,2,1,2]), а умножает каждый элемент. Это называется векторизация: операция применяется ко всему массиву сразу, поэлементно. Сравни два способа удвоить числа:

# по-старому, циклом:
result = []
for x in [1, 2, 3, 4]:
    result.append(x * 2)

# по-новому, векторизованно:
result = a * 2

Второй вариант короче, читается как математика и работает кратно быстрее. Насколько именно быстрее? Это не абстракция — давай измерим реальное время на массивах растущего размера: для каждого размера сравним цикл на чистом Python с векторизованной операцией NumPy.

import numpy as np, time

for n in [10_000, 100_000, 1_000_000]:
    data = list(range(n))
    arr = np.arange(n)

    t0 = time.perf_counter()
    res = [v * 2 for v in data]          # цикл (списковое включение)
    t_loop = time.perf_counter() - t0

    t0 = time.perf_counter()
    _ = arr * 2                          # векторизация
    t_np = time.perf_counter() - t0

    print(f"n={n:>9}: цикл {t_loop*1000:6.2f} мс, numpy {t_np*1000:6.3f} мс")
Две кривые в двойном логарифмическом масштабе: время цикла Python растёт заметно выше времени NumPy при росте размера массива
Время удвоения массива: цикл Python (оранжевый) против векторизации NumPy (индиго). Обе оси логарифмические — разрыв между линиями и есть выигрыш в скорости (десятки раз).

Чем больше массив, тем заметнее разрыв. На реальных датасетах с сотнями тысяч строк это разница между «посчиталось мгновенно» и «пошёл пить кофе». Поэтому правило простое: в NumPy циклов по элементам стараются избегать — почти всегда есть векторизованный способ.

Почему a * 2 быстрее цикла

NumPy хранит числа подряд в памяти как единый плотный блок одного типа (например, восемь байт на число) и выполняет операцию сразу над всем блоком на уровне скомпилированного C-кода, а часто и параллельно на нескольких ядрах процессора. Цикл на чистом Python обрабатывает по одному числу за раз, и на каждом шаге интерпретатор тратит время на проверку типов, разыменование объектов и прочую служебную работу. За 8 байтами одного Python-объекта-числа скрывается целая структура с заголовком. Отсюда и разрыв в десятки-сотни раз на больших массивах.

Форма массива: shape

У массива есть форма (shape) — кортеж, говорящий, сколько в массиве измерений и какого они размера. Это, пожалуй, источник 90% ошибок новичка в ML, поэтому разберём подробно.

m = np.array([[1, 2, 3],
              [4, 5, 6]])
print(m.shape)      # → (2, 3)   — 2 строки, 3 столбца

Форма читается так: первое число задаёт количество строк, второе — количество столбцов. (2, 3) — это таблица 2×3. Форма критична: модели придирчивы, и если подать данные не той формы, они откажутся работать с ошибкой вроде Expected 2D array, got 1D array. Самый частый случай — когда одномерный массив нужно превратить в столбец:

x = np.array([10, 20, 30])
print(x.shape)                 # → (3,)      — одномерный, "в ряд"
print(x.reshape(-1, 1).shape)  # → (3, 1)    — двумерный, "в столбик"

Разница между (3,) и (3, 1) — это разница между «числа в ряд» и «числа в столбик»:

(3,)     →   10  20  30          (три числа лежат в один ряд, одно измерение)

(3, 1)   →   10
             20                  (те же три числа стоят столбиком, два измерения)
             30

Зачем это нужно: в машинном обучении принято, что каждый признак — это отдельный столбец, а каждая строка отвечает за отдельный объект. Поэтому одиночный признак (например, только площадь) переводят из формы (n,) в форму (n, 1) — «n объектов, 1 признак». Магическая -1 в reshape(-1, 1) означает «посчитай это измерение сам, исходя из общего числа элементов»: у нас 3 числа и мы просим 1 столбец, значит строк будет 3. Запомни этот приём — он понадобится в каждом уроке, где у модели всего один признак.

Мини-правило. Одно измерение (n,) — это вектор (один столбец данных «лёжа»). Два измерения (n, m) — это матрица: n объектов, m признаков. Модели scikit-learn почти всегда хотят на входе именно матрицу.

pandas: данные как таблица

NumPy хорош для чистых чисел, но реальные данные — это таблица с названиями колонок, разными типами (числа, строки, даты) и, возможно, пропусками. Здесь на сцену выходит pandas и его главный объект — DataFrame. NumPy работает с числами, а pandas — с таблицами, как Excel внутри Python.

таргет y признаки X площадьэтаждо центрацена 1 253123.6 2 42765.4 3 55286.8 4 70938.5
Датасет: строки — объекты, столбцы — признаки, последний столбец — таргет.
import pandas as pd   # тоже короткое имя — pd

data = pd.DataFrame({
    "area":     [25, 42, 55, 70],      # площадь, м²
    "floor":    [3, 7, 2, 9],          # этаж
    "distance": [12, 6, 8, 3],         # до центра, км
    "price":    [3.6, 5.4, 6.8, 8.5],  # цена, млн ₽ — это таргет
})
data.head()

Вывод data (или data.head()) — аккуратная таблица:

   area  floor  distance  price
0    25      3        12    3.6
1    42      7         6    5.4
2    55      2         8    6.8
3    70      9         3    8.5

Каждая строка — это объект (одна квартира), а каждый столбец — либо признак, либо таргет. Числа слева (0, 1, 2, 3) — это индекс, автоматические номера строк: не данные, а «адреса» строк.

Ключевое понятие, к которому мы будем возвращаться весь курс:

Таргет мы выбираем сами, в зависимости от задачи. Здесь хотим предсказывать цену по характеристикам квартиры — значит, таргет price, а остальное признаки. Если бы по площади и цене мы предсказывали этаж — таргетом стал бы floor. Один и тот же датасет под разные задачи делится по-разному.

Зачем смотреть на связь признак → таргет

Прежде чем обучать модель, полезно глазами посмотреть, есть ли вообще связь между признаком и таргетом. Самый простой способ — точечный график (scatter): по горизонтали признак, по вертикали таргет.

import matplotlib.pyplot as plt

plt.scatter(data["area"], data["price"])
plt.xlabel("признак: площадь, м²")
plt.ylabel("таргет: цена, млн ₽")
plt.show()
Точки растут слева направо: чем больше площадь, тем выше цена
Связь признака и таргета: точки идут вверх-вправо — чем больше площадь, тем выше цена. Эту закономерность модель и будет ловить.

Если точки образуют тренд (вверх или вниз) — между признаком и таргетом есть связь, и задачу можно решать. Если точки разбросаны бесформенным облаком — этот признак мало что объясняет, и одной модели на нём не построить. Такой взгляд «глазами на данные» — первое, что делает любой практик, и мы будем повторять его в каждом уроке.

Признаки и таргет: как разделить их в коде

В машинном обучении приняты две буквы-обозначения, и они встретятся тебе тысячу раз: большая X обозначает таблицу признаков (матрица «объекты × признаки»), а маленькая y — столбец ответов (вектор). Регистр не случаен: большая буква намекает на матрицу (двумерную таблицу), маленькая — на один вектор-столбец. Перед обучением данные почти всегда делят на эти две части:

X = data[["area", "floor", "distance"]].values   # матрица признаков
y = data["price"].values                          # таргет (вектор ответов)

print(X.shape, y.shape)   # → (4, 3) (4,)

Разберём строку с X. Двойные скобки data[["area", "floor", "distance"]] — это выбор нескольких столбцов: внутренние скобки задают список имён, а внешние отвечают за индексацию датафрейма. Результат — снова таблица (поэтому (4, 3): 4 объекта, 3 признака). А .values превращает таблицу pandas в чистый NumPy-массив — ровно тот формат, который ждут модели библиотеки scikit-learn (sklearn); с ней начнём работать со следующего урока.

Заметь формы: у X она двумерная (4, 3), у y — одномерная (4,). Это и есть тот самый контракт «матрица признаков + вектор таргета», который повторяется во всём ML.

Сводная статистика: describe() и head()

Эти несколько приёмов закрывают бóльшую часть повседневной работы с данными — назовём их «осмотр данных».

data.head()              # первые 5 строк — быстро взглянуть, что внутри
data.shape               # → (строк, столбцов)
data["area"].mean()      # → 48.0   — среднее по одной колонке
data.describe()          # сводная статистика по всем числовым колонкам

describe() — самый ценный из них. По каждой числовой колонке он сразу выдаёт несколько чисел:

        area  floor  distance  price
count   4.00   4.00      4.00   4.00
mean   48.00   5.25      7.25   6.08
std    19.13   3.30      3.77   2.08
min    25.00   2.00      3.00   3.60
25%    37.75   2.75      5.25   4.95
50%    48.50   5.00      7.00   6.10
75%    58.75   7.50      9.00   7.22
max    70.00   9.00     12.00   8.50

Читаем по строкам: count — сколько непустых значений (если меньше числа строк, есть пропуски!), mean — среднее, std — стандартное отклонение (разброс), min/max — границы, а 25% / 50% / 75% — квартили (50% — медиана). Этот вывод за секунду показывает разброс данных и подозрительные значения: площадь 0, цену в миллиард, отрицательный возраст — всё это всплывёт в min/max и намекнёт на ошибку в данных.

Но у describe() есть ловушка: он сводит каждый столбец к нескольким числам, а форму распределения не показывает. Среднее и медиана могут сильно расходиться, и это сигнал. Нарисуем гистограмму площадей на датасете побольше:

rng = np.random.default_rng(0)
area = np.concatenate([
    rng.normal(45, 10, size=160),   # основная масса — малогабаритные
    rng.normal(85, 12, size=40),    # длинный хвост — большие квартиры
]).clip(18, 130)

plt.hist(area, bins=24)
plt.axvline(area.mean(),  label="среднее")
plt.axvline(np.median(area), linestyle="--", label="медиана")
plt.xlabel("площадь, м²"); plt.ylabel("сколько квартир")
plt.legend(); plt.show()
Гистограмма площадей: высокий пик слева около 45 и более низкий горб справа около 85; вертикальные линии среднего и медианы
Распределение площадей. Среднее (оранжевая) утянуто вправо хвостом больших квартир сильнее, чем медиана (пунктир): два горба, которые describe() как одно число не покажет.

Видно сразу два «горба» — мелкие и крупные квартиры — и то, что среднее смещено хвостом вправо относительно медианы. describe() этого не показал бы. Мораль та же, что и в первом уроке статистики: числа полезны, но на данные надо ещё и смотреть.

Создание новых признаков (feature engineering)

Последняя важная операция — создать новый столбец из существующих. Это уже зачаток feature engineering (конструирования признаков), которому посвящён отдельный урок впереди. Иногда удачный новый признак улучшает модель сильнее, чем смена самой модели.

data["price_per_m"] = data["price"] / data["area"]   # цена за квадратный метр
print(data[["area", "price", "price_per_m"]].round(4))
# →    area  price  price_per_m
# → 0    25    3.6       0.1440
# → 1    42    5.4       0.1286
# → 2    55    6.8       0.1236
# → 3    70    8.5       0.1214

Деление двух столбцов — это снова векторизация: pandas делит их поэлементно, без цикла. И смотри, что обнаружилось: у больших квартир цена за метр ниже (0.121 против 0.144 у маленькой). Этот скрытый эффект не виден в столбце price, зато выпукло проявляется в новом признаке. Покажем его на датасете побольше: слева цена против площади (обычный растущий тренд), справа — наш новый признак «цена за м²» против площади.

rng = np.random.default_rng(3)
area = rng.uniform(25, 110, size=80)
price_per_m = 0.135 - 0.0004 * area + rng.normal(0, 0.004, size=80)
price = price_per_m * area

fig, ax = plt.subplots(1, 2)
ax[0].scatter(area, price);                  ax[0].set_title("исходный признак")
ax[1].scatter(area, price_per_m * 1000);     ax[1].set_title("новый признак")
plt.show()
Две панели: слева цена растёт с площадью; справа цена за квадратный метр падает с ростом площади
Один и тот же набор квартир. Слева — рост цены с площадью (очевидно). Справа новый признак «цена за м²» вскрывает скрытое: метр в больших квартирах дешевле.

Фильтрация строк по условию

Часто нужно оставить только часть строк — например, дорогие квартиры:

data[data["price"] > 5]

Читается изнутри наружу: выражение data["price"] > 5 даёт столбец из True/False (для каждой строки — выполнено ли условие), а внешнее data[...] оставляет только строки, где стоит True. Это называется булева индексация — ещё одна векторизованная операция вместо цикла.

print(data[data["price"] > 5][["area", "price"]])
# →    area  price
# → 1    42    5.4
# → 2    55    6.8
# → 3    70    8.5
Чем .loc отличается от обычных скобок

В реальном коде ты встретишь data.loc[строки, столбцы] — это «правильный» способ одновременно выбирать строки и столбцы и присваивать значения. Например, data.loc[data["price"] > 5, "tag"] = "дорогая" поставит метку только дорогим квартирам. pandas даже предупреждает (SettingWithCopyWarning), если изменять данные через цепочку [][], — потому что иногда так меняется копия, а не оригинал. Пока достаточно помнить: для чтения хватает обычных скобок, а для надёжного присваивания по условию используют .loc.

Чтение реальных данных

В жизни данные редко набирают руками — обычно они лежат в CSV-файле (текстовая таблица с запятыми). pandas читает их одной строкой:

df = pd.read_csv("apartments.csv")
df.head()        # первый взгляд
df.describe()    # сводка
df.shape         # размеры

После этого — те же приёмы: осмотр через head()/describe(), чистка подозрительных значений, при необходимости создание новых признаков, и наконец разделение на X и y. Дальше данные готовы отправиться в модель — чем мы и займёмся со следующего урока.

Итог урока

  1. NumPy даёт быстрые массивы; операции применяются ко всему массиву сразу — это векторизация, и она в десятки раз быстрее цикла.
  2. Форма (shape) — кортеж размеров: (n,) — вектор «в ряд», (n, 1) — столбец, (n, m) — матрица. reshape(-1, 1) переводит признак в столбец.
  3. Модели sklearn придирчивы к форме: на входе почти всегда нужна матрица (объекты, признаки).
  4. pandas DataFrame — таблица: строки это объекты, столбцы это признаки и таргет.
  5. Перед обучением данные делят на матрицу признаков X (двумерную) и вектор ответов y (одномерный).
  6. head(), describe() и гистограмма — базовый «осмотр данных»; но describe() прячет форму распределения, поэтому на данные ещё и смотрят графиком.
  7. Новый столбец из старых (price / area) — это feature engineering, и он бывает мощнее смены модели.
  8. Булева индексация (data[data["price"] > 5]) фильтрует строки по условию — тоже без цикла.

Инструменты в руках. В следующем уроке разберёмся, что вообще значит «машина учится», встретим первую модель и научимся честно проверять её качество.


Домашнее задание

Задания идут от простых к сложным. 🧠 — теория, 💻 — код, 🔮 — «что будет, если…». Сначала попробуй сам, потом открой разбор.

  1. 💻 Создай массив np.array([5, 10, 15, 20]). Получи: все элементы в квадрате, среднее, сумму и стандартное отклонение.
Разбор
import numpy as np
a = np.array([5, 10, 15, 20])
print(a ** 2)        # → [ 25 100 225 400]
print(a.mean())      # → 12.5
print(a.sum())       # → 50
print(a.std().round(3))  # → 5.59

Все операции векторизованы: a ** 2 возводит в квадрат каждый элемент сразу, без цикла.

  1. 💻 Преврати np.array([1, 2, 3, 4, 5]) в столбец формы (5, 1) через reshape. Проверь форму до и после.
Разбор
x = np.array([1, 2, 3, 4, 5])
print(x.shape)               # → (5,)
print(x.reshape(-1, 1).shape) # → (5, 1)

-1 означает «посчитай число строк сам»: 5 элементов в 1 столбец → 5 строк.

  1. 💻 Собери DataFrame из 5 фильмов с колонками год, длительность, рейтинг. Выведи head() и describe(). Какой фильм самый длинный?
Разбор
import pandas as pd
films = pd.DataFrame({
    "год":         [1999, 2008, 2014, 2019, 2023],
    "длительность":[136, 152, 169, 181, 124],
    "рейтинг":     [8.7, 9.0, 8.6, 8.4, 7.9],
})
films.head()
films.describe()          # mean длительности ≈ 152.4
films["длительность"].idxmax()  # индекс самого длинного (2019, 181 мин)

describe() подскажет средние и разброс; idxmax() вернёт индекс строки с максимумом.

  1. 💻 Из датафрейма задания 3 выдели X (год и длительность) и y (рейтинг). Проверь их shape. Почему у X два измерения, а у y одно?
Разбор
X = films[["год", "длительность"]].values
y = films["рейтинг"].values
print(X.shape, y.shape)   # → (5, 2) (5,)

X — это таблица «5 объектов × 2 признака», поэтому два измерения. y — один столбец ответов, поэтому одно измерение. Это стандартный контракт ML: матрица признаков + вектор таргета.

  1. 🧠 Объясни своими словами разницу между формой (5,) и (5, 1). Почему это важно для sklearn?
Разбор

(5,) — пять чисел в один ряд, одномерный вектор. (5, 1) — те же пять чисел, но стоящие столбиком, двумерная матрица из 5 строк и 1 столбца. Для sklearn это важно, потому что модель ждёт на входе матрицу «объекты × признаки»: 5 объектов с 1 признаком — это (5, 1). Подашь (5,) — получишь ошибку Expected 2D array. Поэтому одиночный признак всегда переводят reshape(-1, 1).

  1. 💻 К датафрейму фильмов добавь новый признак «минут на год новизны» (длительность / (2025 - год)). Затем отфильтруй фильмы с рейтингом выше 8.5.
Разбор
films["мин_на_год"] = films["длительность"] / (2025 - films["год"])
films[films["рейтинг"] > 8.5]

Деление столбцов векторизовано — pandas посчитает новый столбец сразу для всех строк. Булева индексация films[films["рейтинг"] > 8.5] оставит только высоко оценённые фильмы.

  1. 🔮 Что вернёт np.array([1,2,3,4,5,6]).reshape(-1, 2)? А reshape(-1, 2) для массива из 5 элементов? Сначала подумай, потом проверь.
Разбор

Для 6 элементов: матрица (3, 2)-1 означает «посчитай число строк», 6 / 2 = 3:

[[1 2]
 [3 4]
 [5 6]]

Для 5 элементов будет ошибка cannot reshape array of size 5 into shape (2): 5 не делится на 2 нацело, и NumPy не может разложить элементы в прямоугольник без остатка. Вывод: reshape требует, чтобы произведение размеров новой формы точно равнялось числу элементов.

  1. 🔮 У тебя массив цен в рублях из миллиона элементов. Ты переводишь их в тысячи рублей двумя способами: циклом for и векторизацией arr / 1000. Что будет с результатом и что — со временем? Проверь на коде с time.perf_counter().
Разбор

Результат одинаковый — те же числа. А вот время отличается в десятки раз: векторизация выполняется на уровне C над всем блоком памяти, а цикл Python обрабатывает по одному числу с накладными расходами интерпретатора на каждом шаге. На графике из урока это и есть расхождение двух линий. Правило: если можно векторизовать — векторизуй.

Нужен разбор?

Застрял на теме — разберём один на один

Объясню сложное на пальцах и доведу до результата: код, формулы, проект.

Записаться на разбор