<SYSTEM_TASK:>
Given the following text description, write Python code to implement the functionality described below step by step
<END_TASK>
<USER_TASK:>
Description:
 An introduction to NumPy
NumPy provides an efficient representation of multidimensional datasets like vectors and matricies, and tools for linear algebra and general matrix manipulations - essential building blocks of virtually all technical computing
Typically NumPy is imported as np
Step1:  NumPy, at its core, provides a powerful array object.  Let's start by exploring how the NumPy array differs from a Python list.  
We start by creating a simple Python list and a NumPy array with identical contents
Step2:  Element indexing
Elements of a one-dimensional array are accessed with the same syntax as a list
Step3:  Differences between arrays and lists
The first difference to note between lists and arrays is that arrays are homogeneous; i.e. all elements of an array must be of the same type.  In contrast, lists can contain elements of arbitrary type. For example, we can change the last element in our list above to be a string
Step4:  But the same can not be done with an array, as we get an error message
Step5:  Caveat, it can be done, but really don't do it; lists are generally better at non-homogeneous collections.
Array Properties and Methods
The following provide basic information about the size, shape and data in the array
Step6:  Arrays also have many useful statistical/mathematical methods
Step7:  Data types
The information about the type of an array is contained in its dtype attribute.
Step8:  Once an array has been created, its dtype is fixed (in this case to an 8 byte/64 bit signed integer) and it can only store elements of the same type.
For this example where the dtype is integer, if we try storing a floating point number in the array it will be automatically converted into an integer
Step9:  NumPy comes with most of the common data types (and some uncommon ones too).
The most used (and portable) dtypes are
Step10:  Floating point precision is covered in detail at http
Step11:  Creating Arrays
Above we created an array from an existing list. Now let's look into other ways in which we can create arrays. 
A common need is to have an array initialized with a constant value. Very often this value is 0 or 1. 
zeros creates arrays of all zeros, with any desired dtype
Step12:  and similarly for ones
Step13:  If we want an array initialized with an arbitrary value, we can create an empty array and then use the fill method to put the value we want into the array
Step14:  Alternatives, such as
Step15:  The linspace and logspace functions to create linearly and logarithmically-spaced grids respectively, with a fixed number of points that include both ends of the specified interval
Step16:  Finally, it is often useful to create arrays with random numbers that follow a specific distribution. 
The np.random module contains a number of functions that can be used to this effect.
For more details see http
Step17:  To produce an array of 5 random samples taken from a standard normal distribution (0 mean and variance 1)
Step18:  For an array of 5 samples from the normal distribution with a mean of 10 and a variance of 3
Step19:  Indexing with other arrays
Above we saw how to index NumPy arrays with single numbers and slices, just like Python lists.  
Arrays also allow for a more sophisticated kind of indexing that is very powerful
Step20:  Now that we have this mask, we can use it to either read those values or to reset them to 0
Step21:  Whilst beyond the scope of this course, it is also worth knowing that a specific masked array object exists in NumPy.
Further details are available at http
Step22:  With two-dimensional arrays we start seeing the power of NumPy
Step23:  Question
Step24:  Multidimensional array creation
The array creation functions listed previously can also be used to create arrays with more than one dimension. 
For example
Step25:  In fact, the shape of an array can be changed at any time, as long as the total number of elements is unchanged. 
For example, if we want a 2x4 array with numbers increasing from 0, the easiest way to create it is
Step26:  Slices
With multidimensional arrays you can also index using slices, and you can mix and match slices and single indices in the different dimensions
Step27:  If you only provide one index to slice a multidimensional array, then the slice will be expanded to "
Step28:  This is also known as "ellipsis".
Ellipsis can be specified explicitly with "...". It will automatically expand to "
Step29:  Operating with arrays
Arrays support all regular arithmetic operators, and the NumPy library also contains a complete collection of basic mathematical functions that operate on arrays.  
It is important to remember that in general, all operations with arrays are applied element-wise, i.e., are applied to all the elements of the array at the same time.  For example
Step30:  Importantly, even the multiplication operator is by default applied element-wise. However it is not the matrix multiplication from linear algebra
Step31:  We may also multiply an array by a scalar
Step32:  This is an example of broadcasting.
Broadcasting
The fact that NumPy operates on an element-wise basis means that in principle arrays must always match one another's shape. However, NumPy will also helpfully "broadcast" dimensions when possible.  
Here is an example of broadcasting a scalar to a 1D array
Step33:  We can also broadcast a 1D array to a 2D array, in this case adding a vector to all rows of a matrix
Step34:  We can also broadcast in two directions at a time
Step35:  Pictorially
Step36:  Exercise 1
1. Use np.arange and reshape to create the array
A = [[1 2 3 4]
     [5 6 7 8]]
