Более пяти лет назад я опубликовал свою самую успешную статью на Medium. Она появилась из-за необходимости отфильтровать данные особенно шумного датчика из потока телематических данных. Если говорить конкретно, то это был датчик крутящего момента, подключенный к карданному валу грузовика, и от шума нужно было избавиться. Решение было найдено с помощью внедренного в Python алгоритма LOESS (locally estimated scatterplot smoothing — локально взвешенное сглаживание диаграммы рассеяния). Это решение и было описано в статье.
Тогда я с головой был погружен в Python, а проект требовал использования Spark, поэтому реализовать алгоритм на Python было несложно. Однако теперь чаще использую Rust и решил попробовать перевести на него старый код. В этой статье описан процесс переноса и мой выбор решений при переписывании кода. Чтобы больше узнать об алгоритме, советую обратиться к исходной статье и справочным материалам. А здесь сосредоточимся на тонкостях написания матричного кода на Rust, с максимальной точностью заменяющего раннюю реализацию NumPy.
Численные вычисления на Rust
Не собираясь изобретать велосипед, я поискал рекомендованные Rust-крейты для замены NumPy в исходном Python-коде. Обнаружение nalgebra не заняло много времени.
nalgebra — это библиотека линейной алгебры общего назначения с низкой размерностью и оптимизированным набором инструментов для работы с компьютерной графикой и физикой.
Хотя в данном случае занятия физикой или компьютерной графикой нас не интересуют, требование низкой размерности подходит как нельзя лучше.
Различия
При переводе Python-кода на Rust я столкнулся с некоторыми трудностями, на преодоление которых ушло немало времени. При использовании NumPy в Python доступны все возможности как языка, так и библиотеки для повышения выразительности и читабельности кода. Rust более многословен, чем Python, и на момент написания этой статьи (версия 0.33.0) крейту nalgebra все еще не хватало возможностей, позволяющих повысить выразительность кода. Лаконичность nalgebra — это проблема.
Первым препятствием для меня стало индексирование массивов с помощью других массивов. NumPy позволяет индексировать массив с помощью другого массива целых чисел или булевых значений. В первом случае каждый элемент индексирующего массива является индексом исходного массива, а индексатор может иметь размерность, равную или меньшую, чем массив данных. В случае булевого индексирования индексатор должен иметь тот же размер, что и данные, и каждый элемент должен указывать, включать ли соответствующий элемент данных. Эта возможность удобна при использовании булевых выражений для выбора данных.
Я использовал эту возможность во всем Python-коде:
# Python
xx = self.n_xx[min_range]
Здесь min_range
— переменная в целочисленном массиве, содержащем подмножество индексов для извлечения из массива self.n_xx
.
Как ни старался, я так не смог найти в Rust-крейте решение, имитирующее индексацию NumPy, поэтому пришлось внедрить его. После нескольких попыток и бенчмарков пришел к финальной версии. Это решение было простым и эффективным.
// Rust
fn select_indices(values: &DVector<f64>,
indices: &DVector<usize>) -> DVector<f64> {
indices.map(|i| values[i])
}
Выражение map довольно простое, но использование имени функции более выразительно, поэтому я заменил приведенный выше Python-код на соответствующий Rust-код:
// Rust
let xx = select_indices(&self.xx, min_range);
Не было и встроенного метода для создания вектора из диапазона целых чисел. Хотя эту задачу легко выполнить с помощью nalgebra, код получается длинноватым:
// Rust
range = DVector::<usize>::from_iterator(window, 0..window);
Можно избежать большей части этой записи, если зафиксировать размеры вектора и массива во время компиляции. Но здесь мне не повезло, поскольку размеры неизвестны. Соответствующий код на Python более лаконичен:
# Python
np.arange(0, window)
Подобная лаконичность распространяется и на другие области, например при построчном заполнении матрицы. В Python можно сделать примерно так:
# Python
for i in range(1, degree + 1):
xm[:, i] = np.power(self.n_xx[min_range], i)
На данный момент я не нашел лучшего способа сделать то же самое с nalgebra, чем этот:
// Rust
for i in 1..=degree {
for j in 0..window {
xm[(j, i)] = self.xx[min_range[j]].powi(i as i32);
}
}
Возможно, я просто не обнаружил в пакете то, что помогло бы в плане краткости.
Документация по nalgebra показалась мне относительно скудной. Этого можно было ожидать от относительно молодого Rust-крейта, подающего большие надежды на будущее.
Позитивный аспект
Самое интересное ожидало меня в конце — номинальная производительность. Попробуйте запустить обе версии одного и того же кода (ссылки на репозитории GitHub приведены ниже) и сравнить их производительность. На моем MacBook Pro 2019 года с 6-ядерным процессором Intel Core i7 2,6 ГГц релизная версия Rust-кода выполняется менее чем за 200 микросекунд, а Python-код — менее чем за 5 миллисекунд.
Заключение
Этот проект стал еще одним увлекательным и познавательным переносом старого кода с Python на Rust. В то время как преобразование известных управляющих структур Python на рельсы Rust с каждым днем становится все более доступным, переход с NumPy на nalgebra оказался более сложной задачей. Пакет Rust демонстрирует большие перспективы, но нуждается в обширной документации и онлайн-поддержке. Я бы горячо приветствовал более подробное руководство пользователя.
Rust более «протокольный» чем Python, но при правильном использовании работает гораздо лучше. Буду продолжать работать с Python для повседневной работы при создании прототипов и в режиме открытий, но при переходе к производству обращусь к Rust для повышения производительности и безопасности памяти. Думаю, что беспроигрышным вариантом будет смешение и сочетание обоих языков с помощью таких крейтов, как PyO3.
Ссылки:
Ассистент, созданный на основе ИИ JetBrains, помог мне написать часть кода и освоить Rust. Он стал неотъемлемой частью моей повседневной работы как с Rust, так и с Python. К сожалению, поддержка nalgebra все еще недостаточна.
Читайте также:
- От отправителя к получателю: подход Rust к локальной передаче файлов
- Rust как часть микросервисной архитектуры
- Rust: выполнение HTTP-запросов и обработка ответов с помощью reqwest
Читайте нас в Telegram, VK и Дзен
Перевод статьи João Paulo Figueira: LOESS in Rust