Улучшить минимальную/максимальную понижающую дискретизацию

У меня есть несколько больших массивов (~ 100 миллионов точек), которые мне нужно построить в интерактивном режиме. В настоящее время я использую Matplotlib. Построение массивов как есть происходит очень медленно и является пустой тратой времени, поскольку вы все равно не можете визуализировать столько точек.

Поэтому я сделал функцию прореживания мин/макс, которую привязал к обратному вызову xlim_changed оси. Я выбрал подход «минимум/максимум», потому что данные содержат быстрые всплески, которые я не хочу пропустить, просто просматривая данные. Есть и другие обертки, которые обрезают x-пределы и пропускают обработку при определенных условиях, но соответствующая часть приведена ниже:

def min_max_downsample(x,y,num_bins):
    """ Break the data into num_bins and returns min/max for each bin"""
    pts_per_bin = x.size // num_bins    

    #Create temp to hold the reshaped & slightly cropped y
    y_temp = y[:num_bins*pts_per_bin].reshape((num_bins, pts_per_bin))
    y_out      = np.empty((num_bins,2))
    #Take the min/max by rows.
    y_out[:,0] = y_temp.max(axis=1)
    y_out[:,1] = y_temp.min(axis=1)
    y_out = y_out.ravel()

    #This duplicates the x-value for each min/max y-pair
    x_out = np.empty((num_bins,2))
    x_out[:] = x[:num_bins*pts_per_bin:pts_per_bin,np.newaxis]
    x_out = x_out.ravel()
    return x_out, y_out

Это работает очень хорошо и достаточно быстро (~ 80 мс на 1e8 точек и 2k бинов). Задержка очень небольшая, так как он периодически пересчитывает и обновляет данные x и y линии.

Тем не менее, моя единственная жалоба заключается в x-данных. Этот код дублирует x-значение левого края каждой ячейки и не возвращает истинное x-местоположение пар y min/max. Я обычно устанавливаю количество бинов, чтобы удвоить ширину пикселя оси. Таким образом, вы не можете увидеть разницу, потому что контейнеры такие маленькие... но я знаю, что она есть... и это меня раздражает.

Итак, попытка № 2, которая возвращает фактические значения x для каждой пары min/max. Однако это примерно в 5 раз медленнее.

def min_max_downsample_v2(x,y,num_bins):
    pts_per_bin = x.size // num_bins
    #Create temp to hold the reshaped & slightly cropped y
    y_temp = y[:num_bins*pts_per_bin].reshape((num_bins, pts_per_bin))
    #use argmax/min to get column locations
    cc_max = y_temp.argmax(axis=1)
    cc_min = y_temp.argmin(axis=1)    
    rr = np.arange(0,num_bins)
    #compute the flat index to where these are
    flat_max = cc_max + rr*pts_per_bin
    flat_min = cc_min + rr*pts_per_bin
    #Create a boolean mask of these locations
    mm_mask  = np.full((x.size,), False)
    mm_mask[flat_max] = True
    mm_mask[flat_min] = True  
    x_out = x[mm_mask]    
    y_out = y[mm_mask]  
    return x_out, y_out

На моей машине это занимает примерно 400+ мс, что становится довольно заметным. Итак, мой вопрос в основном заключается в том, есть ли способ работать быстрее и обеспечивать те же результаты? Узкое место в основном находится в функциях numpy.argmin и numpy.argmax, которые немного медленнее, чем numpy.min и numpy.max.

Ответ может заключаться в том, чтобы просто жить с версией № 1, поскольку визуально она не имеет большого значения. Или, может быть, попытаться ускорить его чем-то вроде cython (который я никогда не использовал).

К вашему сведению, использование Python 3.6.4 в Windows... пример использования будет примерно таким:

x_big = np.linspace(0,10,100000000)
y_big = np.cos(x_big )
x_small, y_small = min_max_downsample(x_big ,y_big ,2000) #Fast but not exactly correct.
x_small, y_small = min_max_downsample_v2(x_big ,y_big ,2000) #correct but not exactly fast.

