Семестр 3 · Модуль 1 · Неделя 2
Индексация, broadcasting, ufuncs
view и копия · fancy indexing · boolean masking · правила broadcasting · универсальные функции · агрегации с осью
2 ч теории: четыре блока, два live-coding, peer instruction, два перерыва.
Впереди самая сложная тема модуля — broadcasting. Здесь ошибаются все, включая практиков. Это не вы такие — тема такая.
Не открывая конспект: .T.shape и linspace(0,1,5)
- Вопрос 1: какой
shape у np.arange(12).reshape(3, 4).T?
- Вопрос 2: что вернёт
np.linspace(0, 1, 5) и сколько в нём элементов?
- Бонус: почему для сетки из N точек не берут
arange?
Это не контроль, а припоминание: вспомнили сразу — отлично; нет — сигнал перечитать конспект.
Нормализация признаков — одна строка NumPy вместо 10 строк циклов
- Признаки разного масштаба (возраст 0–80, доход 0–10⁶) ломают обучение: модель «считает» большой признак важным только из-за масштаба.
- ML-библиотека (scikit-learn) нормализует признаки перед
fit — это одна векторная строка, которую вы напишете сегодня: (x - x.min(axis=0)) / (x.max(axis=0) - x.min(axis=0)).
- Эта строка заменяет 10 строк циклов, которые вы писали бы на прошлом семестре.
Без нормализации регрессия недели 3 даст бессмысленные веса.
К концу лекции вы сможете…
- выбирать и менять элементы (
a[i,j], срезы, fancy, маски, np.where);
- объяснять view vs копия и управлять ей;
- применять broadcasting по правилу сравнения форм;
- предсказывать
ValueError несовместимых форм;
- агрегировать с корректной осью;
- нормализовать матрицу признаков без циклов.
Пункт 6 — прямое ДЗ недели и подготовка к регрессии.
Индексация a[i,j] и отрицательные индексы работают как в строках
a = np.arange(12).reshape(3, 4)
a[1, 2] # строка 1, столбец 2
a[-1, -1] # последний элемент
a[:, 1] # весь 2-й столбец
a[::-1] # реверс строк
6
11
array([1, 5, 9])
array([[ 8, 9, 10, 11],
[ 4, 5, 6, 7],
[ 0, 1, 2, 3]])
Правило: a[строка, столбец]; : — «все по этой оси»; отрицательные индексы считают с конца.
Что вернёт a[1, :]?
Срез по двум осям a[0:2, 0:2] — блок 2×2, а не 1D
a = np.arange(12).reshape(3, 4)
a[0:2, 0:2]
Какой формы результат? Что внутри? Предскажите, не запуская код.
Срез возвращает view: изменение view меняет исходный массив
a = np.arange(12).reshape(3, 4)
view = a[0:2, 0:2]
view[0, 0] = 99
print(a[0, 0])
99
Срез не копирует данные — он описывает «окно» на ту же ленту памяти. Запись через окно меняет исходный массив.
Почему изменение «под-массива» изменило исходный? Когда это удобно?
b = a[:, 1]; b[0]=100 — изменится ли a[0,1]?
a = np.arange(12).reshape(3, 4)
b = a[:, 1] # 2-й столбец
b[0] = 100
print(a[0, 1]) # что выведет?
Изменится ли a[0, 1]? Почему? Столбец — тоже view. Нужна независимая копия — .copy().
Копия — .copy(); правило: срез=view, fancy=копия
a = np.arange(12).reshape(3, 4)
b = a[0:2, 0:2].copy()
b[0, 0] = 99
print(a[0, 0])
0
| Приём | Что возвращает |
срез a[0:2] | view (те же данные) |
fancy indexing a[[0,2]] | копия |
явная копия .copy() | копия |
Правило «срез=view» — источник неожиданной мутации; «fancy=копия» закрепим на следующем слайде.
Fancy indexing — выборка по спискам индексов и перестановка строк
a = np.arange(12).reshape(3, 4)
a[[0, 2], [1, 3]]
array([ 1, 11])
# a[0,1] и a[2,3] — параллельные списки
a[[2, 0, 1]] # строки в порядке 2, 0, 1
array([[ 8, 9, 10, 11],
[ 0, 1, 2, 3],
[ 4, 5, 6, 7]])
Правило: списки индексов идут параллельно: (0,1) и (2,3) — берутся пары; один список по оси 0 — перестановка строк.
Как выбрать a[0,1] и a[2,3] одной операцией?
Fancy indexing возвращает копию; для прямоугольных выборок — np.ix_
- Fancy indexing возвращает копию: изменение результата не трогает исходный массив.
- Нужны «все комбинации» строк и столбцов (прямоугольник), а не диагональные пары —
a[np.ix_([0, 2], [1, 3])].
- Применение fancy — случайная выборка строк:
a[rng.choice(len(a), size=k)] (вернёмся на неделе 3).
Запомните контраст: срез — view, fancy — копия.
Boolean mask: a[a>5] возвращает элементы, где условие истинно
a = np.arange(12).reshape(3, 4)
a[a > 5]
array([ 6, 7, 8, 9, 10, 11])
Правило: a > 5 — булев массив той же формы (маска); a[mask] отдаёт элементы, где маска истинна. Результат — 1D: маска «выпрямляет».
Какой shape у a[a>5]?
Комбинирование условий: &, |, ~ — всегда со скобками
(a > 3) & (a < 7)
array([[False, False, False, False],
[ True, True, True, False],
[False, False, False, False]])
Правило: & (И), | (ИЛИ), ~ (НЕ) — поэлементные; скобки обязательны, потому что приоритет & выше, чем >. Логического and у массивов нет.
Что нужно поставить в (a > 3) _ (a < 7), чтобы получить пересечение?
Присваивание по маске a[a>5] = -1 меняет только отмеченные элементы
a = np.arange(12).reshape(3, 4)
a[a > 5] = -1
a
array([[ 0, 1, 2, 3],
[ 4, 5, -1, -1],
[-1, -1, -1, -1]])
Правило: a[mask] = value присваивает значение всем элементам, где маска истинна. Так заменяют выбросы, обнуляют, клампают значения.
Как заменить на -1 все элементы больше 5 одной строкой?
np.where(cond, x, y) — поэлементный выбор; np.argwhere — индексы
a = np.arange(12).reshape(3, 4)
np.where(a > 5, a, -1)
array([[ -1, -1, -1, -1],
[ -1, -1, 6, 7],
[ 8, 9, 10, 11]])
Правило: маска a[a>5] возвращает значения; np.where(cond, x, y) — массив той же формы (поэлементный выбор из x и y); np.argwhere(cond) — индексы истинных.
Чем a[a>3] отличается от np.where(a>3, a, 0)?
Live-coding: маскирование выбросов ±3σ
Задача: сгенерировать 1000×5 нормальных данных, отметить значения за пределами ±3σ как nan и посчитать их долю. Исходные данные не мутировать!
# ожидаемый результат
mask.mean() # доля ≈ 0.0026 (0.26%) — правило 3σ
Почему result = d.copy(), а не result = d?
Broadcasting: сначала почувствуй — таблица умножения одной строкой
np.arange(1, 11).reshape(-1, 1) * np.arange(1, 11)
array([[ 1, 2, 3, ...],
[ 2, 4, 6, ...],
[ 3, 6, 9, ...],
...
[ 10, 20, 30, ..., 100]])
# форма (10, 1) * (10,) → (10, 10)
Сверка углов: table[0,0] == 1, table[9,9] == 100. Одна операция вместо двойного цикла.
Как столбец (10,1) умножился на строку (10,) и получилась таблица? Что произошло с формами?
Правило сравнения форм справа налево: равные / 1 / иначе ValueError
Broadcasting — NumPy расширяет меньший массив до формы большего без копирования данных, чтобы выполнить поэлементную операцию.
- равные размерности — совместимы;
- одна из размерностей равна 1 — она расширяется;
- не совпали и ни одна не 1 →
ValueError.
# (3, 1) — столбец
# 1 растягивается
(3, 1) → (3, 4)
# (1, 4) — строка
# 1 растягивается
(1, 4) → (3, 4)
Совместимы ли (3,1) и (1,4)? А (4,) и (3,4)?
(3,1)+(1,4) → (3,4): размерность 1 растягивается
np.arange(3).reshape(-1, 1) + np.arange(4)
array([[0, 1, 2, 3],
[1, 2, 3, 4],
[2, 3, 4, 5]])
Правило: (3,1) + (1,4): справа налево — 1=1, 3 vs 1 — расширяется до (3,4). Результат — таблица «строка + столбец».
Какой формы будет результат np.arange(3).reshape(-1,1) + np.arange(4)?
(5,)+(3,5) — центрирование строкой; broadcasting — тема, где ошибаются все
x = np.array([[1., 2., 3., 4., 5.],
[2., 3., 4., 5., 6.],
[3., 4., 5., 6., 7.]])
x - x.mean(axis=0) # (3,5) - (5,) → (3,5)
array([[-1., -1., -1., -1., -1.],
[ 0., 0., 0., 0., 0.],
[ 1., 1., 1., 1., 1.]])
Правило: вектор (5,) трактуется как «строка» и вычитается из каждой строки — это центрирование.
Честно о сложности: broadcasting — самая сложная тема модуля, здесь ошибаются все. Сложность — свойство темы, а не вас. Навык один: проверяйте .shape.
Почему при вычитании (3,5) - (5,) форма не ломается?
Несовместимые формы → ValueError; проверка .shape — навык, а не стыд
np.zeros((3, 4)) + np.arange(3)
ValueError: operands could not be broadcast
together with shapes (3,4) (3,)
Разбор: (3,4) и (3,) — справа налево: 4 vs 3, ни одна не 1 → несовместимо. А (3,4) + (4,) — совместимо: последняя ось должна совпасть или быть 1.
Ошибка совместимости форм — сигнал проверить .shape, а не повод стыдиться. Это рабочий процесс.
Чем (4,) отличается от (3,) в той же позиции?
Нормализация по столбцам: (x - min)/span; константный столбец → NaN
x = np.array([[0., 2., 10.],
[4., 2., 20.]])
x_min = x.min(axis=0) # → [0. 2. 10.]
span = x.max(axis=0) - x_min # → [4. 0. 10.]
result = (x - x_min) / span # (2,3) - (3,) → (2,3)
result
array([[0., nan, 0.],
[1., nan, 1.]]) # столбец с span=0 → NaN
Правило: (2,3) - (3,) работает через broadcasting; константный столбец (span=0) даёт 0/0 = NaN — edge case, который обрабатывается через np.where(span == 0, 0.0, ...).
Почему при вычитании (2,3) - (3,) форма не ломается?
Live-coding: центрирование матрицы, keepdims
Задача: сгенерировать матрицу 5×3 и отцентрировать её по столбцам и по строкам — среднее каждого центрированного среза должно стать нулём.
# ожидаемые проверки
np.allclose(c.mean(axis=0), 0) # True
np.allclose(c2.mean(axis=1), 0) # True
# ошибка без keepdims:
ValueError: operands could not be broadcast
together with shapes (5,3) (5,)
Почему центрирование по строкам требует keepdims=True, а по столбцам — нет?
Peer instruction · голосуем
Совместимы ли (3,1) и (1,4)? Какой формы результат?
Какой результат даст np.arange(3).reshape(-1, 1) + np.arange(4)?
- (а)
ValueError — формы несовместимы;
- (б) массив формы
(3, 4);
- (в) массив формы
(4, 3);
- (г) массив формы
(3,).
Бонус: совместимы ли (3,) и (3,4)? А (4,) и (3,4)? Голосуем, обсуждаем в парах, голосуем снова.
ufunc — поэлементная функция: np.sqrt, np.exp, np.abs…
np.sqrt(np.arange(4))
np.exp([0, 1, 2])
array([0. , 1. , 1.41421356, 1.73205081])
array([1. , 2.71828183, 7.3890561 ])
ufunc (универсальная функция) — функция, действующая поэлементно над массивами: np.add, np.multiply, np.exp, np.log, np.sqrt, np.sin, np.abs, np.sign. Запись +, *, ** — лишь синтаксический сахар над np.add, np.multiply, np.power.
Все поэлементные операции недели 1 — это ufuncs; теперь у них есть имена и методы.
Методы ufunc: reduce, accumulate, outer
np.add.reduce([1, 2, 3, 4]) # сумма — «свёртка»
np.add.accumulate([1, 2, 3, 4]) # накопленные суммы
np.add.outer(np.arange(3), np.arange(3)) # внешнее «сложение»
10
array([ 1, 3, 6, 10])
array([[0, 1, 2],
[1, 2, 3],
[2, 3, 4]])
Правило: reduce — последовательное свёртывание; accumulate — все промежуточные результаты; outer — «таблица» по двум осям (та же идея, что broadcasting-таблица на слайде 17).
Что вернёт np.add.reduce([1,2,3,4])?
Агрегации с axis: axis=0 схлопывает строки, axis=1 — столбцы
m = np.array([[1, 2, 3], [4, 5, 6]])
m.sum(axis=0) # вдоль столбцов
m.sum(axis=1) # вдоль строк
m.mean(axis=0)
array([5, 7, 9])
array([ 6, 15])
array([2.5, 3.5, 4.5])
Правило: axis=0 схлопывает «вертикаль» — результат по столбцам (для (2,3) → длина 3); axis=1 — «горизонталь» (длина 2). Тест: какая размерность исчезает — та и ось.
Другие агрегации: np.std, np.var, np.min/max, np.unique, np.count_nonzero.
Какая размерность исчезает при axis=0?
Какой shape у m.sum(axis=0) для (4,5)?
m = np.arange(20).reshape(4, 5)
m.sum(axis=0).shape
Какой shape вернёт? Почему не (4,)? axis=0 удаляет ось строк — остаётся 5 чисел.
argmax без axis — плоский индекс; с axis — по строкам/столбцам
m = np.arange(20).reshape(4, 5)
np.argmax(m) # плоский индекс глобального максимума
np.argmax(m, axis=1) # индекс максимума в каждой строке
np.count_nonzero(m > 15) # число элементов > 15
np.unique([1, 2, 2, 3]) # уникальные значения
19
array([4, 4, 4, 4])
4
array([1, 2, 3])
Правило: без axis argmax ищет по «расплющенному» массиву (0..19); с axis — построчно/постолбцово. count_nonzero — число истинных в маске; unique — отсортированные уникальные значения.
Как найти индексы элементов, больших порога, и заменить их на порог?
a>3 and a<7 → ValueError; для массивов — только & со скобками
a = np.arange(12).reshape(3, 4)
a > 3 and a < 7
ValueError: The truth value of an array with more
than one element is ambiguous. Use a.any() or a.all()
Разбор: and требует одного булева значения, а у массива таких значений много — результат неоднозначен. Исправление: (a > 3) & (a < 7).
Что говорит последняя строка traceback? Что значит «ambiguous»?
Типичные ошибки недели 2
a > 3 and a < 7: and не работает с массивами; нужно & и скобки (слайд 30).
- Путаница
axis=0 / axis=1: не понимают, по какой оси схлопывается (слайд 27).
- Неожиданная мутация из-за view: исходный массив меняется там, где ждали копию (слайды 7–8).
- «NumPy сам разберётся»: не проверяют
.shape → ValueError (слайд 21).
np.where vs маска: np.where возвращает значения, np.argwhere — индексы (слайд 15).
Перед какой операцией всегда полезно вывести .shape?
normalize/standardize — подготовка признаков к регрессии (проект)
def normalize(x: np.ndarray) -> np.ndarray:
x_min = x.min(axis=0)
span = x.max(axis=0) - x_min
return np.where(span == 0, 0.0, (x - x_min) / span)
normalize(np.array([[0., 2., 10.], [4., 2., 20.]]))
array([[0., 0., 0.],
[1., 0., 1.]])
Правило: нормализация — обязательная подготовка признаков перед регрессией (проект модуля); edge case константного столбца закрыт np.where. То же самое сделает sklearn.preprocessing.MinMaxScaler.
Это ровно задача ДЗ недели 2 и кирпич регрессии недели 3.
Сегодня вы научились…
- выбирать данные: индексация, срезы, fancy, маски,
np.where;
- управлять view/копией (правило «срез=view, fancy=копия»);
- применять broadcasting по правилу сравнения форм справа налево;
- предсказывать
ValueError и лечить его проверкой .shape;
- агрегировать с правильной осью;
- нормализовать признаки без циклов.
One-minute paper: запишите (1) главное, что поняли; (2) один вопрос, что остался.
Что дальше
- Семинар недели 2: «Индексация и срезы» (базовый), «Нормализация и маскирование выбросов» (стандартный), «Broadcasting + агрегация» (продвинутый).
- ДЗ недели 2:
normalize и standardize без циклов (autograder, task-02). Edge case: константный столбец → нули, не NaN.
- Анонс недели 3: «Линейная алгебра и случайные числа» — матричное умножение
@, решение СЛАУ, SVD-сжатие изображений, Монте-Карло.
На этой неделе вы подготовили данные; на следующей — построите на них первую модель.
Готовы? Откройте семинар недели 2 и выполните базовый уровень.