Часть 1: Простые матрицы и линейная регрессия
Серия Мотивация
Я был инженером MLOps в течение последних 5 лет, и хотя моя работа не связана с повседневным написанием низкоуровневых функций машинного обучения, я хотел убедиться, что понимаю, как все работает в условиях капот.
В дополнение к этому я хочу учиться/совершенствоваться в Rust. И судя по всему — ты тоже!
Моя цель — изучить rust и получить практическое представление о методах машинного обучения. Для этого я хочу написать очень простую библиотеку, способную взять массив любого размера и измерения, а затем применить к нему несколько очень простых методов машинного обучения. Окончательным тестом будет использование этой библиотеки для глубокого обучения распознаванию изображений.
Начальные замечания
Мой любимый способ научиться чему-либо — начать с наивной точки зрения, а затем столкнуться с проблемами все более сложной сложности и решить их. Таким образом, остается меньше места для любых логических или интеллектуальных скачков, которые обычно расстраивают меня, когда я пытаюсь учиться.
Этот итеративный процесс также помогает в определении направления и основывает процесс разработки на определенной дисциплине, препятствуя работе над вещами, которые не имеют прямого отношения к поставленной задаче.
Я собираюсь следовать этому подходу, поэтому я начну с очень простого и буду делать ошибки (иногда преднамеренные) на этом пути.
Часть 1 Описание
В части 1 этой серии мы начнем абсолютно ни с чем и закончим очень простой структурой Matrix
и алгоритмом LinearRegression
, который работает с такими Matrix
как ввод и вывод.
Какой API мы хотим, чтобы линейная регрессия имела?
Всегда очень хорошо начинать процесс разработки с продумывания того, как будет использоваться наш код. Мы хотим, чтобы наши пользователи могли создавать LinearRegression
структуры, что бы они ни включали. Мы хотим, чтобы они могли сопоставить строку LinearRegression
с некоторыми данными, и мы хотим, чтобы они могли использовать этот экземпляр LinearRegression
для прогнозирования значений для других точек данных. Следующий API — это первое, что приходит на ум.
struct LinearRegression{ /* some fields */ } impl LinearRegression { pub fn new() -> Self { todo!() } pub fn fit(&mut self, x: Matrix, y: Matrix) { todo!() } pub fn predict(&self, x: f64) -> f64 { todo!() } }
Какого типа должна быть Матрица?
Матрица представляет собой двумерный массив со строками и столбцами. Очень распространенный способ представления матриц - это вложенный список списков:
let matrix = [ [1, 2, 3], [4, 5, 6], [7, 8, 9], ];
и мы могли бы очень легко начать с определения нашего типа Matrix
как векторов ржавчины!
type Matrix = Vec<Vec<f64>>;
Проблема №1 — сохранение данных в валидности
Используя приведенное выше объявление типа Matrix
, мы сразу же сталкиваемся с очень большой проблемой. Что произойдет, если мы напишем такой код?
let input: Matrix = vec![ vec![1], vec![3], vec![4, 5], ]; let output: Matrix = vec![ vec![0], vec![1], vec![2], ]; let regression = LinearRegression::new(); regression.fit(input, output);
input
не является допустимой матрицей — ее последняя строка содержит больше элементов, чем строки, предшествующие ей. Что бы мы сделали, если бы попытались использовать эту матрицу для построения нашей линии регрессии? Мы могли бы подтвердить! В этом случае функция fit
должна будет вернуть Result
, потому что потенциально она может завершиться ошибкой, если переданная матрица недействительна.
impl LinearRegression { /* ... */ pub fn fit(&mut self, x: Matrix, y: Matrix) -> Result<(), String> { if !validate_matrix(x) { return Err("x is not a valid Matrix!".to_owned()); } if !validate_matrix(y) { return Err("y is not a valid Matrix!".to_owned()); } todo!() } /* ... */ }
Но в rust есть мощная система типов, и здесь мы можем использовать ее в своих интересах. Как очень хорошо объяснено в разделе Разбирать, не проверять — мы могли бы вместо этого убедиться, что Matrix
в нашем коде rust всегда действителен!
Как мы можем сделать это? Обернув Vec<Vec<f64>>
в нашу собственную структуру, а затем проанализировав любые данные, которые используются для инициализации Matrix
.
struct Matrix { data: Vec<Vec<f64>>, } impl Matrix { pub fn new(data: Vec<Vec<f64>>) -> Result<Self, String> { let inner_dim = match data.first() { Some(first) => first.len(), None => 0, }; for (dim, inner_vec) in data.iter().enumerate() { if inner_vec.len() != inner_dim { return Err(format!("vector in position {dim} does not match the inferred inner dimension of {inner_dim}")); } } Ok(Self{ data }) } }
Таким образом, если мы когда-нибудь попытаемся передать неверные данные в функцию Matrix::new
, она вернет Result::Error
.
#[cfg(test)] mod tests { use super::*; #[test] fn test_only_valid_matrices() { // This matrix is fine! let matrix = Matrix::new( vec![ vec![1, 2, 3], vec![1, 2, 3], vec![1, 2, 3], ] ); assert!(matrix.is_ok()); // This matrix should not be possible - it's last row has less elements // than all the other rows let matrix = Matrix::new( vec![ vec![1, 2, 3], vec![1, 2, 3], vec![1, 2], ] ); assert!(matrix.is_err()); } }
Теперь у нас есть уверенность, что если мы примем Matrix
в качестве входных данных для нашей функции LinearRegression::fit
, она всегда будет действительна! Анализируя данные в момент создания экземпляра, мы можем удалить любую проверку, которая в противном случае была бы необходима.
В том же духе — если мы когда-нибудь создадим нашу структуру LinearRegression
следующим образом:
let regression = LinearRegression::new(); /* HERE! */ regression.fit(Matrix::new(vec![vec![1.]]), Matrix::new(vec![vec![10.]]));
между строками 1 и 3 (где я добавил комментарий «ЗДЕСЬ!») линейная регрессия как бы… бесполезна! Его линия еще не была смоделирована на основе каких-либо данных, поэтому она всегда будет возвращать одни и те же (и скорее мусорные) прогнозы. И здесь снова — мы могли бы положиться на пользователя, чтобы убедиться, что он правильно использует нашу библиотеку, но поскольку мы используем ржавчину — мы можем удалить такое «бесполезное» состояние LinearRegression
даже при компиляции!
struct LinearRegression { /* some fields */ } impl LinearRegression { // The difference is here: // 1. I removed the `pub fn new` line // 2. And instead made the `pub fn fit` be the // entrypoint. I also dropped the `Result` type // as it's not needed - the `Matrix` type is always valid! pub fn fit(x: Matrix, y: Matrix) -> Self { todo!() } pub fn predict(data: Matrix) -> Matrix { todo!() } }
Проблема 1. Что произойдет, если пользователи нашей библиотеки передают неверные данные в линейную регрессию?
Решение 1. Мы анализируем все данные по мере создания Matrix
, чтобы Matrix
всегда содержало только действительные данные!
Проблема №2 — выравнивание данных
Эта проблема требует некоторого взгляда в будущее и небольшого прыжка веры. Это связано с формой нашей матрицы, и что произойдет, если мы когда-нибудь захотим расширить ее в будущем? Наша матрица имеет только 2 измерения, но в будущем мы можем захотеть обобщить нашу библиотеку для N-мерных массивов. Кроме того, что произойдет, если мы захотим «изменить» нашу матрицу? Допустим, наша матрица представляет изображение в градациях серого — каждое число f64
представляет собой значение пикселя между 0
и 1
, представляющее, насколько оно белое или черное.
Очень пикселизированное изображение, представляющее 0, может выглядеть примерно так, как наша матрица:
let matrix = Matrix::new( vec![ vec![1, 1, 1], vec![1, 0, 1], vec![1, 1, 1], ], );
Его размеры: 3 x 3
. 3 строки и 3 столбца.
Однако в будущем — если мы захотим передать эти данные, например, в нейронную сеть — мы на самом деле захотим объединить эти данные в массив 1 x 9
. (Если это не очевидно — здесь требуется небольшой прыжок веры.)
let matrix = matrix.reshape(vec![1, 9]);
Как будет выглядеть такая функция reshape
?
impl Matrix { /* ... */ pub fn reshape(self, new_shape: Vec<usize>) -> Result<Self, String> { let old_shape: Vec<usize> = self.data.iter().map(|inner| inner.len()).collect(); let old_total_size: usize = old_shape.iter().product(); let new_total_size: usize = new_shape.iter().product(); if old_total_size != new_total_size { return Err(format!("Matrix of shape {:?} cannot be reshaped into Matrix of shape {:?}", old_shape, new_shape)); } let mut data = vec![vec![0.0; new_shape[1]]; new_shape[0]]; let mut num_iter = self.data.into_iter().flatten(); for outer_idx in 0..new_shape[0] { for inner_idx in 0..new_shape[1] { // .unwrap() here is safe because we already checked that the dimensions match // if .unwrap() here panics - it means that our old matrix was somehow invalid to begin with, // and that's a bug with the library, not an error that we should propagate to the user! data[outer_idx][inner_idx] = num_iter.next().unwrap(); } } Ok(Self{ data }) } }
Вы видите здесь большую проблему? Мы обращаемся к числам из self.data
по порядку — num_iter.next()
в каждой итерации цикла. Таким образом, сглаженные данные как в старом, так и в новом Matrix
будут точно такими же, единственная разница заключается в размерах внешнего и внутреннего Vec
. Чтобы прояснить этот момент, рассмотрим следующий пример:
let matrix1 = Matrix::new(vec![vec![1, 2, 3]]); // shape (1 x 3) let matrix2 = Matrix::new(vec![vec![1], vec![2], vec![3]]); // shape (3 x 1)
Данные в matrix1
и matrix2
одинаковы, если бы не тот факт, что мы по-разному «распределяем» их между внешним и внутренним векторами. И хотя все числа внутри matrix1
и matrix2
одинаковы — нам нужно воссоздавать (копировать) внутренний и внешний векторы при каждом изменении формы. Это пустая трата времени — именно из-за найденного нами инварианта (номера по порядку совпадают). Вместо того, чтобы копировать все числа при каждом изменении формы, что, если бы вместо этого мы сохранили числа в плоской форме для начала, а чтобы узнать, каковы внутренние и внешние измерения, мы могли бы сохранить форму вместе с данными вот так :
struct Matrix { data: Vec<f64>, shape: [usize; 2], } fn check_shape_matches_data(data_size: usize, new_shape: &[usize; 2]) -> Result<(), String> { let shape_size = new_shape[0] * new_shape[1]; if data_size != shape_size { return Err(format!("shape {new_shape:?} doesn't match data of size {data_size}")); } Ok(()) } impl Matrix { pub fn new(data: Vec<f64>, shape: [usize; 2]) -> Result<Self, String> { check_shape_matches_data(data.len(), &shape)?; Ok(Self { data, shape }) } pub fn reshape(mut self, new_shape: [usize; 2]) -> Result<Self, String> { check_shape_matches_data(self.data.len(), &new_shape)?; self.shape = new_shape; Ok(self) } }
[usize; 2]
похоже на Vec<usize>
, но мы гарантируем, что оно всегда будет иметь ровно 2 элемента (поскольку наша матрица имеет только 2 измерения). Вы можете видеть, как теперь нам просто нужно сравнить общий размер data
vec с формой (как в Matrix::new
, так и в Matrix::reshape
. Нам не нужно перебирать какие-либо внутренние векторы и проверять, совпадают ли внутренние размеры и т. д. (В в будущем — когда мы обобщим на N-меры — мы также увидим, как отсутствие вложенных векторов упрощает это обобщение. Вы уже видите, как?).
Но самое главное — функция Matrix::reshape
теперь вообще не манипулирует базовыми данными матрицы! Каждое изменение формы теперь очень дешевая операция.
Проблема 2: изменение матрицы на основе вложенных Vec
относительно сложно и дорого.
Решение 2. Выровняйте поле data
и сохраните shape
рядом!
Проблема №3 — доступ к данным
Наш Matrix
выглядит все лучше и лучше! Но поскольку мы остановились на довольно хорошем базовом решении того, как данные будут храниться, нам нужно начать думать о том, как к ним будет осуществляться доступ. Вы видите в настоящее время, если мы хотим сделать что-то вроде этого:
let matrix = Matrix::new(vec![1, 2, 3], [3, 1]).unwrap(); do_something_with_data(matrix.data); do_something_with_shape(matrix.shape);
мы получим ошибку! Ни data
, ни shape
не являются публичными полями! Поэтому мы не можем получить к ним прямой доступ, пока не сделаем их общедоступными. Должны ли мы это сделать?
struct Matrix { pub data: Vec<f64>, pub shape: [usize; 2], }
Это ужасная идея! Рассмотрим следующий пример:
let mut matrix = Matrix::new(vec![1, 2, 3], [3, 1]).unwrap(); matrix.data.push(10);
Вы видите, что не так? Теперь мы разрешили пользователям Matrix
аннулировать его внутреннюю согласованность. data
и shape
не совпадают, и ничто не мешает этому! Мы нарушили наш контракт — Matrix
недействительно всегда!
Альтернативой является использование инкапсуляции — держите поля закрытыми и предоставляйте функциональность только по мере необходимости через связанные функции и методы. Одной из таких функций может быть возможность видеть форму нашей матрицы (без возможности изменить ее — reshape
уже предоставляет это).
struct Matrix { data: Vec<f64>, shape: [usize; 2], } impl Matrix { /* ... */ pub fn get_shape(&self) -> &[usize; 2] { &self.shape } }
Сладкий! Красиво и просто!
Но что, если бы мы захотели посмотреть, какие значения находятся в поле data
, или что, если бы мы захотели их изменить?
Заманчиво начать писать что-то вроде этого:
impl Matrix { /* ... */ pub fn get(&self, index: [usize; 2]) -> f64 { todo!() } pub fn get_mut(&mut self, index: [usize; 2]) -> &mut f64 { todo!() } }
Но мы можем сделать это поведение совместимым с общим оператором индексации Rust, реализовав трейты оператора Index
и IndexMut
для нашего Matrix
.
use std::ops::{Index, IndexMut}; fn convert_index(shape: &[usize; 2], index: &[usize]) -> usize { index[0] * shape[1] + index[1] } impl Index<[usize; 2]> for Matrix { type Output = f64; fn index(&self, index: [usize; 2]) -> &Self::Output { let data_index = convert_index(&self.shape, &index); &self.data[data_index] } } impl IndexMut<[usize; 2]> for Matrix { fn index_mut(&mut self, index: [usize; 2]) -> &mut Self::Output { let data_index = convert_index(&self.shape, &index); &mut self.data[data_index] } } impl Index<&[usize]> for Matrix { type Output = f64; fn index(&self, index: &[usize]) -> &Self::Output { if index.len() != self.shape.len() { panic!("index must have length {}", self.shape.len()); } let data_index = convert_index(&self.shape, index); &self.data[data_index] } } impl IndexMut<&[usize]> for Matrix { fn index_mut(&mut self, index: &[usize]) -> &mut Self::Output { if index.len() != self.shape.len() { panic!("index must have length {}", self.shape.len()); } let data_index = convert_index(&self.shape, index); &mut self.data[data_index] } }
Это позволяет пользователям нашего Matrix
индексировать его напрямую следующим образом:
let matrix = Matrix::new(vec![1, 2, 3, 4], [2, 2]); println!("{}", matrix[[1, 0]]); // prints "3" matrix[[1, 0]] = 10; println!("{}", matrix[[1, 0]]); // prints "10"
Проблема 3.Пользователи Matrix
не могут получить доступ ни к data
, ни к shape
.
Решение 3.Мы решили оставить поля закрытыми, но предоставить определенные функции, такие как индексирование или заимствование формы, с помощью методов и реализации свойств оператора.
Линейная регрессия
Теперь у нас есть все необходимое для реализации элементарной линейной регрессии. Я собираюсь использовать следующую формулу для моделирования линии регрессии.
LinearRegression::fit
принимает два вектора — x
и y
. x
— это входные данные формы [N, 1]
— всего N
строк — и каждая строка имеет только 1 размер (x на числовой прямой). y
— ожидаемый результат. Он должен иметь те же размеры, что и x
.
pub struct LinearRegression { intercept: f64, // a in the equation coefficient: f64, // b in the equation } impl LinearRegression { pub fn fit(x: Matrix, y: Matrix) -> Self { let shape_x = x.get_shape(); let shape_y = y.get_shape(); if shape_x[1] != 1 { panic!("LinearRegression works only with 1 dimensional data points"); } if shape_y[1] != 1 { panic!("LinearRegression works only with 1 dimensional data points"); } if shape_x[0] != shape_y[0] { panic!("shape of x and y do not match"); } // Calculate mean_x and mean_y let n = shape_x[0]; let mut mean_x = 0.; let mut mean_y = 0.; for i in 0..n { mean_x += x[[i, 0]]; mean_y += y[[i, 0]]; } mean_x /= n as f64; mean_y /= n as f64; // Calculate numerator and denominator; let mut numerator = 0.; let mut denominator = 0.; for i in 0..n { let x_err = x[[i, 0]] - mean_x; let y_err = y[[i, 0]] - mean_y; numerator += x_err * y_err; denominator += x_err * x_err; } let coefficient = numerator / denominator; let intercept = mean_y - coefficient * mean_x; Self { intercept, coefficient } } pub fn predict(&self, x: f64) -> f64 { self.intercept + x * self.coefficient } }
Это работает!
#[cfg(test)] mod tests { use super::*; #[test] fn test_predict() { let regression = LinearRegression { intercept: 5., coefficient: 2.3 }; assert_eq!(regression.predict(10.), 28.); assert_eq!(regression.predict(99.), 232.7); } #[test] fn test_fit() { let x = Matrix::new(vec![1., 2., 3., 4., 5., 6.], [6, 1]).unwrap(); let y = Matrix::new(vec![1., 1.25, 1.5, 1.75, 2., 2.25], [6, 1]).unwrap(); let regression = LinearRegression::fit(x, y); assert_eq!(regression.intercept, 0.75); assert_eq!(regression.coefficient, 0.25); assert_eq!(regression.predict(99.), 25.5); } }
Следующие шаги
Во второй части я собираюсь очистить наш код линейной регрессии, внедрить дополнительные трейты оператора, чтобы включить арифметические операции над векторами, внедрить широковещательную рассылку и начать обобщать матрицу до N измерений.
Обратная связь
Я @bamwasylow в Твиттере — любой отзыв приветствуется!