person Aero Engy    schedule 30.01.2019    source источник
comment
Из вашего вопроса я так понимаю, что это вызывается несколько раз? Если это так, вы можете значительно ускорить процесс, используя метод set_data существующей строки в matplotlib.   -  person user2699    schedule 31.01.2019
comment
@user2699 user2699 Да, это именно то, что я делаю с выводом этой функции. То, что я опубликовал, является основным узким местом, потому что оно часто вызывается, когда я панорамирую. Тем не менее, я изменил внешнюю часть этого кода (не показана), чтобы он вызывался реже, буферизируя больше дат за пределами x-ограничений графика. Таким образом, он должен пересчитываться только тогда, когда вы панорамируете за определенный предел. Это немного помогло пользователю.   -  person Aero Engy    schedule 01.02.2019
comment
Я сталкивался с подобными проблемами (хотя и менее чем в 100 миллионах точек) и могу включить часть кода, который я написал, который имеет значение. Что я нашел наиболее эффективным, так это кэширование сигнала с пониженной частотой дискретизации и понижение частоты дискретизации только тогда, когда это имеет существенное значение в представлении. Пока данные (в некоторой степени) равномерно отбираются, наличие точных x точек не имеет значения.   -  person user2699    schedule 01.02.2019
comment
@ user2699 Я хотел бы посмотреть, как вы кэшируете. Я делаю что-то подобное сейчас. В основном загружая данные за пределы x на некоторое количество и делая плотность точек немного больше, чем необходимо. Затем он запускает новый пересчет только в том случае, если вы увеличиваете масштаб настолько, что плотность точек становится низкой, или если вы панорамируете за край буфера. Я хотел бы посмотреть, как это делает кто-то другой, так как я придумал большую часть этого, и я знаю, что есть некоторые логические дыры. Мой будет иногда пересчитывать без необходимости.   -  person Aero Engy    schedule 01.02.2019
comment
Я включил это в ответ. Дайте мне знать, как это работает для вас, это было довольно надежно в моем использовании, но могут быть проблемы, которые я пропустил.   -  person user2699    schedule 01.02.2019


Ответы (4)


Мне удалось повысить производительность, используя вывод arg(min|max) непосредственно для индексации массивов данных. Это происходит за счет дополнительного вызова np.sort, но ось для сортировки имеет только два элемента (минимальные/максимальные индексы), а общий массив довольно мал (количество ячеек):

def min_max_downsample_v3(x, y, num_bins):
    pts_per_bin = x.size // num_bins

    x_view = x[:pts_per_bin*num_bins].reshape(num_bins, pts_per_bin)
    y_view = y[:pts_per_bin*num_bins].reshape(num_bins, pts_per_bin)
    i_min = np.argmin(y_view, axis=1)
    i_max = np.argmax(y_view, axis=1)

    r_index = np.repeat(np.arange(num_bins), 2)
    c_index = np.sort(np.stack((i_min, i_max), axis=1)).ravel()

    return x_view[r_index, c_index], y_view[r_index, c_index]

Я проверил тайминги для вашего примера и получил:

  • min_max_downsample_v1: 110 ms ± 5 ms
  • min_max_downsample_v2: 240 ms ± 8.01 ms
  • min_max_downsample_v3: 164 ms ± 1.23 ms

Я также проверил возврат сразу после вызовов arg(min|max), и результат был равным 164 мс, т.е. после этого больше не было реальных накладных расходов.

person a_guest    schedule 31.01.2019
comment
Хороший трюк с индексацией и обходом маски. Я бы не подумал, что sort будет быстрее. Я получаю 87, 421 и 338 мс для v1, v2 и v3. Я хотел бы вернуть его к этому диапазону менее 100 мс, но я приму все улучшения, которые смогу получить. Тем не менее, я заметил, что приведенный выше код будет ошибкой, если x не будет точно равным num_bin*pts_per_bin элементам... что часто бывает. Вот почему я делал y_temp = y[:num_bins*pts_per_bin].reshape - person Aero Engy; 31.01.2019
comment
@AeroEngy Вы правы, включая это. Не могли бы вы проверить результат синхронизации, когда вы вернетесь сразу после вызовов arg(min|max), чтобы увидеть вклад следующего вызова в sort? Существуют разные способы объединения индексов, но на моей машине это не имело значения. - person a_guest; 31.01.2019
comment
Я прогнал его через профиль и получил всего 339 мс (142 мс для argmax и 196 мс для argmin). sort было тривиальным при 45 мкс. Поэтому я думаю, что вы убрали все остальные накладные расходы. Чтобы сделать это быстрее, потребуется какой-то неизвестный новый подход. - person Aero Engy; 31.01.2019
comment
@AeroEngy Может быть, вы могли бы следовать этому подходу, чтобы вместо этого скомпилировать свою собственную функцию argmin_max, которая вместо этого вычисляет argmin и argmax за один раз. дважды перебрать массив. Для массива такого размера разница должна быть измеримой. - person a_guest; 31.01.2019
comment
Спасибо за ссылку! Я собираюсь сначала попытаться сделать numba-версию однопроходного argminmax, чтобы посмотреть, как она сравнивается. Я не использовал numba или cython раньше, но это выглядит интересно. Я недавно перешел на Python из Matlab, поэтому я привык ускорять код с помощью MEX, который кажется похожей идеей. - person Aero Engy; 01.02.2019
comment
Использование вашего ответа + эта ссылка помогли мне сделать красивую и быструю версию с numba. Я разместил его как отдельный ответ для потомков, но не буду отвечать на ваш ответ, поскольку вы ведете меня по правильному пути. Еще раз спасибо! - person Aero Engy; 01.02.2019

