8

Given a 2D numpy array:

00111100110111
01110011000110
00111110001000
01101101001110

Is there an efficient way to replace runs of 1 which are >= N in length?

For example, if N=3

00222200110222
02220011000110
00222220001000
01101101002220

In reality the 2D array is binary and I want to replace runs of 1 with 0, but for clarity I have replace them with 2 in the above example.

Runnable Example: http://runnable.com/U6q0q-TFWzxVd_Uf/numpy-replace-runs-for-python

The code I'm currently using looks a bit hacky and I feel like there is probably some magic numpy way of doing this:

UPDATE: I'm aware that I changed the example to a version which didn't handle corner cases. That was a minor implementation bug (now fixed). I was more interested if there was a a faster way of doing it.

import numpy as np
import time

def replace_runs(a, search, run_length, replace = 2):
  a_copy = a.copy() # Don't modify original
  for i, row in enumerate(a):
    runs = []
    current_run = []
    for j, val in enumerate(row):
      if val == search:
        current_run.append(j)
      else:
        if len(current_run) >= run_length or j == len(row) -1:
          runs.append(current_run)
        current_run = []

    if len(current_run) >= run_length or j == len(row) -1:
      runs.append(current_run)

    for run in runs:
      for col in run:
        a_copy[i][col] = replace

  return a_copy

arr = np.array([
  [0,0,1,1,1,1,0,0,1,1,0,1,1,1],
  [0,1,1,1,0,0,1,1,0,0,0,1,1,0],
  [0,0,1,1,1,1,1,0,0,0,1,0,0,0],
  [0,1,1,0,1,1,0,1,0,0,1,1,1,0],
  [1,1,1,1,1,1,1,1,1,1,1,1,1,1],
  [0,0,0,0,0,0,0,0,0,0,0,0,0,0],
  [1,1,1,1,1,1,1,1,1,1,1,1,1,0],
  [0,1,1,1,1,1,1,1,1,1,1,1,1,1],
])

print arr
print replace_runs(arr, 1, 3)

iterations = 100000

t0 = time.time()
for i in range(0,iterations):
  replace_runs(arr, 1, 3)
t1 = time.time()

print "replace_runs: %d iterations took %.3fs" % (iterations, t1 - t0)

Output:

[[0 0 1 1 1 1 0 0 1 1 0 1 1 1]
 [0 1 1 1 0 0 1 1 0 0 0 1 1 0]
 [0 0 1 1 1 1 1 0 0 0 1 0 0 0]
 [0 1 1 0 1 1 0 1 0 0 1 1 1 0]
 [1 1 1 1 1 1 1 1 1 1 1 1 1 1]
 [0 0 0 0 0 0 0 0 0 0 0 0 0 0]
 [1 1 1 1 1 1 1 1 1 1 1 1 1 0]
 [0 1 1 1 1 1 1 1 1 1 1 1 1 1]]

[[0 0 2 2 2 2 0 0 1 1 0 2 2 2]
 [0 2 2 2 0 0 1 1 0 0 0 2 2 0]
 [0 0 2 2 2 2 2 0 0 0 1 0 0 0]
 [0 1 1 0 1 1 0 1 0 0 2 2 2 0]
 [2 2 2 2 2 2 2 2 2 2 2 2 2 2]
 [0 0 0 0 0 0 0 0 0 0 0 0 0 0]
 [2 2 2 2 2 2 2 2 2 2 2 2 2 0]
 [0 2 2 2 2 2 2 2 2 2 2 2 2 2]]

replace_runs: 100000 iterations took 14.406s
Pete Hamilton
  • 7,730
  • 6
  • 33
  • 58
  • Please don't edit your question if possible, I don't want it to become Wiki (which it will in 2 more edits). Please leave comments instead; people (including me) worked hard to give you answers. Thanks! – Ali Jul 06 '14 at 20:50
  • Sure, sorry. I still need to go through the answers and perform some tests, I only made edits to try and resolve the implementation errors as people seemed to be misinterpreting and proving me with "Fixed" versions that actually ran slow whereas I was really looking for **Fast** implementations (though obviously as you point out my own code didn't quite do what I needed either). I just didn't want to put people on the wrong path. – Pete Hamilton Jul 10 '14 at 22:56
  • Well, my solution is blazing fast ;) – Ali Jul 10 '14 at 23:16

6 Answers6

2

using a pattern matching through convolution:

