Прежде чем обучать модели, нужно научиться держать данные в руках. Любая модель машинного обучения — это, по сути, функция, которая ест числа и выдаёт числа. Но реальные данные приходят к нам не идеальными матрицами, а таблицами с пропусками, строками, разными единицами измерения. Между «сырыми данными» и «моделью» стоит слой инструментов: в Python это две библиотеки, NumPy (быстрые массивы и вычисления) и pandas (таблицы). Этот урок закладывает практический фундамент: дальше мы пользуемся NumPy и pandas в каждом занятии, поэтому потратить на них полтора часа сейчас — лучшая инвестиция в курс.
Урок неспешный и с примерами, которые стоит запускать руками. Если ты уже уверенно крутишь DataFrame и понимаешь, чем (3,) отличается от (3, 1) — пролистай по диагонали и переходи к разделу про признаки и таргет. Если нет — устраивайся, начинаем с самого начала.
Где запускать код. Удобнее всего — в Jupyter Notebook или Google Colab: там результат последней строки ячейки (например,
data.head()) виден сразу, без.py-файл — оборачивай вывод в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} мс")
Чем больше массив, тем заметнее разрыв. На реальных датасетах с сотнями тысяч строк это разница между «посчиталось мгновенно» и «пошёл пить кофе». Поэтому правило простое: в 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.
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) — это индекс, автоматические номера строк: не данные, а «адреса» строк.
Ключевое понятие, к которому мы будем возвращаться весь курс:
- признак (feature) — то, что мы про объект знаем: площадь, этаж, расстояние до центра;
- таргет (target, целевая переменная) — тот один столбец, который мы хотим научиться предсказывать: здесь это цена.
Таргет мы выбираем сами, в зависимости от задачи. Здесь хотим предсказывать цену по характеристикам квартиры — значит, таргет
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()
Видно сразу два «горба» — мелкие и крупные квартиры — и то, что среднее смещено хвостом вправо относительно медианы. 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. Дальше данные готовы отправиться в модель — чем мы и займёмся со следующего урока.
Итог урока
- NumPy даёт быстрые массивы; операции применяются ко всему массиву сразу — это векторизация, и она в десятки раз быстрее цикла.
- Форма (
shape) — кортеж размеров:(n,)— вектор «в ряд»,(n, 1)— столбец,(n, m)— матрица.reshape(-1, 1)переводит признак в столбец. - Модели
sklearnпридирчивы к форме: на входе почти всегда нужна матрица(объекты, признаки). - pandas
DataFrame— таблица: строки это объекты, столбцы это признаки и таргет. - Перед обучением данные делят на матрицу признаков
X(двумерную) и вектор ответовy(одномерный). head(),describe()и гистограмма — базовый «осмотр данных»; ноdescribe()прячет форму распределения, поэтому на данные ещё и смотрят графиком.- Новый столбец из старых (
price / area) — это feature engineering, и он бывает мощнее смены модели. - Булева индексация (
data[data["price"] > 5]) фильтрует строки по условию — тоже без цикла.
Инструменты в руках. В следующем уроке разберёмся, что вообще значит «машина учится», встретим первую модель и научимся честно проверять её качество.
Домашнее задание
Задания идут от простых к сложным. 🧠 — теория, 💻 — код, 🔮 — «что будет, если…». Сначала попробуй сам, потом открой разбор.
- 💻 Создай массив
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 возводит в квадрат каждый элемент сразу, без цикла.
- 💻 Преврати
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 строк.
- 💻 Собери
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() вернёт индекс строки с максимумом.
- 💻 Из датафрейма задания 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: матрица признаков + вектор таргета.
- 🧠 Объясни своими словами разницу между формой
(5,)и(5, 1). Почему это важно дляsklearn?
Разбор
(5,) — пять чисел в один ряд, одномерный вектор. (5, 1) — те же пять чисел, но стоящие столбиком, двумерная матрица из 5 строк и 1 столбца. Для sklearn это важно, потому что модель ждёт на входе матрицу «объекты × признаки»: 5 объектов с 1 признаком — это (5, 1). Подашь (5,) — получишь ошибку Expected 2D array. Поэтому одиночный признак всегда переводят reshape(-1, 1).
- 💻 К датафрейму фильмов добавь новый признак «минут на год новизны» (
длительность / (2025 - год)). Затем отфильтруй фильмы с рейтингом выше 8.5.
Разбор
films["мин_на_год"] = films["длительность"] / (2025 - films["год"])
films[films["рейтинг"] > 8.5]Деление столбцов векторизовано — pandas посчитает новый столбец сразу для всех строк. Булева индексация films[films["рейтинг"] > 8.5] оставит только высоко оценённые фильмы.
- 🔮 Что вернёт
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 требует, чтобы произведение размеров новой формы точно равнялось числу элементов.
- 🔮 У тебя массив цен в рублях из миллиона элементов. Ты переводишь их в тысячи рублей двумя способами: циклом
forи векторизациейarr / 1000. Что будет с результатом и что — со временем? Проверь на коде сtime.perf_counter().
Разбор
Результат одинаковый — те же числа. А вот время отличается в десятки раз: векторизация выполняется на уровне C над всем блоком памяти, а цикл Python обрабатывает по одному числу с накладными расходами интерпретатора на каждом шаге. На графике из урока это и есть расхождение двух линий. Правило: если можно векторизовать — векторизуй.