Таким образом, это не касается ускорения конкретной рассматриваемой функции, но показывает несколько эффективных способов построения линии с большим количеством точек. Это предполагает, что точки x упорядочены и выбраны равномерно (или близко к равномерному).

Настраивать

from pylab import *

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

def calc_rand(y, factor):
    split = y[:len(y)//factor*factor].reshape(-1, factor)
    idx = randint(0, split.shape[-1], split.shape[0])
    return split[arange(split.shape[0]), idx]

А вот мин и макс чтобы увидеть огибающую сигнала

def calc_env(y, factor):
    """
    y : 1D signal
    factor : amount to reduce y by (actually returns twice this for min and max)
    Calculate envelope (interleaved min and max points) for y
    """
    split = y[:len(y)//factor*factor].reshape(-1, factor)
    upper = split.max(axis=-1)
    lower = split.min(axis=-1)
    return c_[upper, lower].flatten()

Следующая функция может принимать любой из них и использовать их для уменьшения отрисовываемых данных. По умолчанию фактически снято 5000 точек, что должно намного превышать разрешение монитора. Данные кэшируются после их уменьшения. Память может быть проблемой, особенно при больших объемах данных, но она не должна превышать объем, требуемый исходным сигналом.

def plot_bigly(x, y, *, ax=None, M=5000, red=calc_env, **kwargs):
    """
    x : the x data
    y : the y data
    ax : axis to plot on
    M : The maximum number of line points to display at any given time
    kwargs : passed to line
    """
    assert x.shape == y.shape, "x and y data must have same shape!"
    if ax is None:
        ax = gca()

    cached = {}

    # Setup line to be drawn beforehand, note this doesn't increment line properties so
    #  style needs to be passed in explicitly
    line = plt.Line2D([],[], **kwargs)
    def update(xmin, xmax):
        """
        Update line data

        precomputes and caches entire line at each level, so initial
        display may be slow but panning and zooming should speed up after that
        """
        # Find nearest power of two as a factor to downsample by
        imin = max(np.searchsorted(x, xmin)-1, 0)
        imax = min(np.searchsorted(x, xmax) + 1, y.shape[0])
        L = imax - imin + 1
        factor = max(2**int(round(np.log(L/M) / np.log(2))), 1)

        # only calculate reduction if it hasn't been cached, do reduction using nearest cached version if possible
        if factor not in cached:
            cached[factor] = red(y, factor=factor)

        ## Make sure lengths match correctly here, by ensuring at least
        #   "factor" points for each x point, then matching y length
        #  this assumes x has uniform sample spacing - but could be modified
        newx = x[imin:imin + ((imax-imin)//factor)* factor:factor]
        start = imin//factor
        newy = cached[factor][start:start + newx.shape[-1]]
        assert newx.shape == newy.shape, "decimation error {}/{}!".format(newx.shape, newy.shape)

        ## Update line data
        line.set_xdata(newx)
        line.set_ydata(newy)

    update(x[0], x[-1])
    ax.add_line(line)
    ## Manually update limits of axis, as adding line doesn't do this
    #   if drawing multiple lines this can quickly slow things down, and some
    #   sort of check should be included to prevent unnecessary changes in limits
    #   when a line is first drawn.
    ax.set_xlim(min(ax.get_xlim()[0], x[0]), max(ax.get_xlim()[1], x[1]))
    ax.set_ylim(min(ax.get_ylim()[0], np.min(y)), max(ax.get_ylim()[1], np.max(y)))

    def callback(*ignore):
        lims = ax.get_xlim()
        update(*lims)

    ax.callbacks.connect('xlim_changed', callback)

    return [line]

Вот тестовый код

L=int(100e6)
x=linspace(0,1,L)
y=0.1*randn(L)+sin(2*pi*18*x)
plot_bigly(x,y, red=calc_env)

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

person user2699    schedule 31.01.2019
comment
Спасибо, я не рассматривал возможность сохранения версии с пониженной частотой дискретизации при различных факторах для последующего использования. Я, вероятно, позаимствую это и включу в свою рутину. - person Aero Engy; 01.02.2019

EDIT: добавлено parallel=True для numba... еще быстрее

В итоге я создал гибрид однопроходной процедуры argmin+max и улучшенной индексации из ответа @a_guest и ссылки на это связанный одновременный вопрос min max.

Эта версия возвращает правильные значения x для каждой пары min/max y и благодаря numba на самом деле немного быстрее, чем "быстрая, но не совсем правильная" версия.

from numba import jit, prange
@jit(parallel=True)
def min_max_downsample_v4(x, y, num_bins):
    pts_per_bin = x.size // num_bins
    x_view = x[:pts_per_bin*num_bins].reshape(num_bins, pts_per_bin)
    y_view = y[:pts_per_bin*num_bins].reshape(num_bins, pts_per_bin)    
    i_min = np.zeros(num_bins,dtype='int64')
    i_max = np.zeros(num_bins,dtype='int64')

    for r in prange(num_bins):
        min_val = y_view[r,0]
        max_val = y_view[r,0]
        for c in range(pts_per_bin):
            if y_view[r,c] < min_val:
                min_val = y_view[r,c]
                i_min[r] = c
            elif y_view[r,c] > max_val:
                max_val = y_view[r,c]
                i_max[r] = c                
    r_index = np.repeat(np.arange(num_bins), 2)
    c_index = np.sort(np.stack((i_min, i_max), axis=1)).ravel()        
    return x_view[r_index, c_index], y_view[r_index, c_index]

Сравнение скоростей с использованием timeit показывает, что код numba примерно в 2,6 раза быстрее и обеспечивает лучшие результаты, чем v1. Это чуть более чем в 10 раз быстрее, чем последовательное выполнение argmin и argmax numpy.

%timeit min_max_downsample_v1(x_big ,y_big ,2000)
96 ms ± 2.46 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

%timeit min_max_downsample_v2(x_big ,y_big ,2000)
507 ms ± 4.75 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit min_max_downsample_v3(x_big ,y_big ,2000)
365 ms ± 1.27 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit min_max_downsample_v4(x_big ,y_big ,2000)
36.2 ms ± 487 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)
person Aero Engy    schedule 01.02.2019
comment
Это потрясающе! Я не знал, что с Numba вы можете делать все это на Python. Полученный код очень легко читается, и для его интеграции достаточно декоратора. Спасибо, что поделились кодом :-) - person a_guest; 01.02.2019
comment
@a_guest Я был очень удивлен тем, насколько легко им пользоваться. До того, как я добавил prange и parallel=True, время составляло ~86 мс, что меня очень порадовало. После их добавления удивительно, насколько это быстрее, чем только Numpy. Для компиляции при первом использовании требуется около 1/2 секунды или около того, но это вызывается много раз, так что это не имеет значения. - person Aero Engy; 01.02.2019

Вы пробовали pyqtgraph для интерактивного построения графиков? Он более отзывчив, чем matplotlib.

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

person danielhrisca    schedule 03.02.2019
comment
Я попробовал pyqtgraph, и он оказался быстрее. Он также имеет встроенную процедуру понижения дискретизации мин/макс, что приятно. Тем не менее, мне не очень понравилась его сюжетная механика. Если вы прочитаете вопрос/ответы, вы увидите, что я делаю min/max с изменением формы вместо array_split. reshape имеет гораздо меньше накладных расходов, поскольку возвращает одно представление массива. array_split, похоже, возвращает list просмотров. Разделение массива элементов 1e8 занимает ~ 2 мс, а изменение формы - ~ 520 нс ... поэтому изменение формы на моей машине выполняется в 3800 раз быстрее. - person Aero Engy; 04.02.2019
comment
array_split действительно намного медленнее, чем изменение формы - person danielhrisca; 05.02.2019