import numpy as np

"""
Explanation: 01 Introduction to Scientific and Numerical Python
See Jake Vanderplas' Intro to NumPy, Efficient NumPy and Intro to Scipy for more details.  This notebook was created from a selection of materials in those more exhaustive notebooks.

Introduction
While the Python language is an excellent tool for general-purpose programming, with a highly readable syntax, rich and powerful data types (strings, lists, sets, dictionaries, arbitrary length integers, etc) and a very comprehensive standard library, it was not designed specifically for mathematical and scientific computing. Neither the language nor its standard library have facilities for the efficient representation of multidimensional datasets, tools for linear algebra and general matrix manipulations (an essential building block of virtually all technical computing), nor any data visualization facilities.
SciPy is the premiere package for scientific computing in Python. It has many, many useful tools, which are central to scientific computing tasks you'll come across.
Some of the submodules available in SciPy include:

File input/output: scipy.io
Special functions: scipy.special
Linear algebra operations: scipy.linalg
Fast Fourier transforms: scipy.fftpack
Optimization and fit: scipy.optimize
Statistics and random numbers: scipy.stats
Interpolation: scipy.interpolate
Numerical integration: scipy.integrate
Signal processing: scipy.signal
Image processing: scipy.ndimage
Sparse Matrices: scipy.sparse
Physical Constants: scipy.constants
Spatial metrics: scipy.spatial

These are often best learned on an "as-needed basis", when a project calls for processing in one of these categories.  Before we get into the SciPy libraries, we need to learn how to efficiently compute with the NumPy library.  Both are installed by default with the Anaconda distribution of Python.
The NumPy library forms the base layer for the entire SciPy ecosystem.  You can import it into the notebook with
End of explanation
"""


lst = [10, 20, 30, 40]
arr = np.array([10, 20, 30, 40])

"""
Explanation: We do this to save time with all future functions. Instead of needing to type (for example)
numpy.sin(x)
we can simply type
np.sin(x) 
As you work through this notebook, I encourage you to not only run the code in each cell, but spend some time to manipulate and play with the code you see until you feel comfortable with what each cell does.
You don't need to memorize everything in this notebook. 
It's great to have basic commands down by heart, but there is so much to coding that no one knows it all.  Professionals are constantly googling and referencing other code.   Coding is more about figuring out what you want in the end, and then using your knowledge AND resources to help the computer get you there.

Basics of NumPy Arrays
You learned about Python lists during your CodeCademy Bootcamp.  Although they are very flexible containers, they are poorly suited to represent efficiently common mathematical constructs like vectors and matrices.
The main object provided by numpy is a powerful array. We'll start by exploring how the numpy array differs from Python lists. We start by creating a simple list and an array with the same contents of the list:
End of explanation
"""


arr[0]

lst[0]

arr[-1]

arr[2:]

"""
Explanation: 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

arr[-1] = 'a string inside an array'

"""
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.dtype

"""
Explanation: but this NumPy array can only contain integers, so you get an error when you try to add a string to a NumPy array.
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 and it can only store elements of the same type. For this example where the dtype is integer, if we store a floating point number it will be automatically converted into an integer:
End of explanation
"""


np.zeros(5, dtype=float)

np.zeros(3, dtype=int)

np.zeros(3, dtype=complex)

print '5 ones:', np.ones(5)

a = np.empty(4)
a.fill(5.5)
a

a = np.empty(3,dtype=int)
a.fill(7)
a

"""
Explanation: Array Creation
As we did above, you can create an array from a python list, but there are other methods of creating arrays that are very useful.  For example, we may want to create an array initialized to a specific value, like 0 or 1, which can then be built upon with addition or multiplication to get arrays of any initial value.  To create these, use zeros or ones and specify the desired type:
End of explanation
"""


np.arange(5)

"""
Explanation: Numpy also offers the arange function, which works like the builtin range but returns an array instead of a list:
End of explanation
"""


c = np.arange(2,16,2)
c

"""
Explanation: But notice something important about how it is used:
End of explanation
"""


print "A linear sequence between 0 and 1 with 4 elements:"
print np.linspace(0, 1, 4)