2. Use np.array to create the array
B = [1 2]
3. Use broadcasting to add B to A to create the final array
A + B = [[2  3  4  5]
         [7  8  9 10]
Hint
Step37:  As you can see in this example, the value of the axis parameter is the dimension that will be consumed once the operation has been carried out.  This is why to sum along the columns we use axis=0.  
This can be easily illustrated with an example that has more dimensions; we create an array with 4 dimensions and shape (3,4,5,6) and sum along the axis number 2 (i.e. the third axis, since in Python all counts are 0-based).  That consumes the dimension whose length was 5, leaving us with a new array that has shape (3,4,6)
Step38:  Another widely used property of arrays is the .T attribute, which allows you to access the transpose of the array
Step39:  Generating 2D coordinate arrays
A common task is to generate a pair of arrays that represent the coordinates of our data.
When orthogonal 1d coordinate arrays already exist, NumPy's meshgrid function is very useful
Step40:  Reshape and newaxis
Reshaping arrays is a common task in order to make the best of NumPy's powerful broadcasting.
A useful tip with the reshape method is that it is possible to provide a -1 length for at most one of the dimensions. This indicates that NumPy should automatically calculate the length of this dimension
Step41:  Another way to increase the dimensionality of an array is to use the newaxis keyword
Step42:  Views, not Copies
Note that reshaping (like most NumPy operations), wherever possible, provides a view of the same memory
Step43:  What this means is that if one array is modified, the other will also be updated
Step44:  This lack of copying allows for very efficient vectorized operations, but this power should be used carefully - if used badly it can lead to some bugs that are hard to track down.
If in doubt, you can always copy the data to a different block of memory with the copy() method.
Element-wise Functions
NumPy ships with a full complement of mathematical functions that work on entire arrays, including logarithms, exponentials, trigonometric and hyperbolic trigonometric functions, etc.  
For example, sampling the sine function at 100 points between $0$ and $2\pi$ is as simple as
Step45:  Or to sample the exponential function between $-5$ and $5$ at intervals of $0.5$
Step46:  Linear algebra in NumPy
NumPy ships with a basic linear algebra library, and all arrays have a dot method whose behavior is that of the scalar dot product when its arguments are vectors (one-dimensional arrays) and the traditional matrix multiplication when one or both of its arguments are two-dimensional arrays
Step47:  For matrix-matrix multiplication, the regular $matrix \times matrix$ rules must be satisfied. For example $A \times A^T$
Step48:  results in a (2, 2) array, yet $A^T \times A$ results in a (3, 3). 
Why is this?
Step49:  NumPy makes no distinction between row and column vectors and simply verifies that the dimensions match the required rules of matrix multiplication. 
Below is an example of matrix-vector multiplication, and in this case we have a $2 \times 3$ matrix multiplied by a 3-vector, which produces a 2-vector
<ASSISTANT_TASK:>
Python Code:
import numpy as np
Explanation: An introduction to NumPy
NumPy provides an efficient representation of multidimensional datasets like vectors and matricies, and tools for linear algebra and general matrix manipulations - essential building blocks of virtually all technical computing
Typically NumPy is imported as np:
End of explanation
lst = [10, 20, 30, 40]
arr = np.array([10, 20, 30, 40])
print(lst)
print(arr)
Explanation: NumPy, at its core, provides a powerful array object.  Let's start by exploring how the NumPy array differs from a Python list.  
We start by creating a simple Python list and a NumPy array with identical contents:
End of explanation
print(lst[0], arr[0])
print(lst[-1], arr[-1])
print(lst[2:], arr[2:])
Explanation: Element indexing
Elements of a one-dimensional array are accessed with the same syntax as a list:
End of explanation
lst[-1] = 'a string inside a list'
lst
Explanation: Differences between arrays and lists
The first difference to note between lists and arrays is that arrays are homogeneous; i.e. all elements of an array must be of the same type.  In contrast, lists can contain elements of arbitrary type. For example, we can change the last element in our list above to be a string:
End of explanation
arr[-1] = 'a string inside an array'
Explanation: But the same can not be done with an array, as we get an error message:
End of explanation
print('Data type                :', arr.dtype)
print('Total number of elements :', arr.size)
print('Number of dimensions     :', arr.ndim)
print('Shape (dimensionality)   :', arr.shape)
print('Memory used (in bytes)   :', arr.nbytes)
Explanation: Caveat, it can be done, but really don't do it; lists are generally better at non-homogeneous collections.
Array Properties and Methods
The following provide basic information about the size, shape and data in the array:
End of explanation
print('Minimum and maximum             :', arr.min(), arr.max())
print('Sum and product of all elements :', arr.sum(), arr.prod())
print('Mean and standard deviation     :', arr.mean(), arr.std())
Explanation: Arrays also have many useful statistical/mathematical methods:
End of explanation
arr.dtype
Explanation: Data types
The information about the type of an array is contained in its dtype attribute.
End of explanation
arr[-1] = 1.234
arr
Explanation: Once an array has been created, its dtype is fixed (in this case to an 8 byte/64 bit signed integer) and it can only store elements of the same type.
For this example where the dtype is integer, if we try storing a floating point number in the array it will be automatically converted into an integer:
End of explanation
np.array(256, dtype=np.uint8)
float_info = ('{finfo.dtype}: max={finfo.max:<18}, '
              'approx decimal precision={finfo.precision};')
print(float_info.format(finfo=np.finfo(np.float32)))
print(float_info.format(finfo=np.finfo(np.float64)))
Explanation: NumPy comes with most of the common data types (and some uncommon ones too).
The most used (and portable) dtypes are:
bool
uint8
int (machine dependent)
int8
int32
int64
float (machine dependent)
float32
float64
Full details can be found at http://docs.scipy.org/doc/numpy/user/basics.types.html.
What are the limits of the common NumPy integer types?
End of explanation
np.array(1, dtype=np.uint8).astype(np.float32)
Explanation: Floating point precision is covered in detail at http://en.wikipedia.org/wiki/Floating_point.
However, we can convert an array from one type to another with the astype method
End of explanation
np.zeros(5, dtype=np.float)
np.zeros(3, dtype=np.int)
Explanation: Creating Arrays
Above we created an array from an existing list. Now let's look into other ways in which we can create arrays. 
A common need is to have an array initialized with a constant value. Very often this value is 0 or 1. 
zeros creates arrays of all zeros, with any desired dtype:
End of explanation
print('5 ones:', np.ones(5, dtype=np.int))
Explanation: and similarly for ones:
End of explanation
a = np.empty(4, dtype=np.float)
a.fill(5.5)
a
Explanation: If we want an array initialized with an arbitrary value, we can create an empty array and then use the fill method to put the value we want into the array:
End of explanation
np.arange(10, dtype=np.float64)
np.arange(5, 7, 0.1)
Explanation: Alternatives, such as:
np.ones(4) * 5.5
np.zeros(4) + 5.5
are generally less efficient, but are also reasonable. 
Filling arrays with sequences
NumPy also offers the arange function, which works like the builtin range but returns an array instead of a list:
End of explanation
print("A linear grid between 0 and 1:")
print(np.linspace(0, 1, 5))
print("A logarithmic grid between 10**2 and 10**4:")
print(np.logspace(2, 4, 3))
Explanation: The linspace and logspace functions to create linearly and logarithmically-spaced grids respectively, with a fixed number of points that include both ends of the specified interval:
End of explanation
import numpy as np
import numpy.random
Explanation: Finally, it is often useful to create arrays with random numbers that follow a specific distribution. 
The np.random module contains a number of functions that can be used to this effect.
For more details see http://docs.scipy.org/doc/numpy/reference/routines.random.html.
Creating random arrays
Finally, it is often useful to create arrays with random numbers that follow a specific distribution.  The np.random module contains a number of functions that can be used to this effect.
First, we must import it:
End of explanation
print(np.random.randn(5))
Explanation: To produce an array of 5 random samples taken from a standard normal distribution (0 mean and variance 1):
End of explanation
norm10 = np.random.normal(10, 3, 5)
print(norm10)
Explanation: For an array of 5 samples from the normal distribution with a mean of 10 and a variance of 3:
End of explanation
mask = norm10 > 9
mask
Explanation: Indexing with other arrays
Above we saw how to index NumPy arrays with single numbers and slices, just like Python lists.  
Arrays also allow for a more sophisticated kind of indexing that is very powerful: you can index an array with another array, and in particular with an array of boolean values.  
This is particularly useful to extract information from an array that matches a certain condition.
Consider for example that in the array norm10 we want to replace all values above 9 with the value 0.  We can do so by first finding the mask that indicates where this condition is true or false:
End of explanation
print(('Values above 9:', norm10[mask]))
print('Resetting all values above 9 to 0...')
norm10[mask] = 0
print(norm10)
Explanation: Now that we have this mask, we can use it to either read those values or to reset them to 0:
End of explanation
lst2 = [[1, 2, 3], [4, 5, 6]]
arr2 = np.array([[1, 2, 3], [4, 5, 6]])
print(arr2)
print(arr2.shape)
Explanation: Whilst beyond the scope of this course, it is also worth knowing that a specific masked array object exists in NumPy.
Further details are available at http://docs.scipy.org/doc/numpy/reference/maskedarray.generic.html
Arrays with more than one dimension
Up until now all our examples have used one-dimensional arrays.  NumPy can also create arrays of arbitrary dimensions, and all the methods illustrated in the previous section work on arrays with more than one dimension.
A list of lists can be used to initialize a two dimensional array:
End of explanation
print(lst2[0][1])
print(arr2[0, 1])
Explanation: With two-dimensional arrays we start seeing the power of NumPy: while nested lists can be indexed by repeatedly using the [ ] operator, multidimensional arrays support a much more natural indexing syntax using a single [ ] and a set of indices separated by commas:
End of explanation
print(lst2[0:2][1])
print(arr2[0:2, 1])
Explanation: Question: Why does the following example produce different results?
End of explanation
np.zeros((2, 3))
np.random.normal(10, 3, size=(2, 4))
Explanation: Multidimensional array creation
The array creation functions listed previously can also be used to create arrays with more than one dimension. 
For example:
End of explanation
arr = np.arange(8).reshape(2, 4)
print(arr)
Explanation: In fact, the shape of an array can be changed at any time, as long as the total number of elements is unchanged. 
For example, if we want a 2x4 array with numbers increasing from 0, the easiest way to create it is:
End of explanation
arr = np.arange(2, 18, 2).reshape(2, 4)
print(arr)
print('Second element from dimension 0, last 2 elements from dimension one:')
print(arr[1, 2:])
Explanation: Slices
With multidimensional arrays you can also index using slices, and you can mix and match slices and single indices in the different dimensions:
End of explanation
print('First row:  ', arr[0], 'is equivalent to', arr[0, :])
print('Second row: ', arr[1], 'is equivalent to', arr[1, :])
Explanation: If you only provide one index to slice a multidimensional array, then the slice will be expanded to ":" for all of the remaining dimensions:
End of explanation
arr1 = np.empty((4, 6, 3))
print('Orig shape: ', arr1.shape)
print(arr1[...].shape)
print(arr1[..., 0:2].shape)
print(arr1[2:4, ..., ::2].shape)
print(arr1[2:4, :, ..., ::-1].shape)
Explanation: This is also known as "ellipsis".
Ellipsis can be specified explicitly with "...". It will automatically expand to ":" for each of the unspecified dimensions in the array, and can even be used at the beginning of the slice:
End of explanation
arr1 = np.arange(4)
arr2 = np.arange(10, 14)
print(arr1, '+', arr2, '=', arr1 + arr2)
Explanation: Operating with arrays
Arrays support all regular arithmetic operators, and the NumPy library also contains a complete collection of basic mathematical functions that operate on arrays.  
It is important to remember that in general, all operations with arrays are applied element-wise, i.e., are applied to all the elements of the array at the same time.  For example:
End of explanation
print(arr1, '*', arr2, '=', arr1 * arr2)
Explanation: Importantly, even the multiplication operator is by default applied element-wise. However it is not the matrix multiplication from linear algebra:
End of explanation
1.5 * arr1
Explanation: We may also multiply an array by a scalar:
End of explanation
print(np.arange(3))
print(np.arange(3) + 5)
Explanation: This is an example of broadcasting.
Broadcasting
The fact that NumPy operates on an element-wise basis means that in principle arrays must always match one another's shape. However, NumPy will also helpfully "broadcast" dimensions when possible.  
Here is an example of broadcasting a scalar to a 1D array:
End of explanation
 np.ones((3, 3)) + np.arange(3)
Explanation: We can also broadcast a 1D array to a 2D array, in this case adding a vector to all rows of a matrix:
End of explanation
a = np.arange(3).reshape((3, 1))
b = np.arange(3)
print(a, '+', b, '=\n', a + b)
Explanation: We can also broadcast in two directions at a time:
End of explanation
arr1 = np.ones((2, 3))
arr2 = np.ones((2, 1))
# arr1 + arr2
arr1 = np.ones((2, 3))
arr2 = np.ones(3)
# arr1 + arr2
arr1 = np.ones((1, 3))
arr2 = np.ones((2, 1))
# arr1 + arr2
arr1 = np.ones((1, 3))
arr2 = np.ones((1, 2))
# arr1 + arr2
arr1 = np.ones((1, 3))
arr3 = arr2[:, :, np.newaxis]
# arr1 + arr3
Explanation: Pictorially:
(image source)
Rules of Broadcasting
Broadcasting follows these three rules:
If the two arrays differ in their number of dimensions, the shape of the array with fewer dimensions is padded with ones on its leading (left) side.
If the shape of the two arrays does not match in any dimension, either array with shape equal to 1 in a given dimension is stretched to match the other shape.
If in any dimension the sizes disagree and neither has shape equal to 1, an error is raised.
Note that all of this happens without ever actually creating the expanded arrays in memory! This broadcasting behavior is in practice enormously powerful, especially given that when NumPy broadcasts to create new dimensions or to 'stretch' existing ones, it doesn't actually duplicate the data.  In the example above the operation is carried out as if the scalar 1.5 was a 1D array with 1.5 in all of its entries, but no actual array is ever created.  This can save lots of memory in cases when the arrays in question are large. As such this can have significant performance implications.
Broadcasting Examples
So when we do...
np.arange(3) + 5
The scalar 5 is:
first 'promoted' to a 1-dimensional array of length 1 (rule 1)
then, this array is 'stretched' to length 3 to match the first array. (rule 2)
After these two operations are complete, the addition can proceed as now both operands are one-dimensional arrays of length 3.
When we do
np.ones((3, 3)) + np.arange(3)
The second array is:
first 'promoted' to a 2-dimensional array of shape (1, 3) (rule 1)
then axis 0 is 'stretched' to length 3 to match the first array (rule 2)
When we do
np.arange(3).reshape((3, 1)) + np.arange(3)
The second array is:
first 'promoted' to a 2-dimensional array of shape (1, 3) (rule 1)
then axis 0 is 'stretched' to form an array of shape (3, 3) (rule 2)
and the first array's axis 1 is 'stretched' to form an array of shape (3, 3) (rule 2)
Then the operation proceeds as if on two 3 $\times$ 3 arrays.
The general rule is: when operating on two arrays, NumPy compares their shapes element-wise. It starts with the trailing dimensions, and works its way forward, creating dimensions of length 1 as needed. Two dimensions are considered compatible when
they are equal to begin with, or
one of them is 1; in this case NumPy will do the 'stretching' to make them equal.
If these conditions are not met, a ValueError: operands could not be broadcast together exception is thrown, indicating that the arrays have incompatible shapes.
Questions:
What will the result of adding arr1 with arr2 be in the following cases?
End of explanation
print('For the following array:\n', arr)
print('The sum of elements along the rows is    :', arr.sum(axis=1))
print('The sum of elements along the columns is :', arr.sum(axis=0))
Explanation: Exercise 1
1. Use np.arange and reshape to create the array
A = [[1 2 3 4]
     [5 6 7 8]]
2. Use np.array to create the array
B = [1 2]
3. Use broadcasting to add B to A to create the final array
A + B = [[2  3  4  5]
         [7  8  9 10]
Hint: what shape does B have to be changed to?
Array Properties and Methods (cont.)
For multidimensional arrays it is possible to carry out computations along a single dimension by passing the axis parameter:
End of explanation
np.zeros((3, 4, 5, 6)).sum(axis=2).shape
Explanation: As you can see in this example, the value of the axis parameter is the dimension that will be consumed once the operation has been carried out.  This is why to sum along the columns we use axis=0.  
This can be easily illustrated with an example that has more dimensions; we create an array with 4 dimensions and shape (3,4,5,6) and sum along the axis number 2 (i.e. the third axis, since in Python all counts are 0-based).  That consumes the dimension whose length was 5, leaving us with a new array that has shape (3,4,6):
End of explanation
print('Array:\n', arr)
print('Transpose:\n', arr.T)
Explanation: Another widely used property of arrays is the .T attribute, which allows you to access the transpose of the array:
End of explanation
x = np.linspace(0, 9, 3)
y = np.linspace(-8, 4, 3)
x2d, y2d = np.meshgrid(x, y)
print(x2d)
print(y2d)
Explanation: Generating 2D coordinate arrays
A common task is to generate a pair of arrays that represent the coordinates of our data.
When orthogonal 1d coordinate arrays already exist, NumPy's meshgrid function is very useful:
End of explanation
np.arange(6).reshape((1, -1))
np.arange(6).reshape((2, -1))
Explanation: Reshape and newaxis
Reshaping arrays is a common task in order to make the best of NumPy's powerful broadcasting.
A useful tip with the reshape method is that it is possible to provide a -1 length for at most one of the dimensions. This indicates that NumPy should automatically calculate the length of this dimension:
End of explanation
arr = np.arange(6)
print(arr[np.newaxis, :, np.newaxis].shape)
Explanation: Another way to increase the dimensionality of an array is to use the newaxis keyword:
End of explanation
arr = np.arange(8)
arr_view = arr.reshape(2, 4)
Explanation: Views, not Copies
Note that reshaping (like most NumPy operations), wherever possible, provides a view of the same memory:
End of explanation
# Print the "view" array from reshape.
print('Before\n', arr_view)
# Update the first element of the original array.
arr[0] = 1000
# Print the "view" array from reshape again,
# noticing the first value has changed.
print('After\n', arr_view)
Explanation: What this means is that if one array is modified, the other will also be updated:
End of explanation
x = np.linspace(0, 2*np.pi, 100)
y = np.sin(x)
Explanation: This lack of copying allows for very efficient vectorized operations, but this power should be used carefully - if used badly it can lead to some bugs that are hard to track down.
If in doubt, you can always copy the data to a different block of memory with the copy() method.
Element-wise Functions
NumPy ships with a full complement of mathematical functions that work on entire arrays, including logarithms, exponentials, trigonometric and hyperbolic trigonometric functions, etc.  
For example, sampling the sine function at 100 points between $0$ and $2\pi$ is as simple as:
End of explanation
x = np.arange(-5, 5.5, 0.5)
y = np.exp(x)
Explanation: Or to sample the exponential function between $-5$ and $5$ at intervals of $0.5$:
End of explanation
v1 = np.array([2, 3, 4])
v2 = np.array([1, 0, 1])
print(v1, '.', v2, '=', np.dot(v1, v2))
Explanation: Linear algebra in NumPy
NumPy ships with a basic linear algebra library, and all arrays have a dot method whose behavior is that of the scalar dot product when its arguments are vectors (one-dimensional arrays) and the traditional matrix multiplication when one or both of its arguments are two-dimensional arrays:
End of explanation
A = np.arange(6).reshape(2, 3)
print(A, '\n')
print(np.dot(A, A.T))
Explanation: For matrix-matrix multiplication, the regular $matrix \times matrix$ rules must be satisfied. For example $A \times A^T$:
End of explanation
print(np.dot(A.T, A))
Explanation: results in a (2, 2) array, yet $A^T \times A$ results in a (3, 3). 
Why is this?:
End of explanation
print(A, 'x', v1, '=', np.dot(A, v1))
Explanation: NumPy makes no distinction between row and column vectors and simply verifies that the dimensions match the required rules of matrix multiplication. 
Below is an example of matrix-vector multiplication, and in this case we have a $2 \times 3$ matrix multiplied by a 3-vector, which produces a 2-vector:
End of explanation
<END_TASK>