def replace_runs(a, N, replace = 2):
    a_copy = a.copy()
    pattern = np.ones(N, dtype=int)
    M = a_copy.shape[1]

    for i, row in enumerate(a_copy):
        conv = np.convolve(row, pattern, mode='same')
        match = np.where(conv==N)

        a_copy[i][match]=replace
        a_copy[i][match[0][match[0]-1>0]-1]=replace
        a_copy[i][match[0][match[0]+1<M]+1]=replace
    return a_copy

3 times slower than the original replace_runs but detect the corner cases (like the proposed string based approach).

On my machine:

replace_runs_org: 100000 iterations took 12.792s

replace_runs_var: 100000 iterations took 33.112s

toine
  • 1,946
  • 18
  • 24
  • Whoops, I hadn't noticed that! The corner case was actually a bug, once fixed runtime is about the same for me, I'm interested in a faster way if possible. Thanks though! – Pete Hamilton Jun 25 '14 at 13:27
  • I think the faster way is proposed by @otus. The approach is similar but its 'convolution' seems more efficient. To be noted though that convolve can be done with the [scipy implementation](http://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.fftconvolve.html) which will scale in a better way. And if you have really a lot of data, maybe usage of several core could be beneficial. – toine Jun 25 '14 at 14:52
1

I'll consider the input a one dimensional array, since it generalizes to two dimensions.

In binary, you can check if two items are both 1 by using &. In numpy you can "shift" an array efficiently by slicing. So, create a second array where there's a 1 in all the places you want to unset (or change to two). Then ^ or + that into the original, depending on whether you want to make zeros or twos out of them:

def unset_ones(a, n):
    match = a[:-n].copy()
    for i in range(1, n): # find 1s that have n-1 1s following
        match &= a[i:i-n]
    matchall = match.copy()
    matchall.resize(match.size + n)
    for i in range(1, n): # make the following n-1 1s as well
        matchall[i:i-n] |= match
    b = a.copy()
    b ^= matchall # xor into the original data; replace by + to make 2s
    return b

Example:

>>> unset_ones(np.array([0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 1, 0, 0]), 3)
array([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0])
otus
  • 5,572
  • 1
  • 34
  • 48
  • This sounds interesting, could you possibly give an example of usage? When I tried this numpy exploded with some shape broadcasting errors. For the &= (I think). Maybe the indexes aren't quite right. I'll investigate too – Pete Hamilton Jun 25 '14 at 13:30
  • 1
    @PeterHamilton, yes, I had some stupid bugs, now it actually works. I don't think this will be fast with short arrays, but it might be with large ones. – otus Jun 25 '14 at 13:53
  • This seems to fail on the corner cases like when a row ends in [...,1,1,1]. Otherwise it looks promising, will test and benchmark. – Pete Hamilton Jun 25 '14 at 14:01
1

First, your code doesn't work properly... It is replacing by 2s a cluster of only two 1s at the end of the second row. That said, the following does what your text describes:

def replace_runs_bis(arr, search=1, n=3, val=2):
    ret = np.array(arr) # this makes a copy by default
    rows, cols = arr.shape
    # Fast convolution with an all 1's kernel
    arr_cum = np.cumsum(arr == search, axis=1)
    arr_win = np.empty((rows, cols-n+1), dtype=np.intp)
    arr_win[:, 0] = arr_cum[:, n-1]
    arr_win[:, 1:] = arr_cum[:, n:] - arr_cum[:, :-n]
    mask_win = arr_win >= n
    # mask_win is True for n item windows all full of searchs, expand to pixels
    mask = np.zeros_like(arr, dtype=np.bool)
    for j in range(n):
        sl_end = -n+j+1
        sl_end = sl_end if sl_end else None
        mask[:, j:sl_end] |= mask_win
    #replace values
    ret[mask] = val

    return ret

For your sample array it is ~2x faster, but I am guessing it will be much faster for larger arrays, as long as n is kept small.

In [23]: %timeit replace_runs(arr, 1, 3)
10000 loops, best of 3: 163 µs per loop

In [24]: %timeit replace_runs_bis(arr, 1, 3)
10000 loops, best of 3: 80.9 µs per loop
Jaime
  • 65,696
  • 17
  • 124
  • 159
1

Pure Python

You might want to test your code, it doesn't seem to do what you expect. Please run this script, test your code against mine and inspect the output:

import numpy as np

def find_first(a, index, value):
    while index<a.size and a[index]!=value:
        index += 1
    return index

def find_end(a, index, value):
    while index<a.size and a[index]==value:
        index += 1
    return index

def replace_run(a, begin, end, threshold, replace):
    if end-begin+1 > threshold:
        a[begin:end] = replace

def process_row(a, value, threshold, replace):
    first = 0
    while first < a.size:
        if a[first]==value:
            end = find_end(a, first, value)
            replace_run(a, first, end, threshold, replace)
            first = end
        else:
            first = find_first(a, first, value)

def replace_py(a, value, length, replace):
    mat = a.copy()
    for row in mat:
        process_row(row, value, length, replace)
    return mat

################################################################################
# Your code as posted in the question:

def replace_runs(a, search, run_length, replace = 2):
  a_copy = a.copy() # Don't modify original
  for i, row in enumerate(a):
    runs = []
    current_run = []
    for j, val in enumerate(row):
      if val == search:
        current_run.append(j)
      else:
        if len(current_run) >= run_length or j == len(row) -1:
          runs.append(current_run)
        current_run = []

    if len(current_run) >= run_length or j == len(row) -1:
      runs.append(current_run)

    for run in runs:
      for col in run:
        a_copy[i][col] = replace

  return a_copy

# End of your code
################################################################################

def print_mismatch(a, b):
    print 'Elementwise equals'
    mat_equals = a==b
    print  mat_equals
    print 'Reduced to rows'
    for i, outcome in enumerate(np.logical_and.reduce(mat_equals, 1)):
        print i, outcome

if __name__=='__main__':
    np.random.seed(31)
    shape = (20, 10)
    mat = np.asarray(a=np.random.binomial(1, p=0.5, size=shape), dtype=np.int32)
    mat.reshape(shape)
    runs = replace_runs(mat, 1, 3, 2)
    py = replace_py(mat, 1, 3, 2)

    print 'Original'
    print mat
    print 'replace_runs()'
    print runs
    print 'replace_py()'
    print py

    print 'Mismatch between replace_runs() and replace_py()'
    print_mismatch(runs, py)

Benchmarking doesn't make sense until your code is not fixed. So I will use mine, the replace_py() function for benchmarking.

The replace_py() implementation, which I think does what you want, is not pythonic, it has many anti-patterns. Nevertheless, it seems to be correct.

Timing:

np.random.seed(31)
shape = (100000, 10)
mat = np.asarray(a=np.random.binomial(1, p=0.5, size=shape), dtype=np.int32)
mat.reshape(shape)
%timeit replace_py(mat, 1, 3, 2)
1 loops, best of 3: 9.49 s per loop

Cython

I don't think your problem can be easily rewritten to use Numpy and vectorization. Perhaps a Numpy guru can do that but I am afraid the code will be either really obscure or slow (or both). To quote one of the Numpy developers:

[...] when it either requires a PhD in NumPy-ology to vectorize the solution or it results in too much memory overhead, you can reach for Cython [...]

So I re-wrote the replace_py() and the functions it calls in Cython, using typed memoryviews:

# cython: infer_types=True
# cython: boundscheck=False
# cython: wraparound=False
import numpy as np
cimport numpy as np

cdef inline int find_first(int[:] a, int index, int n, int value) nogil:
    while index<n and a[index]!=value:
        index += 1
    return index

cdef inline int find_end(int[:] a, int index, int n, int value) nogil:
    while index<n and a[index]==value:
        index += 1
    return index

cdef inline void replace_run(int[:] a, int begin, int end, int threshold, int replace) nogil:
    if end-begin+1 > threshold:
        for i in xrange(begin, end):
            a[i] = replace

cdef inline void process_row(int[:] a, int value, int threshold, int replace) nogil:
    cdef int first, end, n
    first = 0
    n = a.shape[0]
    while first < n:
        if a[first]==value:
            end = find_end(a, first, n, value)
            replace_run(a, first, end, threshold, replace)
            first = end
        else:
            first = find_first(a, first, n, value)

def replace_cy(np.ndarray[np.int32_t, ndim=2] a, int value, int length, int replace):
    cdef int[:, ::1] vmat
    cdef int i, n
    mat = a.copy()
    vmat = mat
    n = vmat.shape[0]
    for i in xrange(n):
        process_row(vmat[i], value, length, replace)
    return mat

It required a little massaging and the code is more cluttered than the corresponding Python code given above. But it wasn't too much work and it was quite straightforward.

Timing:

np.random.seed(31)
shape = (100000, 10)
mat = np.asarray(a=np.random.binomial(1, p=0.5, size=shape), dtype=np.int32)
mat.reshape(shape)
%timeit replace_cy(mat, 1, 3, 2)
100 loops, best of 3: 8.16 ms per loop

That's a 1163x speed-up!


Numba

I have received help on Github and now the Numba version is also working; I just added @autojit to the pure Python code, except a[begin:end] = replace, see the discussion on Github where I got this workaround.

import numpy as np
from numba import autojit

@autojit
def find_first(a, index, value):
    while index<a.size and a[index]!=value:
        index += 1
    return index

@autojit
def find_end(a, index, value):
    while index<a.size and a[index]==value:
        index += 1
    return index

@autojit
def replace_run(a, begin, end, threshold, replace):
    if end-begin+1 > threshold:
        for i in xrange(begin, end):
            a[i] = replace

@autojit        
def process_row(a, value, threshold, replace):
    first = 0
    while first < a.size:
        if a[first]==value:
            end = find_end(a, first, value)
            replace_run(a, first, end, threshold, replace)
            first = end
        else:
            first = find_first(a, first, value)

@autojit            
def replace_numba(a, value, length, replace):
    mat = a.copy()
    for row in mat:
        process_row(row, value, length, replace)
    return mat

Timing (with the usual input as above, code omitted):

1 loops, best of 3: 86.5 ms per loop

That's a 110x speed-up compared to the pure Python code for basically free!!! The Numba version is still a 10x slower than Cython, most likely due to not inlining the tiny functions, but I think it's amazing to get this speed-up basically for free, without messing up our Python code!

Ali
  • 56,466
  • 29
  • 168
  • 265
0

This is slightly faster than OP, but still hacky:

def replace2(originalM) :
    m = originalM.copy()
    for v in m :
        idx = 0
        for (key,n) in ( (key, sum(1 for _ in group)) for (key,group) in itertools.groupby(v) ) :
            if key and n>=3 :
                v[idx:idx+n] = 2
            idx += n
    return m

%%timeit
replace_runs(arr, 1, 3)
10000 loops, best of 3: 61.8 µs per loop

%%timeit
replace2(arr)
10000 loops, best of 3: 48 µs per loop
usual me
  • 8,338
  • 10
  • 52
  • 95
0

The convolution method of toine is also a good way to go. Based on these answers, you could use groupy to get what you want.

from itertools import groupby, repeat, chain
run_length = 3
new_value = 2
# Groups the element by successive repetition
grouped = [(k, sum(1 for _ in v)) for k, v in groupby(arr[0])]
# [(0, 2), (1, 4), (0, 2), (1, 2), (0, 1), (1, 3)]
output = list(chain(*[list(repeat(k if v < run_length else new_value, v)) for k, v in grouped]))
# [0, 0, 2, 2, 2, 2, 0, 0, 1, 1, 0, 2, 2, 2]

You'd just have to do it for every row you have in arr. You'd have to tweak it for your needs if you want to be really efficient (remove the list creations, for example).

Using the example Paul gave in the answer I linked, you can do something along the lines of:

import numpy as np
new_value = 2
run_length = 3
# Pad with values outside the possible values
diff = np.concatenate(([2], np.diff(arr[0]), [-1]))
# Get the array difference (every number substracted from the preceding)
idx_diff = np.where(diff)[0]
# Get values where groups are longer than 2 and value is 1
idx = np.where((np.diff(idx_diff) >= run_length) & arr[0][idx_diff[:-1]])[0]
# Set every group to its new value
for i in idx:
    arr[0][idx_diff[i]:idx_diff[i+1]] = new_value

This is only food for though. With this method, it is possible to do the whole matrix in one run and modify the array in place, which should be efficient. Sorry for the pretty raw state of this idea. I hope it gives you insights. A good speedup hint would be to remove the for loop.

This is, of course, if you want to sacrifice clarify for the sake of clarity. In my opinion, this is rarely the case in Python where you want to prototype quickly ideas. If you have an algorithm proven right that must be fast, write it in C (or with Cython) and use it in your Python program (using either ctypes or CFFI).

Community
  • 1
  • 1
Soravux
  • 9,653
  • 2
  • 27
  • 25