Математика везде в нашей жизни, но в программировании, а особенно 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 - путь к лучшему пониманию машинного обучения в целом.