print "A logarithmic sequence between 10**1 and 10**3 with 4 elements:"
print np.logspace(1, 3, 4)

"""
Explanation: The max number in the range is exclusive because python numbering starts at zero.
Alternatively, the linspace and logspace functions create linearly and logarithmically-spaced sequences respectively, with a fixed number of points and including both ends of the specified interval:
End of explanation
"""


np.random.randn(5)

"""
Explanation: 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 example this will produce an array of 5 random samples taken from a standard normal distribution, or "bell curve" with a mean (average) value of 0 and variance (width) of 1:
End of explanation
"""


norm10 = np.random.normal(10, 3, 5)
norm10

"""
Explanation: whereas this will also give 5 samples, but from a normal distribution (bell curve) with a mean of 10 and a variance of 3:
End of explanation
"""


#Your code here

"""
Explanation: Exercise 1
How about making a few arrays for yourself?

Create an array of 10 integer values
Create an array of 5 floating point values
End of explanation
"""


#Your code here

"""
Explanation: Exercise 2
Use np.linspace to place 5 floating point numbers in the range between 7. and 12.
Look at the documentation for np.arange and use it to create a set of numbers between 7 and 12.  
How are np.linspace and np.arange different?
End of explanation
"""


lst2 = [[1, 2], [3, 4]]
arr2 = np.array([[1, 2], [3, 4]])

"""
Explanation: Multidimensional arrays
Numpy can create arrays of aribtrary dimensions.  For example, a list of lists can be used to initialize a two dimensional array:
End of explanation
"""


lst2

"""
Explanation: How are these different?  Print them to see.
arr2
End of explanation
"""


print lst2[0][1]
print arr2[0,1]

"""
Explanation: With two-dimensional arrays we start seeing the power of NumPy.  If we wanted to access the second element in the first list of lst2, we would have to type lst2[0],[1]. We do this because first we need to access the "zeroth" list (remember Python starts numbering elements at 0), and then the "first" element of that list. (Again, since the number 1 corresponds to the second item in a list.) Whereas, if we wanted to access the second element in the first row of arr2, we simply tell it the row number and column number. Remembering that since NumPy starts numbering at 0, we would access the "zeroth" row and "first" element as [0,1]. 
While nested lists can be indexed using the [ ] operator repeatedly, NumPy arrays support a much more natural indexing syntax with a single [ ] and a set of indices separated by commas:
End of explanation
"""


np.zeros((2,3))

"""
Explanation: Most of the array creation functions listed above can be used with more than one dimension, for example:
End of explanation
"""


arr = np.arange(8).reshape(2, 4)
print arr

"""
Explanation: 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(8)
arr2 = arr.reshape(2, 4)

arr[0] = 1000
print arr
print arr2

"""
Explanation: But note that reshaping (like most NumPy operations) provides a view of the same memory:
End of explanation
"""


print 'Slicing in the second row:', arr2[1, 2:4]
print 'All rows, third column   :', arr2[:, 2]

print 'First row:  ', arr2[0]
print 'Second row: ', arr2[1]

#Print some properties of the array arr2
print 'Data type                :', arr2.dtype
print 'Total number of elements :', arr2.size
print 'Number of dimensions     :', arr2.ndim
print 'Shape (dimensionality)   :', arr2.shape
print 'Memory used (in bytes)   :', arr2.nbytes

#Print some useful information that the arrays can calculate for us
print 'Minimum and maximum             :', arr2.min(), arr2.max()
print 'Sum and product of all elements :', arr2.sum(), arr2.prod()
print 'Mean and standard deviation     :', arr2.mean(), arr2.std()

"""
Explanation: This lack of copying allows for very efficient vectorized operations.
With multidimensional arrays, you can also use slices, and you can mix and match slices and single indices in the different dimensions:
End of explanation
"""


print 'For the following array:\n', arr2
print 'The sum of elements along the rows is    :', arr2.sum(axis=1)
print 'The sum of elements along the columns is :', arr2.sum(axis=0)

"""
Explanation: It's also possible to do the computation along a single dimension, by passing the axis parameter; for example:
End of explanation
"""


print 'Array:\n', arr2
print 'Transpose:\n', arr2.T

"""
Explanation: As you can see in this example, the value of the axis parameter is the dimension which will be consumed once the operation has been carried out. This is why to sum along the rows we use axis=1.
Another widely used property of arrays is the .T attribute, which allows you to access the transpose of the array:
End of explanation
"""


#identity matrix
c = np.eye(3)
c

#diagonal matrix wth elements of an array
d = np.diag(np.array([1,2,3,4]))
d

e = np.diag(np.linspace(0,1,6))
e

"""
Explanation: NumPy can also create some useful matrices:
End of explanation
"""


e[1,1]

e[2,2]

"""
Explanation: To access the elements of a multidimensional (in this case 2D) array:
End of explanation
"""


#Your code here

"""
Explanation: Exercise 3
Create the following arrays with correct data types:
[[1 1 1 1]
 [1 1 1 1]
 [1 1 1 2]
 [1 6 1 1]]
