<jupyter_start><jupyter_text>Scaling for containers and algorithms We've seen that NumPy arrays are really useful. Why wouldn't we always want to use them for data which is all the same type?<jupyter_code>import numpy as np
from timeit import repeat
from matplotlib import pyplot as plt
%matplotlib inline<jupyter_output><empty_output><jupyter_text>Let's look at appending data into a NumPy array, compared to a plain Python list:<jupyter_code>def time_append_to_ndarray(count):
    # the function repeat does the same that the `%timeit` magic
    # but as a function; so we can plot it.
    return repeat('np.append(before, [0])',
                  f'import numpy as np; before=np.ndarray({count})',
                  number=10000)
def time_append_to_list(count):
    return repeat('before.append(0)',
                  f'before = [0] * {count}',
                  number=10000)
counts = np.arange(1, 100000, 10000)

def plot_time(function, counts, title=None):
    plt.plot(counts, list(map(function, counts)))
    plt.ylim(bottom=0) 
    plt.ylabel('seconds')
    plt.xlabel('array size')
    plt.title(title or function.__name__)
plot_time(time_append_to_list, counts)
plot_time(time_append_to_ndarray, counts)<jupyter_output><empty_output><jupyter_text>Adding an element to a Python list is way faster! Also, it seems that adding an element to a Python list is independent of the length of the list, but it's not so for a NumPy array. How do they perform when accessing an element in the middle?<jupyter_code>def time_lookup_middle_element_in_list(count):
    before = [0] * count
    def totime():
        x = before[count // 2]
    return repeat(totime, number=10000)
def time_lookup_middle_element_in_ndarray(count):
    before = np.ndarray(count)
    def totime():
        x = before[count // 2]
    return repeat(totime, number=10000)

plot_time(time_lookup_middle_element_in_list, counts)
plot_time(time_lookup_middle_element_in_ndarray, counts)<jupyter_output><empty_output><jupyter_text>Both scale well for accessing the middle element. What about inserting at the beginning?If we want to insert an element at the beginning of a Python list we can do:<jupyter_code>x = list(range(5))
x
x[0:0] = [-1]
x
def time_insert_to_list(count):
    return repeat('before[0:0] = [0]',
                  f'before = [0] * {count}',number=10000)
plot_time(time_insert_to_list, counts)<jupyter_output><empty_output><jupyter_text>`list` performs **badly** for insertions at the beginning! There are containers in Python that work well for insertion at the start:<jupyter_code>from collections import deque
def time_insert_to_deque(count):
    return repeat('before.appendleft(0)', 
                  f'from collections import deque; before = deque([0] * {count})',
                  number=10000)
plot_time(time_insert_to_deque, counts)<jupyter_output><empty_output><jupyter_text>But looking up in the middle scales badly:<jupyter_code>def time_lookup_middle_element_in_deque(count):
    before = deque([0] * count)
    def totime():
        x = before[count // 2]
    return repeat(totime, number=10000)
plot_time(time_lookup_middle_element_in_deque, counts)<jupyter_output><empty_output><jupyter_text>What is going on here? Arrays are stored as contiguous memory. Anything which changes the length of the array requires the whole array to be copied elsewhere in memory. This copy takes time proportional to the array size. ![Adding an element to an array - memory representation](./array_memory.svg) The Python `list` type is **also** an array, but it is allocated with **extra memory**. Only when that memory is exhausted is a copy needed. ![Adding an element to a list - memory representation](list_memory.svg) If the extra memory is typically the size of the current array, a copy is needed every 1/N appends, and costs N to make, so **on average** copies are cheap. We call this **amortized constant time**. This makes it fast to look up values in the middle. However, it may also use more space than is needed. The deque type works differently: each element contains a pointer to the next. Inserting elements is therefore very cheap, but looking up the Nth element requires traversing N such pointers. ![Adding an element to a deque - memory representation](deque_memory.svg)  Dictionary performance For another example, let's consider the performance of a dictionary versus a couple of other ways in which we could implement an associative array.<jupyter_code>class evildict:
    def __init__(self, data):
        self.data = data
        
    def __getitem__(self, akey):
        for key, value in self.data:
            if key == akey:
                return value
        raise KeyError()<jupyter_output><empty_output><jupyter_text>If we have an evil dictionary of N elements, how long would it take - on average - to find an element?<jupyter_code>eric = [["Name", "<NAME>"], ["Job", "Comedian"], ["Home", "London"]]
eric_evil = evildict(eric)
eric_evil["Job"]
eric_dict = dict(eric)
eric_evil["Job"]
x = ["Hello", "License", "Fish", "Eric", "Pet", "Halibut"]
sorted(x, key=lambda el: el.lower())<jupyter_output><empty_output><jupyter_text>What if we created a dictionary where we bisect the search?<jupyter_code>class sorteddict:
    def __init__(self, data):
        self.data = sorted(data, key = lambda x:x[0])
        self.keys = list(map(lambda x:x[0], self.data))
        
    def __getitem__(self,akey):
        from bisect import bisect_left
        loc = bisect_left(self.keys, akey)
        
        if loc != len(self.data):
            return self.data[loc][1]
        
        raise KeyError()
eric_sorted = sorteddict(eric)
eric_sorted["Job"]
def time_dict_generic(ttype, count, number=10000):
    from random import randrange
    keys = list(range(count))
    values = [0] * count
    data = ttype(list(zip(keys, values)))
    def totime():
        x = data[keys[count // 2]]
    return repeat(totime, number=10000)
time_dict = lambda count: time_dict_generic(dict, count)
time_sorted = lambda count: time_dict_generic(sorteddict, count)
time_evil = lambda count: time_dict_generic(evildict, count)
plot_time(time_sorted, counts, title='sorted')<jupyter_output><empty_output><jupyter_text>We can't really see what's going on here for the sorted example as there's too much noise, but theoretically we should get **logarithmic** asymptotic performance. We write this down as $O(\ln N)$. This doesn't mean there isn't also a constant term, or a term proportional to something that grows slower (such as $\ln(\ln N)$): we always write down just the term that is dominant for large $N$. We saw before that `list` is $O(1)$ for appends, $O(N)$ for inserts. Numpy's `array` is $O(N)$ for appends.<jupyter_code>counts = np.arange(1, 1000, 100)
plot_time(time_evil, counts, title='evil')<jupyter_output><empty_output><jupyter_text>The simple check-each-in-turn solution is $O(N)$ - linear time.<jupyter_code>counts = np.arange(1, 100000, 10000)
plot_time(time_dict, counts, title='dict')<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.10011376564277588, 'fraction_numerical': 0.014647326507394767, 'mean_word_length': 4.418335901386749, 'pattern_counts': {'":': 0, '<': 46, '<?xml version=': 0, '>': 46, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '11', 'dir': 'jupyter-structured-clean-dedup', 'id': '78853', 'license': 'CC-BY-3.0', 'n_tokens_mistral': 2256, 'n_tokens_neox': 2121, 'n_words': 743, 'path': 'ch08performance/050scaling.ipynb', 'repo_name': 'andyyhchen/rsd-engineeringcourse', 'size': '7035'}