Математика везде в нашей жизни, но в программировании, а особенно ML ее два раза больше. Обычно Питон берут в пример самого "научного" языка программирования из-за математических фреймворков. Как не Питон может помочь оперировать математическими абстракциями, некоторые из сферы ресерча пользуются исключительно питоном для всяких научных изысканий — сегодня мы поговорим про библиотеку NumPy и работу с массивами.
Самая новичковая "библиотека" с примочками в виде SciPy и Matplotlib предназначена для работы с многомерными массивами. NumPy – основа для многих других библиотек для машинного обучения, таких как SciPy, Pandas, Scikit-learn и TensorFlow.
Pandas, например, строится поверх NumPy и позволяет работать со структурами данных высокого уровня по типу DataFrame и Series. При помощи NumPy можно проводить преобразование категориальных данных в числовой формат, например, с использованием кодирования one-hot.
NumPy реализована на C, на уровне низких абстракций, поэтому вся работа с библиотекой не протекает в формате "ждем два часа компиляции кода" - библиотека написана на низкоуровневом языке для максимальной скорости и эффективности.
Массивы = работа с классическими матрицами и векторами, иначе многомерными массивами/ndarray. В распоряжении самые простые функции для элементов внутри sin/cos/or/and, линейные операции для самих матриц от нахождения определителя до их перемножения, поддержка векторизации — все это и есть наш кратчайший путь к математическим абстракциям, ML.
Предварительно поставить утилиту можно через pip install numpy, импортировать через import numpy as np
Пробежимся по самым простым "операциям" в библиотеке. Главный объект — массивы. NumPy предоставляет несколько способов создания массивов.
Наиболее распространенные из них про создание:
Cоздание массивов из списков.
Создание массивов заданного размера или формы с начальными значениями
Массивы нулей или массивы с рандомными элементами.
Генерируем стандартные списки массивы
Из списка:
arr = np.array([1, 2, 3, 4, 5], float)
На входе нам предоставлено два аргумента: список, конвертируемый в массив и тип данных.
Из заданного размера и начальных значений
zeros_arr = np.zeros((3, 3)) # Массив нулей размером 3x3
ones_arr = np.ones((2, 2)) # Массив единиц размером 2x2
Да, внутри Numpy можно быстро задать массив любой размерности с одинаковыми элементами.
Массив с рандомизированными значениями.
rand_arr = np.random.rand(3, 3) # Массив 3x3 со случайными значениями
У массивов есть несколько особенностей:
- Размер массива фиксируется и поменяться после создания не может. С одной стороны, мы теряем в гибкости и некотором функционале – с другой, так библиотека выполняет операции с массивами быстрее.
- Все элементы должны иметь одинаковый тип данных.
Но задавать можно не только целочисленные значения – можно выбирать тип данных.
Для создания многомерных массивов, нам следует просто прописать разные оси через запятую.
matrix = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
#двумерный массив 3x3.
tensor = np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]], [[9, 10], [11, 12]]])
#наш маленький трехмерный тензор 3х2х2.
Как прооперировать наши миниматрицы?
Индексация и нарезка — это способы доступа к элементам и подмассивам в массивах NumPy.
Напоминаем, что индексация начинается с 0.
Для доступа к элементам массива указывается их индекс или индексы в квадратных скобках, разделенные запятыми для многомерных массивов. Можно использовать отрицательные индексы для обращения к элементам с конца массива. Прописывать мы пример не будем, достаточно просто ввести матрицу в аргумент функции при вызове.
Нарезка позволяет выбирать подмассивы по заданным диапазонам индексов с использованием синтаксиса [start:stop:step],
где start – начальный индекс (включается),
stop – конечный индекс (не включается),
step – шаг.
Выглядит это примерно так:
arr1d = np.array([1, 2, 3, 4, 5])
# Нарезка для выбора подмассива
print(arr1d[1:4]) # Получаем [2 3 4] (элементы с индексами от 1 до 3)
print(arr1d[:3]) # Получаем [1 2 3] (элементы до индекса 3)
print(arr1d[::2]) # Получаем [1 3 5] (каждый второй элемент)
# Тоже самое можно проделать с двумерными матрицами
arr2d = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
# Нарезка для выбора подматрицы
print(arr2d[0:2, 1:])
# Вывод: [[2 3] [5 6]] (подматрица с 1 по последний столбец в строках с 0 по 1)
print(arr2d[:2, :2])
# Вывод: [[1 2] [4 5]] (подматрица в строках с 0 по 1 и столбцах с 0 по 1)
Как управлять элементами матрицы (творить с ними все, что захочется)
В этом материале мы не будем расписывать, например, применение логического "Или" и "И", так как это слишком просто — совсем новичку стоит открыть документацию. Но есть парочка интересных функций, с которыми можно поработать в перспективе и сократить время на написание "костылей".
Например, в Numpy есть статистические функции
np.mean() - среднее значение элементов массива.
np.median() - медиана элементов массива.
np.std(): стандартное отклонение элементов массива.
np.var(): дисперсия элементов массива.
np.percentile() - квантили массива.
Или бродкастинг, про который некоторые не знают.
Например, функция np.broadcast() позволяет выполнять поэлементные операции между массивами различных форм и размерностей. Этот объект не массив, но информирует о том, как выполнять операции с массивами разной формы без явного копирования данных или изменения их формы.
arr1 = np.array([1, 2, 3]) # Форма (3,)
arr2 = np.array([[4], [5], [6]]) # Форма (3, 1)
broadcasted = np.broadcast(arr1, arr2)
print(broadcasted.shape)
Мы позаимствовали с Pinterest даже небольшую схемку с отображениями “представлений массивов”.
А еще в NumPy можно работать с многочленами. Если уж любить линейную математику, то до конца. Создать многочлен можно через функцию np.polyid(). И некоторые "специальные" виды уравнений:
np.polynomial.legendre.Legendre() – многочлены Лежандра.
np.polynomial.chebyshev.Chebyshev() – многочлены Чебышева.
np.polynomial.laguerre.Laguerre() – многочлены Лагерра.
Например, полиномы Чебышева используются в исчислимых методах аппроксимации или интерполяции.
# Создаем полином Чебышева первого рода
chebyshev_poly = np.polynomial.chebyshev.Chebyshev([1, 0, -1]) # Многочлен: x^2 - 1
# Вычисление значения многочлена в заданной точке
x = 0.5
value = chebyshev_poly(x)
# Интегрирование многочлена
integral = chebyshev_poly.integrate()
Работаем с матрицами/массивами по взрослому
В библиотеке NumPy доступно множество операций из линейной алгебры, которые позволяют работать с векторами, матрицами и другими линейными структурами данных.
Умножение матриц (np.dot()), транспонирование матриц (np.transpose()), нахождение обратной матрицы (np.linalg.inv()), решение систем линейных уравнений (np.linalg.solve()) и нахождение собственных значений и собственных векторов (np.linalg.eig()).
На примере решения системы линейных уравнений:
Предположим, у нас есть задача обучения с учителем, где у нас есть набор данных для обучения, состоящий из признаков (входных данных) и соответствующих целевых переменных. Мы хотим найти параметры модели, которая лучше всего аппроксимирует наш набор данных.
В контексте линейной модели, где мы строим связь между входными данными и целевыми значениями, мы ищем оптимальные веса и смещение для этой модели. Мы представляем эту связь в виде уравнения, где входные данные умножаются на веса и добавляется смещение. Наша цель — найти такие веса и смещение, чтобы минимизировать ошибку модели.
Функция np.linalg.solve() решает систему линейных уравнений, которая моделирует это уравнение. Мы представляем эту систему в виде матрицы, где строки представляют собой примеры данных, а столбцы — признаки. Мы находим оптимальные значения весов и смещения путем решения этой системы уравнений. Эти оптимальные параметры позволяют нам построить и оценить линейную модель для наших данных в задачах машинного обучения.
В коде выглядит это как-то так:
# Создаем матрицы признаков X и вектора целевых переменных y (пример)
X = np.array([[1, 2], [3, 4], [5, 6]])
y = np.array([3, 7, 11])
# Решаем линейное уравнение
w, b = np.linalg.solve(X.T @ X, X.T @ y)
print(" Тут у нас оптимальные значения весов:")
print(w)
print(" А тут оптимальное значение смещения:")
print(b)
Неочевидные функции:
Да, действительно в NumPy достаточно функций и перечислить все — растянуть материал на пару десятков килознаков. Но есть несколько неочевидных решений, которые упростят работу в моменте.
np.where() позволяет выполнять условное индексирование. Она возвращает индексы элементов, удовлетворяющих заданному условию. Можно также использовать ее для замены значений в массиве по определенному условию.
arr = np.array([1, 2, 3, 4, 5])
indices = np.where(arr > 2)
print(indices) # Вывод: (array([2, 3, 4]),)
np.unique() – функция возвращает уникальные значения в массиве в отсортированном порядке. Это может быть полезно для удаления дубликатов или анализа уникальных значений.
arr = np.array([1, 2, 3, 1, 2, 4, 5])
unique_values = np.unique(arr)
print(unique_values) # Вывод: [1 2 3 4 5]
np.clip() – функция обрезает значения массива, чтобы они попадали в определенный диапазон. Это может быть полезно для ограничения значений массива сверху и снизу.
arr = np.array([1, 2, 3, 4, 5])
clipped_arr = np.clip(arr, 2, 4)
print(clipped_arr) # Вывод: [2 2 3 4 4]
np.ravel() "выпрямляет" массив, превращая многомерный массив в одномерный. Это может быть удобно для быстрого преобразования массива в одномерный вид.
arr = np.array([[1, 2, 3], [4, 5, 6]])
raveled_arr = np.ravel(arr)
print(raveled_arr) # Вывод: [1 2 3 4 5 6]
Простой пример или проба NumPy на линейной регрессии без сторонних библиотек и фреймворков
Мы создаем массивы NumPy для представления обучающих данных, добавляем столбец с единицами к входным признакам для учета свободного члена в модели и вычисляем параметры модели (веса) с использованием нормального уравнения для линейной регрессии.
X_train = np.array([[1], [2], [3], [4], [5]]) # Входные признаки (одномерный массив)
y_train = np.array([2, 4, 5, 4, 5]) # Целевая переменная
# Добавляем столбец с единицами для учета свободного члена в модели
X_train_with_bias = np.c_[np.ones((X_train.shape[0], 1)), X_train]
# Обучение модели линейной регрессии
theta = np.linalg.inv
(X_train_with_bias.T.dot(X_train_with_bias)).dot(X_train_with_bias.T).dot(y_train)
# Вывод коэффициентов модели
print("Коэффициенты модели:", theta)
# Предсказание на новых данных
X_test = np.array([[6], [7]]) # Новые входные признаки
X_test_with_bias = np.c_[np.ones((X_test.shape[0], 1)), X_test]
y_pred = X_test_with_bias.dot(theta)
# Вывод предсказанных значений
print("Предсказанные значения:", y_pred)
Функция np.linalg.inv() используется для вычисления обратной матрицы.
Затем мы создаем новые входные признаки для предсказания и выполняем предсказание значений целевой переменной с использованием обученных параметров модели.
И не нужно нам писать: используйте обычные списки. Мы просто посмеемся. Хотя пример карикатурный, но весь смысл NumPy – базовая библиотека для линейных операций, коих в ML много и дальше больше.
В этой статье мы быстренько пробежались по функционалу библиотеки и даже показали на примере, как построить простую линейную регрессию. Самое большое наставление, которое можно дать любому начинающему ML/Data-специалисту - математика наше все. Благодаря рукотворно написанным ресерчам мы и получили прекрасные GPT, иначе трансформаторы и благодаря линейной алгебре мы можем прописывать "градиентный" бустинг.
Писать на низком уровне абстракций, мы не про ассемблер и С, попробовать обозначать математику через такие библиотеки как NumPy - путь к лучшему пониманию машинного обучения в целом.