[[0. 0. 0. 0. 0.]
 [2. 0. 0. 0. 0.]
 [0. 3. 0. 0. 0.]
 [0. 0. 4. 0. 0.]
 [0. 0. 0. 5. 0.]
 [0. 0. 0. 0. 6.]]
You should be able to accomplish this in 3 lines of code for each one. The documentation for the diag method may be helpful.
End of explanation
"""


#Your code here

"""
Explanation: Exercise 4
Read the documentation for np.tile and use this function to construct the following array:
[[4 3 4 3 4 3]
 [2 1 2 1 2 1]
 [4 3 4 3 4 3]
 [2 1 2 1 2 1]]
End of explanation
"""


#Your code here

"""
Explanation: Exercise 5
Print the shape, size, dimensions, minimum, maximum, and mean values of the array from exercise 4.
End of explanation
"""


a = np.arange(10)
a

a[:4]

a[2:4]

a[5:]

a[:]

"""
Explanation: Slicing Basics
To select a range of elements in the array, you can use slices, just like for Python lists:
End of explanation
"""


a[2:9:3]

"""
Explanation: You can also give the step size:
End of explanation
"""


#Your code here

"""
Explanation: Exercise 6
Create an array of 25 values between 17. and 42. using np.linspace.  Print the result and then use a slice to print only every third value in the array.
End of explanation
"""


norm10 = np.random.normal(10, 3, 5)
norm10

mask = norm10 > 9
mask

"""
Explanation: Boolean Masks
Arrays allow for a more sophisticated kind of indexing which 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
"""


#Your code here

"""
Explanation: Exercise 7
Create a normal distribution of 8 numbers with mean 0 and variance 2.  Then create a mask that selects only the negative values in the array.  Print the following:

The array of 8 numbers
The mask of boolean values
The array with the boolean mask as the selection

Now use the mask to set all the negative values to equal 8 and print the array again.
End of explanation
"""


arr1 = np.arange(4)
arr2 = np.arange(10, 14)
print arr1, '+', arr2, '=', arr1+arr2

"""
Explanation: Operations 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. Consider for example:
End of explanation
"""


print arr1, '*', arr2, '=', arr1*arr2

"""
Explanation: Importantly, you must remember that even the multiplication operator is by default applied element-wise, it is not the matrix multiplication from linear algebra (as is the case in Matlab, for example):
End of explanation
"""


1.5 * arr1

"""
Explanation: We may also multiply an array by a scalar:
End of explanation
"""


x = np.linspace(0, 2*np.pi, 100)
y = np.sin(x)

"""
Explanation: Universal Functions or ufuncs
NumPy ships with a full complement of mathematical functions that work on entire arrays, including logarithms, exponentials, trigonometric and hyperbolic trigonometric functions, etc.  The most important reason to learn these functions now is that they allow you to very quickly manipulate arrays for mathematical calculations.
For example, sampling the sine function at 100 points between 0 and $2\pi$ is as simple as:
End of explanation
"""


x = np.random.random(4)
print x
print x + 1  # add 1 to each element of x

print x * 2  # multiply each element of x by 2

print x * x  # multiply each element of x by itself

print x[1:] - x[:-1]

"""
Explanation: These are examples of Universal Functions or ufuncs in NumPy. They operate element-wise on an array. We've already seen examples of these in the various arithmetic operations:
End of explanation
"""


-x

np.sin(x)

"""
Explanation: These are binary ufuncs: they take two arguments.  There are also unary ufuncs:
End of explanation
"""


x = np.random.random(10000)

%%timeit
# compute element-wise x + 1 via a ufunc 
y = np.zeros_like(x)
y = x + 1

%%timeit
# compute element-wise x + 1 via a loop
y = np.zeros_like(x)
for i in range(len(x)):
    y[i] = x[i] + 1

"""
Explanation: Ufuncs are very fast.  If you wanted to create a very large array and then add 1 to every element in the array, you could write a for loop to do it element by element, but it would take a long time to complete compared to what you can do with arrays.  Here's an example, using the %%timeit magic function to show how long the two methods take.
End of explanation
"""


x = np.random.random(5)
print x
print np.minimum(x, 0.5)
print np.maximum(x, 0.5)

"""
Explanation: NumPy ufuncs are faster than Python functions involving loops, because the looping happens in compiled code. This is only possible when types are known beforehand, which is why NumPy arrays must be typed.
Other Available Ufuncs:

