2026, Jan 05 15:04

Схожесть массивов по L1 в NumPy: векторизация без циклов

Как посчитать схожесть по минимальным L1-расстояниям между строками массивов в NumPy без циклов: бродкастинг и осевые свертки в одном векторизованном выражении.

Векторизация вычисления схожести для массивов с разным числом строк — частый прием в конвейерах обработки данных. Задача проста: для каждой строки одного массива найти минимальное L1-расстояние до любой строки другого массива и сложить эти минимумы в единый показатель. Прямолинейный способ работает, но опирается на циклы Python. Есть аккуратное, «чисто NumPy» решение, которое убирает накладные расходы Python и заметно ускоряет вычисления.

Пример: базовая реализация

Код ниже вычисляет метрику схожести между двумя NumPy-массивами разных размеров. Для каждой строки второго массива он считает L1-расстояния до всех строк первого, выбирает минимальное и в конце суммирует эти минимумы.

import numpy as np
from numpy.linalg import norm as l1norm

x_mat = np.array([(1, 2, 3), (1, 4, 9), (2, 4, 4)])
y_mat = np.array([(1, 3, 3), (1, 5, 9)])
score = sum([min(l1norm(x_mat - row_vec, ord=1, axis=1)) for row_vec in y_mat])

Что происходит под капотом

Логика прозрачна и последовательна. Вычитание одной строки из первого массива использует бродкастинг и дает построчные разности; L1-норма с axis=1 сворачивает их в расстояния, а минимум выбирает ближайшую строку. Запуск этого процесса внутри спискового включения в Python повторяет шаги для каждой строки второго массива и затем агрегирует результат через сумму. Единственный минус — явный цикл на стороне Python, который ограничивает эффективность.

Выражение только на NumPy

Цикл можно полностью убрать, построив матрицу попарных расстояний за счет бродкастинга и свернув ее по нужным осям. Математика остается той же, а вся работа переносится в векторизованные операции.

import numpy as np
from numpy.linalg import norm as l1norm

x_mat = np.array([(1, 2, 3), (1, 4, 9), (2, 4, 4)])
y_mat = np.array([(1, 3, 3), (1, 5, 9)])
score_opt = l1norm(x_mat[:, None, :] - y_mat[None], ord=1, axis=2).min(axis=0).sum()

Срезы x_mat[:, None, :] и y_mat[None] добавляют одиночные измерения, благодаря чему вычитание по бродкастингу формирует трехмерный массив попарных разностей. L1-норма с axis=2 сворачивает последнее измерение в расстояния, и получается матрица, где каждый столбец соответствует одной строке второго массива. Затем min(axis=0) выбирает минимальное расстояние по столбцам, напрямую повторяя логику поиска лучшего соответствия для каждой строки. Финальная сумма агрегирует эти минимумы в тот же скалярный показатель. Явное min(axis=0) лучше передает намерение; это эквивалентно min(0).

Почему это важно

Опираясь на API NumPy для операций над массивами целиком, мы снижаем накладные расходы Python и используем оптимизированные векторизованные ядра. На практике такой подход заметно быстрее циклической версии и даже немного опережает прямую перепись на Numba для той же задачи.

Вывод

Когда нужен показатель схожести на основе минимальных L1-расстояний между строками двух массивов разного размера, сочетание бродкастинга и осевых сверток — прямое и удобное решение. Преобразуйте массивы в тензор попарных разностей, вычислите норму по оси признаков, возьмите минимумы по столбцам и сложите. Для читаемости явно указывайте параметр axis и избегайте возвращения к циклам Python там, где NumPy позволяет выразить весь конвейер одной понятной формулой.