Trigonometric functions (np.sin, np.cos, etc.)
Scipy special functions (scipy.special.j0, scipy.special.gammaln, etc.)
Element-wise minimum/maximum (np.minimum, np.maximum)
User-defined ufuncs
End of explanation
"""


print np.min(x)
print np.max(x)

"""
Explanation: How is this different from min() and max()?
End of explanation
"""


#Your code here

"""
Explanation: We've only scratched the surface of what NumPy can do.  It would be overwhelming to try to show you all of NumPy's capabilities in an introductory notebook, so we will end here with a final exercise.  As you follow along in the subsequent notebooks, notice where and how NumPy arrays are used.  You may have ideas about something you'd like to do with an array to solve a problem or complete an exercise.  Check the NumPy documentation or Google for help with more advanced commands and syntax.

Final Exercise
Let's practice some of the ideas we have learned with a simple exercise to create a prime number sieve. We will use the sieve to see what numbers between 0 and 100 are prime.
(a) First, we will construct an array of booleans called is prime with shape (100,), filled with True in all the elements.  Type the next line of code to create the array, then print it.
Don't copy/paste.  Type it yourself.
python
is_prime = np.ones((100,), dtype=bool)
End of explanation
"""


#Your code here

"""
Explanation: (b) The index of each boolean element represents the number. “Cross out” 0 and 1, which are not primes. You can either set them to False or 0 (which python recognizes as equivalent to False for boolean types) in the array is_prime.  Then print the array again to see what changed.
End of explanation
"""


#Your code here

"""
Explanation: (c) For each subsequent integer j starting from 2, cross out its higher multiples.  
Again, type this code yourself.  Don't copy/paste.
python
N_max = int(np.sqrt(len(is_prime)))
for j in range(2, N_max):
    is_prime[2*j::j] = False
What the heck is this code doing? What does that slicing of the array mean?  Try printing the values of j, 2*j, and is_prime inside the loop to see what is happening at each step.  Can you figure it out from the values at each iteration?
End of explanation
"""


#Your code here

"""
Explanation: (d) Look up the documentation for np.nonzero (try help(np.nonzero) or np.nonzero?) and use it to print the prime numbers that are left in the array is_prime.
End of explanation
"""


#Your code here

"""
Explanation: (e) Finally, combine the above code into a new function, called eratosthenes_sieve() that takes one argument, maximum, the maximum number to test for primes, and returns an array containing the prime numbers between 2 and maximum. 
The algorithm we just implemented was developed by Eratosthenes and is described on wikipedia. 
Print the result for the test cases where maximum = 10 and 100.
Below is the scaffold for the function and the test cases you should implement.
```python
def eratosthenes_sieve(maximum):
    #your code here
    return prime_array
print eratosthenes_sieve(10)
print eratosthenes_sieve(100)
```
End of explanation
"""


