# Exploring Data with Python

A significant part of a data scientist's role is to explore, analyze, and visualize data. There's a wide range of tools and programming languages that they can use to do this, and of the most popular approaches is to use Jupyter notebooks (like this one) and Python.

Python is a flexible programming language that is used in a wide range of scenarios; from web applications to device programming. It's extremely popular in the data science and machine learning community because of the many packages it supports for data analysis and visualization.

In this notebook, we'll explore some of these packages, and apply basic techniques to analyze data. This is not intended to be a comprehensive Python programming exercise; or even a deep dive into data analysis. Rather, it's intended as a crash course in some of the common ways in which data scientists can use Python to work with data.

> **Note**: If you've never used the Jupyter Notebooks environment before, there are a few things you should be aware of:
>
> - Notebooks are made up of *cells*. Some cells (like this one) contain *markdown* text, while others (like the one beneath this one) contain code.
> - The notebook is connected to a Python *kernel* (you can see which one at the top right of the page - if you're running this notebook in an Azure Machine Learning compute instance it should be connected to the **Python 3.6 - AzureML** kernel). If you stop the kernel or disconnect from the server (for example, by closing and reopening the notebook, or ending and resuming your session), the output from cells that have been run will still be displayed; but any variables or functions defined in those cells will have been lost - you must rerun the cells before running any subsequent cells that depend on them.
> - You can run each code cell by using the **► Run** button. The **◯** symbol next to the kernel name at the top right will briefly turn to **⚫** while the cell runs before turning back to **◯**.
> - The output from each code cell will be displayed immediately below the cell.
> - Even though the code cells can be run individually, some variables used in the code are global to the notebook. That means that you should run all of the code cells **in order**. There may be dependencies between code cells, so if you skip a cell, subsequent cells might not run correctly.

## Exploring data arrays with NumPy

Let's start by looking at some simple data.

Suppose a college takes a sample of student grades for a data science class.

Run the code in the cell below by clicking the **► Run** button to see the data.

```python
data = [50,50,47,97,49,3,53,42,26,74,82,62,37,15,70,27,36,35,48,52,63,64]
print(data)
```

    [50, 50, 47, 97, 49, 3, 53, 42, 26, 74, 82, 62, 37, 15, 70, 27, 36, 35, 48, 52, 63, 64]

The data has been loaded into a Python **list** structure, which is a good data type for general data manipulation, but not optimized for numeric analysis. For that, we're going to use the **NumPy** package, which includes specific data types and functions for working with *Num*bers in *Py*thon.

Run the cell below to load the data into a NumPy **array**.

```python
import numpy as np

grades = np.array(data)
print(grades)
```

    [50 50 47 97 49  3 53 42 26 74 82 62 37 15 70 27 36 35 48 52 63 64]

Just in case you're wondering about the differences between a **list** and a NumPy **array**, let's compare how these data types behave when we use them in an expression that multiplies them by 2.

```python
print (type(data),'x 2:', data * 2)
print('---')
print (type(grades),'x 2:', grades * 2)
```

    <class 'list'> x 2: [50, 50, 47, 97, 49, 3, 53, 42, 26, 74, 82, 62, 37, 15, 70, 27, 36, 35, 48, 52, 63, 64, 50, 50, 47, 97, 49, 3, 53, 42, 26, 74, 82, 62, 37, 15, 70, 27, 36, 35, 48, 52, 63, 64]
    ---
    <class 'numpy.ndarray'> x 2: [100 100  94 194  98   6 106  84  52 148 164 124  74  30 140  54  72  70
      96 104 126 128]

Note that multiplying a list by 2 creates a new list of twice the length with the original sequence of list elements repeated. Multiplying a NumPy array on the other hand performs an element-wise calculation in which the array behaves like a *vector*, so we end up with an array of the same size in which each element has been multiplied by 2.

The key takeaway from this is that NumPy arrays are specifically designed to support mathematical operations on numeric data - which makes them more useful for data analysis than a generic list.

You might have spotted that the class type for the numpy array above is a **numpy.ndarray**. The **nd** indicates that this is a structure that can consists of multiple *dimensions* (it can have *n* dimensions). Our specific instance has a single dimension of student grades.

Run the cell below to view the **shape** of the array.

```python
grades.shape
```

    (22,)

The shape confirms that this array has only one dimension, which contains 22 elements (there are 22 grades in the original list). You can access the individual elements in the array by their zero-based ordinal position. Let's get the first element (the one in position 0).

```python
grades[0]
```

    50

Alright, now you know your way around a NumPy array, it's time to perform some analysis of the grades data.

You can apply aggregations across the elements in the array, so let's find the simple average grade (in other words, the *mean* grade value).

```python
grades.mean()
```

    49.18181818181818

So the mean grade is just around 50 - more or less in the middle of the possible range from 0 to 100.

Let's add a second set of data for the same students, this time recording the typical number of hours per week they devoted to studying.

```python
# Define an array of study hours
study_hours = [10.0,11.5,9.0,16.0,9.25,1.0,11.5,9.0,8.5,14.5,15.5,
               13.75,9.0,8.0,15.5,8.0,9.0,6.0,10.0,12.0,12.5,12.0]

# Create a 2D array (an array of arrays)
student_data = np.array([study_hours, grades])

# display the array
student_data
```

    array([[10.  , 11.5 ,  9.  , 16.  ,  9.25,  1.  , 11.5 ,  9.  ,  8.5 ,
            14.5 , 15.5 , 13.75,  9.  ,  8.  , 15.5 ,  8.  ,  9.  ,  6.  ,
            10.  , 12.  , 12.5 , 12.  ],
           [50.  , 50.  , 47.  , 97.  , 49.  ,  3.  , 53.  , 42.  , 26.  ,
            74.  , 82.  , 62.  , 37.  , 15.  , 70.  , 27.  , 36.  , 35.  ,
            48.  , 52.  , 63.  , 64.  ]])

Now the data consists of a 2-dimensional array - an array of arrays. Let's look at its shape.

```python
# Show shape of 2D array
student_data.shape
```

    (2, 22)

The **student_data** array contains two elements, each of which is an array containing 22 elements.

To navigate this structure, you need to specify the position of each element in the hierarchy. So to find the first value in the first array (which contains the study hours data), you can use the following code.

```python
# Show the first element of the first element
student_data[0][0]
```

    10.0

Now you have a multidimensional array containing both the student's study time and grade information, which you can use to compare data. For example, how does the mean study time compare to the mean grade?

```python
# Get the mean value of each sub-array
avg_study = student_data[0].mean()
avg_grade = student_data[1].mean()

print('Average study hours: {:.2f}\nAverage grade: {:.2f}'.format(avg_study, avg_grade))
```

    Average study hours: 10.52
    Average grade: 49.18

## Exploring tabular data with Pandas

While NumPy provides a lot of the functionality you need to work with numbers, and specifically arrays of numeric values; when you start to deal with two-dimensional tables of data, the **Pandas** package offers a more convenient structure to work with - the **DataFrame**.

Run the following cell to import the Pandas library and create a DataFrame with three columns. The first column is a list of student names, and the second and third columns are the NumPy arrays containing the study time and grade data.

```python
import pandas as pd

df_students = pd.DataFrame({'Name': ['Dan', 'Joann', 'Pedro', 'Rosie', 'Ethan', 'Vicky', 'Frederic', 'Jimmie',
                                     'Rhonda', 'Giovanni', 'Francesca', 'Rajab', 'Naiyana', 'Kian', 'Jenny',
                                     'Jakeem','Helena','Ismat','Anila','Skye','Daniel','Aisha'],
                            'StudyHours':student_data[0],
                            'Grade':student_data[1]})

df_students
```

      Name
      StudyHours
      Grade

      0
      Dan
      10.00
      50.0

      1
      Joann
      11.50
      50.0

      2
      Pedro
      9.00
      47.0

      3
      Rosie
      16.00
      97.0

      4
      Ethan
      9.25
      49.0

      5
      Vicky
      1.00
      3.0

      6
      Frederic
      11.50
      53.0

      7
      Jimmie
      9.00
      42.0

      8
      Rhonda
      8.50
      26.0

      9
      Giovanni
      14.50
      74.0

      10
      Francesca
      15.50
      82.0

      11
      Rajab
      13.75
      62.0

      12
      Naiyana
      9.00
      37.0

      13
      Kian
      8.00
      15.0

      14
      Jenny
      15.50
      70.0

      15
      Jakeem
      8.00
      27.0

      16
      Helena
      9.00
      36.0

      17
      Ismat
      6.00
      35.0

      18
      Anila
      10.00
      48.0

      19
      Skye
      12.00
      52.0

      20
      Daniel
      12.50
      63.0

      21
      Aisha
      12.00
      64.0

Note that in addition to the columns you specified, the DataFrame includes an *index* to unique identify each row. We could have specified the index explicitly, and assigned any kind of appropriate value (for example, an email address); but because we didn't specify an index, one has been created with a unique integer value for each row.

### Finding and filtering data in a DataFrame

You can use the DataFrame's **loc** method to retrieve data for a specific index value, like this.

```python
# Get the data for index value 5
df_students.loc[5]
```

    Name          Vicky
    StudyHours        1
    Grade             3
    Name: 5, dtype: object

You can also get the data at a range of index values, like this:

```python
# Get the rows with index values from 0 to 5
df_students.loc[0:5]
```

      Name
      StudyHours
      Grade

      0
      Dan
      10.00
      50.0

      1
      Joann
      11.50
      50.0

      2
      Pedro
      9.00
      47.0

      3
      Rosie
      16.00
      97.0

      4
      Ethan
      9.25
      49.0

      5
      Vicky
      1.00
      3.0

In addition to being able to use the **loc** method to find rows based on the index, you can use the **iloc** method to find rows based on their ordinal position in the DataFrame (regardless of the index):

```python
# Get data in the first five rows
df_students.iloc[0:5]
```

      Name
      StudyHours
      Grade

      0
      Dan
      10.00
      50.0

      1
      Joann
      11.50
      50.0

      2
      Pedro
      9.00
      47.0

      3
      Rosie
      16.00
      97.0

      4
      Ethan
      9.25
      49.0

Look carefully at the `iloc[0:5]` results, and compare them to the `loc[0:5]` results you obtained previously. Can you spot the difference?

The **loc** method returned rows with index *label* in the list of values from *0* to *5* - which includes *0*, *1*, *2*, *3*, *4*, and *5* (six rows). However, the **iloc** method returns the rows in the *positions* included in the range 0 to 5, and since integer ranges don't include the upper-bound value, this includes positions *0*, *1*, *2*, *3*, and *4* (five rows).

**iloc** identifies data values in a DataFrame by *position*, which extends beyond rows to columns. So for example, you can use it to find the values for the columns in positions 1 and 2 in row 0, like this:

```python
df_students.iloc[0,[1,2]]
```

    StudyHours    10
    Grade         50
    Name: 0, dtype: object

Let's return to the **loc** method, and see how it works with columns. Remember that **loc** is used to locate data items based on index values rather than positions. In the absence of an explicit index column, the rows in our dataframe are indexed as integer values, but the columns are identified by name:

```python
df_students.loc[0,'Grade']
```

    50.0

Here's another useful trick. You can use the **loc** method to find indexed rows based on a filtering expression that references named columns other than the index, like this:

```python
df_students.loc[df_students['Name']=='Aisha']
```

      Name
      StudyHours
      Grade

      21
      Aisha
      12.0
      64.0

Actually, you don't need to explicitly use the **loc** method to do this - you can simply apply a DataFrame filtering expression, like this:

```python
df_students[df_students['Name']=='Aisha']
```

      Name
      StudyHours
      Grade

      21
      Aisha
      12.0
      64.0

And for good measure, you can achieve the same results by using the DataFrame's **query** method, like this:

```python
df_students.query('Name=="Aisha"')
```

      Name
      StudyHours
      Grade

      21
      Aisha
      12.0
      64.0

The three previous examples underline an occassionally confusing truth about working with Pandas. Often, there are multiple ways to achieve the same results. Another example of this is the way you refer to a DataFrame column name. You can specify the column name as a named index value (as in the `df_students['Name']` examples we've seen so far), or you can use the column as a property of the DataFrame, like this:

```python
df_students[df_students.Name == 'Aisha']
```

      Name
      StudyHours
      Grade

      21
      Aisha
      12.0
      64.0

### Loading a DataFrame from a  file

We constructed the DataFrame from some existing arrays. However, in many real-world scenarios, data is loaded from sources such as files. Let's replace the student grades DataFrame with the contents of a text file.

```python
df_students = pd.read_csv('data/grades.csv',delimiter=',',header='infer')
df_students.head()
```

      Name
      StudyHours
      Grade

      0
      Dan
      10.00
      50.0

      1
      Joann
      11.50
      50.0

      2
      Pedro
      9.00
      47.0

      3
      Rosie
      16.00
      97.0

      4
      Ethan
      9.25
      49.0

The DataFrame's **read_csv** method is used to load data from text files. As you can see in the example code, you can specify options such as the column delimiter and which row (if any) contains column headers (in this case, the delimiter is a comma and the first row contains the column names - these are the default settings, so the parameters could have been omitted).

### Handling missing values

One of the most common issues data scientists need to deal with is incomplete or missing data. So how would we know that the DataFrame contains missing values? You can use the **isnull** method to identify which individual values are null, like this:

```python
df_students.isnull()
```

      Name
      StudyHours
      Grade

      0
      False
      False
      False

      1
      False
      False
      False

      2
      False
      False
      False

      3
      False
      False
      False

      4
      False
      False
      False

      5
      False
      False
      False

      6
      False
      False
      False

      7
      False
      False
      False

      8
      False
      False
      False

      9
      False
      False
      False

      10
      False
      False
      False

      11
      False
      False
      False

      12
      False
      False
      False

      13
      False
      False
      False

      14
      False
      False
      False

      15
      False
      False
      False

      16
      False
      False
      False

      17
      False
      False
      False

      18
      False
      False
      False

      19
      False
      False
      False

      20
      False
      False
      False

      21
      False
      False
      False

      22
      False
      False
      True

      23
      False
      True
      True

Of course, with a larger DataFrame, it would be inefficient to review all of the rows and columns individually; so we can get the sum of missing values for each column, like this:

```python
df_students.isnull().sum()
```

    Name          0
    StudyHours    1
    Grade         2
    dtype: int64

So now we know that there's one missing **StudyHours** value, and two missing **Grade** values.

To see them in context, we can filter the dataframe to include only rows where any of the columns (axis 1 of the DataFrame) are null.

```python
df_students[df_students.isnull().any(axis=1)]
```

      Name
      StudyHours
      Grade

      22
      Bill
      8.0
      NaN

      23
      Ted
      NaN
      NaN

When the DataFrame is retrieved, the missing numeric values show up as **NaN** (*not a number*).

So now that we've found the null values, what can we do about them?

One common approach is to *impute* replacement values. For example, if the number of study hours is missing, we could just assume that the student studied for an average amount of time and replace the missing value with the mean study hours. To do this, we can use the **fillna** method, like this:

```python
df_students.StudyHours = df_students.StudyHours.fillna(df_students.StudyHours.mean())
df_students
```

      Name
      StudyHours
      Grade

      0
      Dan
      10.000000
      50.0

      1
      Joann
      11.500000
      50.0

      2
      Pedro
      9.000000
      47.0

      3
      Rosie
      16.000000
      97.0

      4
      Ethan
      9.250000
      49.0

      5
      Vicky
      1.000000
      3.0

      6
      Frederic
      11.500000
      53.0

      7
      Jimmie
      9.000000
      42.0

      8
      Rhonda
      8.500000
      26.0

      9
      Giovanni
      14.500000
      74.0

      10
      Francesca
      15.500000
      82.0

      11
      Rajab
      13.750000
      62.0

      12
      Naiyana
      9.000000
      37.0

      13
      Kian
      8.000000
      15.0

      14
      Jenny
      15.500000
      70.0

      15
      Jakeem
      8.000000
      27.0

      16
      Helena
      9.000000
      36.0

      17
      Ismat
      6.000000
      35.0

      18
      Anila
      10.000000
      48.0

      19
      Skye
      12.000000
      52.0

      20
      Daniel
      12.500000
      63.0

      21
      Aisha
      12.000000
      64.0

      22
      Bill
      8.000000
      NaN

      23
      Ted
      10.413043
      NaN

Alternatively, it might be important to ensure that you only use data you know to be absolutely correct; so you can drop rows or columns that contains null values by using the **dropna** method. In this case, we'll remove rows (axis 0 of the DataFrame) where any of the columns contain null values.

```python
df_students = df_students.dropna(axis=0, how='any')
df_students
```

      Name
      StudyHours
      Grade

      0
      Dan
      10.00
      50.0

      1
      Joann
      11.50
      50.0

      2
      Pedro
      9.00
      47.0

      3
      Rosie
      16.00
      97.0

      4
      Ethan
      9.25
      49.0

      5
      Vicky
      1.00
      3.0

      6
      Frederic
      11.50
      53.0

      7
      Jimmie
      9.00
      42.0

      8
      Rhonda
      8.50
      26.0

      9
      Giovanni
      14.50
      74.0

      10
      Francesca
      15.50
      82.0

      11
      Rajab
      13.75
      62.0

      12
      Naiyana
      9.00
      37.0

      13
      Kian
      8.00
      15.0

      14
      Jenny
      15.50
      70.0

      15
      Jakeem
      8.00
      27.0

      16
      Helena
      9.00
      36.0

      17
      Ismat
      6.00
      35.0

      18
      Anila
      10.00
      48.0

      19
      Skye
      12.00
      52.0

      20
      Daniel
      12.50
      63.0

      21
      Aisha
      12.00
      64.0

### Explore data in the DataFrame

Now that we've cleaned up the missing values, we're ready to explore the data in the DataFrame. Let's start by comparing the mean study hours and grades.

```python
# Get the mean study hours using to column name as an index
mean_study = df_students['StudyHours'].mean()

# Get the mean grade using the column name as a property (just to make the point!)
mean_grade = df_students.Grade.mean()

# Print the mean study hours and mean grade
print('Average weekly study hours: {:.2f}\nAverage grade: {:.2f}'.format(mean_study, mean_grade))
```

    Average weekly study hours: 10.52
    Average grade: 49.18

OK, let's filter the DataFrame to find only the students who studied for more than the average amount of time.

```python
# Get students who studied for the mean or more hours
df_students[df_students.StudyHours > mean_study]
```

      Name
      StudyHours
      Grade

      1
      Joann
      11.50
      50.0

      3
      Rosie
      16.00
      97.0

      6
      Frederic
      11.50
      53.0

      9
      Giovanni
      14.50
      74.0

      10
      Francesca
      15.50
      82.0

      11
      Rajab
      13.75
      62.0

      14
      Jenny
      15.50
      70.0

      19
      Skye
      12.00
      52.0

      20
      Daniel
      12.50
      63.0

      21
      Aisha
      12.00
      64.0

Note that the filtered result is itself a DataFrame, so you can work with its columns just like any other DataFrame.

For example, let's find the average grade for students who undertook more than the average amount of study time.

```python
# What was their mean grade?
df_students[df_students.StudyHours > mean_study].Grade.mean()
```

    66.7

Let's assume that the passing grade for the course is 60.

We can use that information to add a new column to the DataFrame, indicating whether or not each student passed.

First, we'll create a Pandas **Series** containing the pass/fail indicator (True or False), and then we'll concatenate that series as a new column (axis 1) in the DataFrame.

```python
passes  = pd.Series(df_students['Grade'] >= 60)
df_students = pd.concat([df_students, passes.rename("Pass")], axis=1)

df_students
```

      Name
      StudyHours
      Grade
      Pass

      0
      Dan
      10.00
      50.0
      False

      1
      Joann
      11.50
      50.0
      False

      2
      Pedro
      9.00
      47.0
      False

      3
      Rosie
      16.00
      97.0
      True

      4
      Ethan
      9.25
      49.0
      False

      5
      Vicky
      1.00
      3.0
      False

      6
      Frederic
      11.50
      53.0
      False

      7
      Jimmie
      9.00
      42.0
      False

      8
      Rhonda
      8.50
      26.0
      False

      9
      Giovanni
      14.50
      74.0
      True

      10
      Francesca
      15.50
      82.0
      True

      11
      Rajab
      13.75
      62.0
      True

      12
      Naiyana
      9.00
      37.0
      False

      13
      Kian
      8.00
      15.0
      False

      14
      Jenny
      15.50
      70.0
      True

      15
      Jakeem
      8.00
      27.0
      False

      16
      Helena
      9.00
      36.0
      False

      17
      Ismat
      6.00
      35.0
      False

      18
      Anila
      10.00
      48.0
      False

      19
      Skye
      12.00
      52.0
      False

      20
      Daniel
      12.50
      63.0
      True

      21
      Aisha
      12.00
      64.0
      True

DataFrames are designed for tabular data, and you can use them to perform many of the kinds of data analytics operation you can do in a relational database; such as grouping and aggregating tables of data.

For example, you can use the **groupby** method to group the student data into groups based on the **Pass** column you added previously, and count the number of names in each group - in other words, you can determine how many students passed and failed.

```python
print(df_students.groupby(df_students.Pass).Name.count())
```

    Pass
    False    15
    True      7
    Name: Name, dtype: int64

You can aggregate multiple fields in a group using any available aggregation function. For example, you can find the mean study time and grade for the groups of students who passed and failed the course.

```python
print(df_students.groupby(df_students.Pass)['StudyHours', 'Grade'].mean())
```

           StudyHours      Grade
    Pass
    False    8.783333  38.000000
    True    14.250000  73.142857

    /usr/local/anaconda3/lib/python3.7/site-packages/ipykernel_launcher.py:1: FutureWarning: Indexing with multiple keys (implicitly converted to a tuple of keys) will be deprecated, use a list instead.
      """Entry point for launching an IPython kernel.

DataFrames are amazingly versatile, and make it easy to manipulate data. Many DataFrame operations return a new copy of the DataFrame; so if you want to modify a DataFrame but keep the existing variable, you need to assign the result of the operation to the existing variable. For example, the following code sorts the student data into descending order of Grade, and assigns the resulting sorted DataFrame to the original **df_students** variable.

```python
# Create a DataFrame with the data sorted by Grade (descending)
df_students = df_students.sort_values('Grade', ascending=False)

# Show the DataFrame
df_students
```

      Name
      StudyHours
      Grade
      Pass

      3
      Rosie
      16.00
      97.0
      True

      10
      Francesca
      15.50
      82.0
      True

      9
      Giovanni
      14.50
      74.0
      True

      14
      Jenny
      15.50
      70.0
      True

      21
      Aisha
      12.00
      64.0
      True

      20
      Daniel
      12.50
      63.0
      True

      11
      Rajab
      13.75
      62.0
      True

      6
      Frederic
      11.50
      53.0
      False

      19
      Skye
      12.00
      52.0
      False

      1
      Joann
      11.50
      50.0
      False

      0
      Dan
      10.00
      50.0
      False

      4
      Ethan
      9.25
      49.0
      False

      18
      Anila
      10.00
      48.0
      False

      2
      Pedro
      9.00
      47.0
      False

      7
      Jimmie
      9.00
      42.0
      False

      12
      Naiyana
      9.00
      37.0
      False

      16
      Helena
      9.00
      36.0
      False

      17
      Ismat
      6.00
      35.0
      False

      15
      Jakeem
      8.00
      27.0
      False

      8
      Rhonda
      8.50
      26.0
      False

      13
      Kian
      8.00
      15.0
      False

      5
      Vicky
      1.00
      3.0
      False

## Visualizing data with Matplotlib

DataFrames provide a great way to explore and analyze tabular data, but sometimes a picture is worth a thousand rows and columns. The **Matplotlib** library provides the foundation for plotting data visualizations that can greatly enhance your ability the analyze the data.

Let's start with a simple bar chart that shows the grade of each student.

```python
# Ensure plots are displayed inline in the notebook
%matplotlib inline

from matplotlib import pyplot as plt

# Create a bar plot of name vs grade
plt.bar(x=df_students.Name, height=df_students.Grade)

# Display the plot
plt.show()
```

Well, that worked; but the chart could use some improvements to make it clearer what we're looking at.

Note that you used the **pyplot** class from Matplotlib to plot the chart. This class provides a whole bunch of ways to improve the visual elements of the plot. For example, the following code:

- Specifies the color of the bar chart.
- Adds a title to the chart (so we know what it represents)
- Adds labels to the X and Y (so we know which axis shows which data)
- Adds a grid (to make it easier to determine the values for the bars)
- Rotates the X markers (so we can read them)

```python
# Create a bar plot of name vs grade
plt.bar(x=df_students.Name, height=df_students.Grade, color='orange')

# Customize the chart
plt.title('Student Grades')
plt.xlabel('Student')
plt.ylabel('Grade')
plt.grid(color='#95a5a6', linestyle='--', linewidth=2, axis='y', alpha=0.7)
plt.xticks(rotation=90)

# Display the plot
plt.show()
```

A plot is technically contained with a **Figure**. In the previous examples, the figure was created implicitly for you; but you can create it explicitly. For example, the following code creates a figure with a specific size.

```python
# Create a Figure
fig = plt.figure(figsize=(8,3))

# Create a bar plot of name vs grade
plt.bar(x=df_students.Name, height=df_students.Grade, color='orange')

# Customize the chart
plt.title('Student Grades')
plt.xlabel('Student')
plt.ylabel('Grade')
plt.grid(color='#95a5a6', linestyle='--', linewidth=2, axis='y', alpha=0.7)
plt.xticks(rotation=90)

# Show the figure
plt.show()
```

A figure can contain multiple subplots, each on its own *axis*.

For example, the following code creates a figure with two subplots - one is a bar chart showing student grades, and the other is a pie chart comparing the number of passing grades to non-passing grades.

```python
# Create a figure for 2 subplots (1 row, 2 columns)
fig, ax = plt.subplots(1, 2, figsize = (10,4))

# Create a bar plot of name vs grade on the first axis
ax[0].bar(x=df_students.Name, height=df_students.Grade, color='orange')
ax[0].set_title('Grades')
ax[0].set_xticklabels(df_students.Name, rotation=90)

# Create a pie chart of pass counts on the second axis
pass_counts = df_students['Pass'].value_counts()
ax[1].pie(pass_counts, labels=pass_counts)
ax[1].set_title('Passing Grades')
ax[1].legend(pass_counts.keys().tolist())

# Add a title to the Figure
fig.suptitle('Student Data')

# Show the figure
fig.show()
```

Until now, you've used methods of the Matplotlib.pyplot object to plot charts. However, Matplotlib is so foundational to graphics in Python that many packages, including Pandas, provide methods that abstract the underlying Matplotlib functions and simplify plotting. For example, the DataFrame provides its own methods for plotting data, as shown in the following example to plot a bar chart of study hours.

```python
df_students.plot.bar(x='Name', y='StudyHours', color='teal', figsize=(6,4))
```

## Getting started with statistical analysis

Now that you know how to use Python to manipulate and visualize data, you can start analyzing it.

A lot of data science is rooted in *statistics*, so we'll explore some basic statistical techniques.

> **Note**: This is not intended to teach you statistics - that's much too big a topic for this notebook. It will however introduce you to some statistical concepts and techniques that data scientists use as they explore data in preparation for machine learning modeling.

### Descriptive statistics and data distribution

When examining a *variable* (for example a sample of student grades), data scientists are particularly interested in its *distribution* (in other words, how are all the different grade values spread across the sample). The starting point for this exploration is often to visualize the data as a histogram, and see how frequently each value for the variable occurs.

```python
# Get the variable to examine
var_data = df_students['Grade']

# Create a Figure
fig = plt.figure(figsize=(10,4))

# Plot a histogram
plt.hist(var_data)

# Add titles and labels
plt.title('Data Distribution')
plt.xlabel('Value')
plt.ylabel('Frequency')

# Show the figure
fig.show()
```

The histogram for grades is a symmetric shape, where the most frequently occurring grades tend to be in the middle of the range (around 50), with fewer grades at the extreme ends of the scale.

#### Measures of central tendency

To understand the distribution better, we can examine so-called *measures of central tendency*; which is a fancy way of describing statistics that represent the "middle" of the data. The goal of this is to try to find a "typical" value. Common ways to define the middle of the data include:

- The *mean*: A simple average based on adding together all of the values in the sample set, and then dividing the total by the number of samples.
- The *median*: The value in the middle of the range of all of the sample values.
- The *mode*: The most commonly occuring value in the sample set\*.

Let's calculate these values, along with the minimum and maximum values for comparison, and show them on the histogram.

> \*Of course, in some sample sets , there may be a tie for the most common value - in which case the dataset is described as *bimodal* or even *multimodal*.

```python
# Get the variable to examine
var = df_students['Grade']

# Get statistics
min_val = var.min()
max_val = var.max()
mean_val = var.mean()
med_val = var.median()
mod_val = var.mode()[0]

print('Minimum:{:.2f}\nMean:{:.2f}\nMedian:{:.2f}\nMode:{:.2f}\nMaximum:{:.2f}\n'.format(min_val,
                                                                                mean_val,
                                                                                med_val,
                                                                                mod_val,
                                                                                max_val))

# Create a Figure
fig = plt.figure(figsize=(10,4))

# Plot a histogram
plt.hist(var)

# Add lines for the statistics
plt.axvline(x=min_val, color = 'gray', linestyle='dashed', linewidth = 2)
plt.axvline(x=mean_val, color = 'cyan', linestyle='dashed', linewidth = 2)
plt.axvline(x=med_val, color = 'red', linestyle='dashed', linewidth = 2)
plt.axvline(x=mod_val, color = 'yellow', linestyle='dashed', linewidth = 2)
plt.axvline(x=max_val, color = 'gray', linestyle='dashed', linewidth = 2)

# Add titles and labels
plt.title('Data Distribution')
plt.xlabel('Value')
plt.ylabel('Frequency')

# Show the figure
fig.show()
```

For the grade data, the mean, median, and mode all seem to be more or less in the middle of the minimum and maximum, at around 50.

Another way to visualize the distribution of a variable is to use a *box* plot (sometimes called a *box-and-whiskers* plot). Let's create one for the grade data.

```python
# Get the variable to examine
var = df_students['Grade']

# Create a Figure
fig = plt.figure(figsize=(10,4))

# Plot a histogram
plt.boxplot(var)

# Add titles and labels
plt.title('Data Distribution')

# Show the figure
fig.show()
```

The box plot shows the distribution of the grade values in a different format to the histogram. The *box* part of the plot shows where the inner two *quartiles* of the data reside - so in this case, half of the grades are between approximately 36 and 63. The *whiskers* extending from the box show the outer two quartiles; so the other half of the grades in this case are between 0 and 36 or 63 and 100. The line in the box indicates the *median* value.

It's often useful to combine histograms and box plots, with the box plot's orientation changed to align it with the histogram (in some ways, it can be helpful to think of the histogram as a "front elevation" view of the distribution, and the box plot as a "plan" view of the distribution from above.)

```python
# Create a function that we can re-use
def show_distribution(var_data):
    from matplotlib import pyplot as plt

    # Get statistics
    min_val = var_data.min()
    max_val = var_data.max()
    mean_val = var_data.mean()
    med_val = var_data.median()
    mod_val = var_data.mode()[0]

    print('Minimum:{:.2f}\nMean:{:.2f}\nMedian:{:.2f}\nMode:{:.2f}\nMaximum:{:.2f}\n'.format(min_val,
                                                                                mean_val,
                                                                                med_val,
                                                                                mod_val,
                                                                                max_val))

    # Create a figure for 2 subplots (2 rows, 1 column)
    fig, ax = plt.subplots(2, 1, figsize = (10,4))

    # Plot the histogram
    ax[0].hist(var_data)
    ax[0].set_ylabel('Frequency')

    # Add lines for the mean, median, and mode
    ax[0].axvline(x=min_val, color = 'gray', linestyle='dashed', linewidth = 2)
    ax[0].axvline(x=mean_val, color = 'cyan', linestyle='dashed', linewidth = 2)
    ax[0].axvline(x=med_val, color = 'red', linestyle='dashed', linewidth = 2)
    ax[0].axvline(x=mod_val, color = 'yellow', linestyle='dashed', linewidth = 2)
    ax[0].axvline(x=max_val, color = 'gray', linestyle='dashed', linewidth = 2)

    # Plot the boxplot
    ax[1].boxplot(var_data, vert=False)
    ax[1].set_xlabel('Value')

    # Add a title to the Figure
    fig.suptitle('Data Distribution')

    # Show the figure
    fig.show()

# Get the variable to examine
col = df_students['Grade']
# Call the function
show_distribution(col)
```

All of the measurements of central tendency are right in the middle of the data distribution, which is symmetric with values becoming progressively lower in both directions from the middle.

To explore this distribution in more detail, you need to understand that statistics is fundamentally about taking *samples* of data and using probability functions to extrapolate information about the full *population* of data. For example, the student data consists of 22 samples, and for each sample there is a grade value. You can think of each sample grade as a variable that's been randomly selected from the set of all grades awarded for this course. With enough of these random variables, you can calculate something called a *probability density function*, which estimates the distribution of grades for the full population.

The Pandas DataFrame class provides a helpful plot function to show this density.

```python
def show_density(var_data):
    from matplotlib import pyplot as plt

    fig = plt.figure(figsize=(10,4))

    # Plot density
    var_data.plot.density()

    # Add titles and labels
    plt.title('Data Density')

    # Show the mean, median, and mode
    plt.axvline(x=var_data.mean(), color = 'cyan', linestyle='dashed', linewidth = 2)
    plt.axvline(x=var_data.median(), color = 'red', linestyle='dashed', linewidth = 2)
    plt.axvline(x=var_data.mode()[0], color = 'yellow', linestyle='dashed', linewidth = 2)

    # Show the figure
    plt.show()

# Get the density of Grade
col = df_students['Grade']
show_density(col)
```

As expected from the histogram of the sample, the density shows the characteristic 'bell curve" of what statisticians call a *normal* distribution with the mean and mode at the center and symmetric tails.

Now let's take a look at the distribution of the study hours data.

```python
# Get the variable to examine
col = df_students['StudyHours']
# Call the function
show_distribution(col)
```

The distribution of the study time data is significantly different from that of the grades.

Note that the whiskers of the box plot only extend to around 6.0, indicating that the vast majority of the first quarter of the data is above this value. The minimum is marked with an **o**, indicating that it is statistically an *outlier* - a value that lies significantly outside the range of the rest of the distribution.

Outliers can occur for many reasons. Maybe a student meant to record "10" hours of study time, but entered "1" and missed the "0". Or maybe the student was abnormally lazy when it comes to studying! Either way, it's a statistical anomaly that doesn't represent a typical student. Let's see what the distribution looks like without it.

```python
# Get the variable to examine
col = df_students[df_students.StudyHours>1]['StudyHours']
# Call the function
show_distribution(col)
```

In this example, the dataset is small enough to clearly see that the value **1** is an outlier for the **StudyHours** column, so you can exclude it explicitly. In most real-world cases, it's easier to consider outliers as being values that fall below or above percentiles within which most of the data lie. For example, the following code uses the Pandas **quantile** function to exclude observations below the 0.01th percentile (the value above which 99% of the data reside).

```python
q01 = df_students.StudyHours.quantile(0.01)
# Get the variable to examine
col = df_students[df_students.StudyHours>q01]['StudyHours']
# Call the function
show_distribution(col)
```

> **Tip**: You can also eliminate outliers at the upper end of the distribution by defining a threshold at a high percentile value - for example, you could use the **quantile** function to find the 0.99 percentile below which 99% of the data reside.

With the outliers removed, the box plot shows all data within the four quartiles. Note that the distribution is not symmetric like it is for the grade data though - there are some students with very high study times of around 16 hours, but the bulk of the data is between 7 and 13 hours; The few extremely high values pull the mean towards the higher end of the scale.

Let's look at the density for this distribution.

```python
# Get the density of StudyHours
show_density(col)
```

This kind of distribution is called *right skewed*. The mass of the data is on the left side of the distribution, creating a long tail to the right because of the values at the extreme high end; which pull the mean to the right.

#### Measures of variance

So now we have a good idea where the middle of the grade and study hours data distributions are. However, there's another aspect of the distributions we should examine: how much variability is there in the data?

Typical statistics that measure variability in the data include:

- **Range**: The difference between the maximum and minimum. There's no built-in function for this, but it's easy to calculate using the **min** and **max** functions.
- **Variance**: The average of the squared difference from the mean. You can use the built-in **var** function to find this.
- **Standard Deviation**: The square root of the variance. You can use the built-in **std** function to find this.

```python
for col_name in ['Grade','StudyHours']:
    col = df_students[col_name]
    rng = col.max() - col.min()
    var = col.var()
    std = col.std()
    print('\n{}:\n - Range: {:.2f}\n - Variance: {:.2f}\n - Std.Dev: {:.2f}'.format(col_name, rng, var, std))
```

    Grade:
     - Range: 94.00
     - Variance: 472.54
     - Std.Dev: 21.74

    StudyHours:
     - Range: 15.00
     - Variance: 12.16
     - Std.Dev: 3.49

Of these statistics, the standard deviation is generally the most useful. It provides a measure of variance in the data on the same scale as the data itself (so grade points for the Grade distribution and hours for the StudyHours distribution). The higher the standard deviation, the more variance there is when comparing values in the distribution to the distribution mean - in other words, the data is more spread out.

When working with a *normal* distribution, the standard deviation works with the particular characteristics of a normal distribution to provide even greater insight. Run the cell below to see the relationship between standard deviations and the data in the normal distribution.

```python
import scipy.stats as stats

# Get the Grade column
col = df_students['Grade']

# get the density
density = stats.gaussian_kde(col)

# Plot the density
col.plot.density()

# Get the mean and standard deviation
s = col.std()
m = col.mean()

# Annotate 1 stdev
x1 = [m-s, m+s]
y1 = density(x1)
plt.plot(x1,y1, color='magenta')
plt.annotate('1 std (68.26%)', (x1[1],y1[1]))

# Annotate 2 stdevs
x2 = [m-(s*2), m+(s*2)]
y2 = density(x2)
plt.plot(x2,y2, color='green')
plt.annotate('2 std (95.45%)', (x2[1],y2[1]))

# Annotate 3 stdevs
x3 = [m-(s*3), m+(s*3)]
y3 = density(x3)
plt.plot(x3,y3, color='orange')
plt.annotate('3 std (99.73%)', (x3[1],y3[1]))

# Show the location of the mean
plt.axvline(col.mean(), color='cyan', linestyle='dashed', linewidth=1)

plt.axis('off')

plt.show()
```

The horizontal lines show the percentage of data within 1, 2, and 3 standard deviations of the mean (plus or minus).

In any normal distribution:
- Approximately 68.26% of values fall within one standard deviation from the mean.
- Approximately 95.45% of values fall within two standard deviations from the mean.
- Approximately 99.73% of values fall within three standard deviations from the mean.

So, since we know that the mean grade is 49.18, the standard deviation is 21.74, and distribution of grades is approximately normal; we can calculate that 68.26% of students should achieve a grade between 27.44 and 70.92.

The descriptive statistics we've used to understand the distribution of the student data variables are the basis of statistical analysis; and because they're such an important part of exploring your data, there's a built-in **Describe** method of the DataFrame object that returns the main descriptive statistics for all numeric columns.

```python
df_students.describe()
```

      StudyHours
      Grade

      count
      22.000000
      22.000000

      mean
      10.522727
      49.181818

      std
      3.487144
      21.737912

      min
      1.000000
      3.000000

      25%
      9.000000
      36.250000

      50%
      10.000000
      49.500000

      75%
      12.375000
      62.750000

      max
      16.000000
      97.000000

## Comparing data

Now that you know something about the statistical distribution of the data in your dataset, you're ready to examine your data to identify any apparent relationships between variables.

First of all, let's get rid of any rows that contain outliers so that we have a sample that is representative of a typical class of students. We identified that the StudyHours column contains some outliers with extremely low values, so we'll remove those rows.

```python
df_sample = df_students[df_students['StudyHours']>1]
df_sample
```

      Name
      StudyHours
      Grade
      Pass

      3
      Rosie
      16.00
      97.0
      True

      10
      Francesca
      15.50
      82.0
      True

      9
      Giovanni
      14.50
      74.0
      True

      14
      Jenny
      15.50
      70.0
      True

      21
      Aisha
      12.00
      64.0
      True

      20
      Daniel
      12.50
      63.0
      True

      11
      Rajab
      13.75
      62.0
      True

      6
      Frederic
      11.50
      53.0
      False

      19
      Skye
      12.00
      52.0
      False

      1
      Joann
      11.50
      50.0
      False

      0
      Dan
      10.00
      50.0
      False

      4
      Ethan
      9.25
      49.0
      False

      18
      Anila
      10.00
      48.0
      False

      2
      Pedro
      9.00
      47.0
      False

      7
      Jimmie
      9.00
      42.0
      False

      12
      Naiyana
      9.00
      37.0
      False

      16
      Helena
      9.00
      36.0
      False

      17
      Ismat
      6.00
      35.0
      False

      15
      Jakeem
      8.00
      27.0
      False

      8
      Rhonda
      8.50
      26.0
      False

      13
      Kian
      8.00
      15.0
      False

### Comparing numeric and categorical variables

The data includes two *numeric* variables (**StudyHours** and **Grade**) and two *categorical* variables (**Name** and **Pass**). Let's start by comparing the numeric **StudyHours** column to the categorical **Pass** column to see if there's an apparent relationship between the number of hours studied and a passing grade.

To make this comparison, let's create box plots showing the distribution of StudyHours for each possible Pass value (true and false).

```python
df_sample.boxplot(column='StudyHours', by='Pass', figsize=(8,5))
```

Comparing the StudyHours distributions, it's immediately apparent (if not particularly surprising) that students who passed the course tended to study for more hours than students who didn't. So if you wanted to predict whether or not a student is likely to pass the course, the amount of time they spend studying may be a good predictive feature.

### Comparing numeric variables

Now let's compare two numeric variables. We'll start by creating a bar chart that shows both grade and study hours.

```python
# Create a bar plot of name vs grade and study hours
df_sample.plot(x='Name', y=['Grade','StudyHours'], kind='bar', figsize=(8,5))
```

The chart shows bars for both grade and study hours for each student; but it's not easy to compare because the values are on different scales. Grades are measured in grade points, and range from 3 to 97; while study time is measured in hours and ranges from 1 to 16.

A common technique when dealing with numeric data in different scales is to *normalize* the data so that the values retain their proportional distribution, but are measured on the same scale. To accomplish this, we'll use a technique called *MinMax* scaling that distributes the values proportionally on a scale of 0 to 1. You could write the code to apply this transformation; but the **Scikit-Learn** library provides a scaler to do it for you.

```python
from sklearn.preprocessing import MinMaxScaler

# Get a scaler object
scaler = MinMaxScaler()

# Create a new dataframe for the scaled values
df_normalized = df_sample[['Name', 'Grade', 'StudyHours']].copy()

# Normalize the numeric columns
df_normalized[['Grade','StudyHours']] = scaler.fit_transform(df_normalized[['Grade','StudyHours']])

# Plot the normalized values
df_normalized.plot(x='Name', y=['Grade','StudyHours'], kind='bar', figsize=(8,5))
```

With the data normalized, it's easier to see an apparent relationship between grade and study time. It's not an exact match, but it definitely seems like students with higher grades tend to have studied more.

So there seems to be a correlation between study time and grade; and in fact, there's a statistical *correlation* measurement we can use to quantify the relationship between these columns.

```python
df_normalized.Grade.corr(df_normalized.StudyHours)
```

    0.9117666413789675

The correlation statistic is a value between -1 and 1 that indicates the strength of a relationship. Values above 0 indicate a *positive* correlation (high values of one variable tend to coincide with high values of the other), while values below 0 indicate a *negative* correlation (high values of one variable tend to coincide with low values of the other). In this case, the correlation value is close to 1; showing a strongly positive correlation between study time and grade.

> **Note**: Data scientists often quote the maxim "*correlation* is not *causation*". In other words, as tempting as it might be, you shouldn't interpret the statistical correlation as explaining *why* one of the values is high. In the case of the student data, the statistics demonstrates that students with high grades tend to also have high amounts of study time; but this is not the same as proving that they achieved high grades *because* they studied a lot. The statistic could equally be used as evidence to support the nonsensical conclusion that the students studied a lot *because* their grades were going to be high.

Another way to visualise the apparent correlation between two numeric columns is to use a *scatter* plot.

```python
# Create a scatter plot
df_sample.plot.scatter(title='Study Time vs Grade', x='StudyHours', y='Grade')
```

Again, it looks like there's a discernible pattern in which the students who studied the most hours are also the students who got the highest grades.

We can see this more clearly by adding a *regression* line (or a *line of best fit*) to the plot that shows the general trend in the data. To do this, we'll use a statistical technique called *least squares regression*.

> **Warning - Math Ahead!**
>
> Cast your mind back to when you were learning how to solve linear equations in school, and recall that the *slope-intercept* form of a linear equation looks like this:
>
> \begin{equation}y = mx + b\end{equation}
>
> In this equation, *y* and *x* are the coordinate variables, *m* is the slope of the line, and *b* is the y-intercept (where the line goes through the Y-axis).
>
> In the case of our scatter plot for our student data, we already have our values for *x* (*StudyHours*) and *y* (*Grade*), so we just need to calculate the intercept and slope of the straight line that lies closest to those points. Then we can form a linear equation that calculates a new *y* value on that line for each of our *x* (*StudyHours*) values - to avoid confusion, we'll call this new *y* value *f(x)* (because it's the output from a linear equation ***f***unction based on *x*). The difference between the original *y* (*Grade*) value and the *f(x)* value is the *error* between our regression line and the actual *Grade* achieved by the student. Our goal is to calculate the slope and intercept for a line with the lowest overall error.
>
> Specifically, we define the overall error by taking the error for each point, squaring it, and adding all the squared errors together. The line of best fit is the line that gives us the lowest value for the sum of the squared errors - hence the name *least squares regression*.

Fortunately, you don't need to code the regression calculation yourself - the **SciPy** package includes a **stats** class that provides a **linregress** method to do the hard work for you. This returns (among other things) the coefficients you need for the slope equation - slope (*m*) and intercept (*b*) based on a given pair of variable samples you want to compare.

```python
from scipy import stats

#
df_regression = df_sample[['Grade', 'StudyHours']].copy()

# Get the regression slope and intercept
m, b, r, p, se = stats.linregress(df_regression['StudyHours'], df_regression['Grade'])
print('slope: {:.4f}\ny-intercept: {:.4f}'.format(m,b))
print('so...\n f(x) = {:.4f}x + {:.4f}'.format(m,b))

# Use the function (mx + b) to calculate f(x) for each x (StudyHours) value
df_regression['fx'] = (m * df_regression['StudyHours']) + b

# Calculate the error between f(x) and the actual y (Grade) value
df_regression['error'] = df_regression['fx'] - df_regression['Grade']

# Create a scatter plot of Grade vs StudyHours
df_regression.plot.scatter(x='StudyHours', y='Grade')

# Plot the regression line
plt.plot(df_regression['StudyHours'],df_regression['fx'], color='cyan')

# Display the plot
plt.show()
```

Note that this time, the code plotted two distinct things - the scatter plot of the sample study hours and grades is plotted as before, and then a line of best fit based on the least squares regression coefficients is plotted.

The slope and intercept coefficients calculated for the regression line are shown above the plot.

The line is based on the ***f*(x)** values calculated for each **StudyHours** value. Run the following cell to see a table that includes the following values:

- The **StudyHours** for each student.
- The **Grade** achieved by each student.
- The ***f(x)*** value calculated using the regression line coefficients.
- The *error* between the calculated ***f(x)*** value and the actual **Grade** value.

Some of the errors, particularly at the extreme ends, are quite large (up to over 17.5 grade points); but in general, the line is pretty close to the actual grades.

```python
# Show the original x,y values, the f(x) value, and the error
df_regression[['StudyHours', 'Grade', 'fx', 'error']]
```

      StudyHours
      Grade
      fx
      error

      3
      16.00
      97.0
      83.098400
      -13.901600

      10
      15.50
      82.0
      79.941687
      -2.058313

      9
      14.50
      74.0
      73.628262
      -0.371738

      14
      15.50
      70.0
      79.941687
      9.941687

      21
      12.00
      64.0
      57.844698
      -6.155302

      20
      12.50
      63.0
      61.001410
      -1.998590

      11
      13.75
      62.0
      68.893193
      6.893193

      6
      11.50
      53.0
      54.687985
      1.687985

      19
      12.00
      52.0
      57.844698
      5.844698

      1
      11.50
      50.0
      54.687985
      4.687985

      0
      10.00
      50.0
      45.217846
      -4.782154

      4
      9.25
      49.0
      40.482777
      -8.517223

      18
      10.00
      48.0
      45.217846
      -2.782154

      2
      9.00
      47.0
      38.904421
      -8.095579

      7
      9.00
      42.0
      38.904421
      -3.095579

      12
      9.00
      37.0
      38.904421
      1.904421

      16
      9.00
      36.0
      38.904421
      2.904421

      17
      6.00
      35.0
      19.964144
      -15.035856

      15
      8.00
      27.0
      32.590995
      5.590995

      8
      8.50
      26.0
      35.747708
      9.747708

      13
      8.00
      15.0
      32.590995
      17.590995

### Using the regression coefficients for prediction

Now that you have the regression coefficients for the study time and grade relationship, you can use them in a function to estimate the expected grade for a given amount of study.

```python
# Define a function based on our regression coefficients
def f(x):
    m = 6.3134
    b = -17.9164
    return m*x + b

study_time = 14

# Get f(x) for study time
prediction = f(study_time)

# Grade can't be less than 0 or more than 100
expected_grade = max(0,min(100,prediction))

#Print the estimated grade
print ('Studying for {} hours per week may result in a grade of {:.0f}'.format(study_time, expected_grade))
```

    Studying for 14 hours per week may result in a grade of 70

So by applying statistics to sample data, you've determined a relationship between study time and grade; and encapsulated that relationship in a general function that can be used to predict a grade for a given amount of study time.

This technique is in fact the basic premise of machine learning. You can take a set of sample data that includes one or more *features* (in this case, the number of hours studied) and a known *label* value (in this case, the grade achieved) and use the sample data to derive a function that calculates predicted label values for any given set of features.

## Further Reading

To learn more about the Python packages you explored in this notebook, see the following documentation:

- [NumPy](https://numpy.org/doc/stable/)
- [Pandas](https://pandas.pydata.org/pandas-docs/stable/)
- [Matplotlib](https://matplotlib.org/contents.html)

## Challenge: Analyze Flight Data

If this notebook has inspired you to try exploring data for yourself, why not take on the challenge of a real-world dataset containing flight records from the US Department of Transportation? You'll find the challenge in the [/challenges/01 - Flights Challenge.ipynb](./challenges/01%20-%20Flights%20Challenge.ipynb) notebook!

> **Note**: The time to complete this optional challenge is not included in the estimated time for this exercise - you can spend as little or as much time on it as you like!

```python

```


# mobrob - A3

Implementieren Sie die Monte-Carlo-Lokalisation aus dem Skript (Seite 21ff.).
Führen Sie hierzu einen künstlichen Fehler ein.
Verwenden Sie ausserdem den differentiellen Roboter aus dem Robosim-Beispiel.
Die Datei "data/multigausstest.tcl" enthält ein Code-Beispiel für einen Zufallsgenerator mit multivariater Gaussverteilung.

# Lösung

Hier wird der Algorithmus vorgestellt so wie er in der Vorlesung und im Programm implementiert wurde.
Es ist dabei zu beachten, dass die folgenden Schritte nur durchgeführt werden, wenn eine Sensormessung gemacht wird.
Dies ist der Fall wenn zum Beispiel nur alle 500 Schritte ein Update gemacht wird und dazwischen die Odometrie bemüht wird.
Für die restlichen 499 Schritte wird <b>nur</b> das Odometriemodell aus Schritt 2 angewandt auf die Partikel angewandt, ohne Rauschen und ohne Sampling.

## Schritt 0: Initialisierung

Es werden $K$ Partikel auf der Karte $\mathcal{W}$ gestreut, wobei jedes Partikel $p_k =(\mathbf{X}_k , w_k )$ aus einer Position $\mathbf{X}_k =(X_k ,Y_k ,\theta_k )^{\text{T}}$ und dem importance-factor $w_k$ besteht. Die drei verschiedenen Initialisierungen der Partikel können angedacht werden:

   * An Roboterposition $\mathbf{X}$: $\mathbf{X}_k = \mathbf{X}$ und $w_k = 1/N$
   * An verauschter Roboterposition ($ \mathbf{C}_{\text{P},0}$ ist die Anfangsunsicherheit): $\mathbf{X}_k = \mathbf{X}+ v_k$ mit $v_k \sim \mathcal{N}(\mathbf{X}, \mathbf{C}_{\text{P},0})$ und $w_k = 1/N$
   * Gleichverteilt im Raum: $\mathbf{X}_k \sim \mathcal{U}(\mathcal{W})$ und $w_k = 1/N$

## Schritt 1: Sampling

Für den Sampling-Schritt wird eine neue Generation an Partikeln $p'_k$ aus der Verteilung der alten Partikel gezogen, wobei Mehrfachziehungen erlaubt sind ($K$-mal ziehen mit Zurücklegen).

\begin{align}
    & \operatorname{f}(x) = \sum_{k=1}^{K}w_k\\
    & s_k = \operatorname{ceil}\left( \operatorname{f}^{-1}( y \sim \mathcal{U}(0,1))\right)\\
    & p'_{k} = p_{s_k}
\end{align}

## Schritt 2: Odometrie und Ruschen der Samples

Es muss eine neue Position $\mathbf{X}'_k \sim P(l_t | a_t , l_{t-1})$ bestimmt werden.

   * Letzte Position: $l_{t-1}$
   * Aktuelle Position: $l_t$
   * Aktionsmodell (Odometrie): $a_t$

Hierfür wird jeder Partikel $p'_k$ entsprechend der Steuersignale $(v_l , v_r)$ fortbewegt:

\begin{align}
\mathbf{X}'_{k,\text{Odo.}} =
  \begin{pmatrix}
    X'_k\\
    Y'_k\\
    \theta'_k
  \end{pmatrix}
  +
  \begin{pmatrix}
    \frac{s_{L} + s_{R} }{2} \cos \left( \theta'_k + \frac{s_{R} -s_{L}}{2b}\right)\\
    \frac{s_{L} + s_{R} }{2} \sin \left( \theta'_k + \frac{s_{R} -s_{L}}{2b}\right)\\
    \frac{s_{R} - s_{L} }{b}
  \end{pmatrix}
  \text{ mit } s_{L}=v_{L}\Delta t \text{ und } s_{R}=v_{R}\Delta t
\end{align}

### Verrauschen der Samples

Nach dem Odometrie-Schritt wird jeder Partikel an der neuen Position verrauscht.
Hierfür wird die Anfangsunsicherheit $\mathbf{C}_{\text{P},0}$ und die Fehlerfortpflanzung im aktuellen Schritt verwendet.

\begin{align}
  & \mathbf{X}'_{k,\text{Odo.+MC}} = \mathbf{X}'_k \sim \mathcal{N}( \mathbf{X}'_{k,\text{Odo.}} , \mathbf{F}_{\text{P}} \mathbf{C}_{\text{P},0} \mathbf{F}_{\text{P}}^{\text{T}} + \mathbf{F}_{\text{S}} \mathbf{C}_{\text{S}} \mathbf{F}_{\text{S}}^{\text{T}})
\end{align}

## Schritt 4: Importance

Für jedes Partikel $p'_k$ an der neuen Position $\mathbf{X}'_{k,\text{Odo.+MC}}$ wird nun der neue Importance-Factor $w'_k$ anhand der Sensormessung $z_k$, und der Messgleichung $P(z_k |\mathbf{X}'_{k,\text{Odo.+MC}})$ bestimmt.
Für diese Messung muss für jedes Partikel eine hypothetisierte Sensormessung durchgeführt werden (ein Laserscan an der Position $\mathbf{X}'_{k,\text{Odo.+MC}}$ des Partikels $p'_k$).
Für die Unsicherheit zwischen der Messung $z_k$ eines Partikels und der realen Messung $z$ des Laserscanners wird eine Gaussche Verteilung an der Stelle $z$ mit einer festen Unsicherheit $\sigma_z$ ausgewertet:

\begin{align}
  & \tilde{w}_{k}=\mathcal{N}(z,z_k,\sigma_z)
\end{align}

Da ein Laserscan an der aktuellen Position nicht nur aus einem Messwert $z$, sondern aus $R$ Messwerten $z_r$ besteht, schreibt sich der neue unnormalisierte Importance-Factor wie folgt:

\begin{align}
  & \tilde{w}_{k}=\prod_{r=1}^R \mathcal{N}(z_r,z_{k,r},\sigma_z)
\end{align}

Zuletzt wird der normalisierte Importance-Factor berechnet:

\begin{align}
  & w'_{k}=\tilde{w}_{k} / \sum_{k=1}^K \tilde{w}_{k}
\end{align}

### Numerische Instabilität

Durch die Berechnung von $\tilde{w}_k$ über alle Sensormessungen $R$ wird das Ergebnis numerisch instabil.
Aus diesem Grund werden die Berechnungen in der $\log$-Domaine durchgeführt:

\begin{align}
  & \widetilde{w}_{k}=\ln(\tilde{w}_{k}) = \sum_{r=1}^R - \ln\left(\sqrt{2\pi\sigma_z}\right) - \frac{(z_r - z_{k,r})^2}{\sigma_z^2}
\end{align}

Für die Normalisierung wird die $\operatorname{logadd}$-Funktion für zwei Zahlen $a$ und $b$ mit $b < a$ wie folgt definiert:

\begin{align}
  & \operatorname{logadd}(a,b)=\ln(a+b) = \ln(a) + \ln(1+\exp(\ln(b)-\ln(a))) \overset{\ln(b)-\ln(a)<-15 }{=} \ln(a)
\end{align}

Mit $\operatorname{logadd}(a,b,c)=\operatorname{logadd}(\operatorname{logadd}(a,b),c)$ lässt sich somit der unnormalisierte Importance-Factor leicht berechnen:

\begin{align}
  & w'_k = \exp\left(\widetilde{w}_{k} - \operatorname{logadd}(\widetilde{w}_{k,1}, \widetilde{w}_{k,2}, \ldots, \widetilde{w}_{k,R})   \right)
\end{align}

## Schritt 5: Iteration

Setze $p_k = \left( \mathbf{X}'_{k,\text{Odo.+MC}}, w'_k\right)$ und starte wieder im Schritt 3, nachdem der Roboter wieder einen Odometrieschritt gemacht hat.


# ADVI scratchpad

Let's start with a simple model:

$$
\begin{align}
n &\sim \mathrm{Normal}(\mu, \sigma^2) \\
\mu &\sim \mathrm{Gamma}(2, 1) \\
\sigma &\sim \mathrm{Gamma}(1, 2)
\end{align}
$$

## Generate fake data

```julia
using Distributions
using ForwardDiff
using VinDsl
srand(12587);
```

    WARNING: Method definition logpdf(Distributions.Gamma, Real) in module Distributions at /home/pearson/.julia/v0.4/Distributions/src/univariates.jl:321 overwritten in module VinDsl at /home/pearson/.julia/v0.4/VinDsl/src/distributions/expfam.jl:54.
    WARNING: Method definition logpdf(Distributions.Poisson, Int64) in module Distributions at /home/pearson/.julia/v0.4/Distributions/src/univariates.jl:321 overwritten in module VinDsl at /home/pearson/.julia/v0.4/VinDsl/src/distributions/expfam.jl:76.

```julia
N = 5000
a, b, c, d = 2, 1, 1, 2
λ = Gamma(a, b)
ϕ = Gamma(d, c)
μ = rand(λ)
σ = rand(ϕ)
n = rand(Normal(μ, σ), N);
```

```julia
using PyPlot
plt[:hist](n);
```

## Build the ELBO

```julia
constrain_positive(x) = exp(x)
unconstrain_positive(x) = log(x)
post_pars(d::Distribution) = (p = length(d); Int(p * (p + 3)/2))
```

    post_pars (generic function with 1 method)

```julia
vars = [λ, ϕ]
nvars = length(vars)
npars = sum(map(post_pars, vars))
```

    4

```julia
x = rand(npars)
```

    4-element Array{Float64,1}:
     0.95917
     0.595884
     0.173404
     0.262329

```julia
function ELBO(x)
    # make posterior distributions
    d_λ = Normal(x[1], exp(x[2]))
    d_ϕ = Normal(x[3], exp(x[4]))

    # scaled unconstrained parameters
    ζ_λ = rand(d_λ)
    ζ_ϕ = rand(d_ϕ)

    # constrained parameters
    λ = constrain_positive(ζ_λ)
    ϕ = constrain_positive(ζ_ϕ)

    # jacobian
    jac = ForwardDiff.jacobian(constrain_positive)

    # make ELBO
    L = sum(map(H, [d_λ, d_ϕ]))
    L += logpdf(Gamma(a, b), λ)
    L += logpdf(Gamma(d, c), ϕ)
    L += sum(logpdf(Normal(λ, ϕ), n))
    L += logdet(jac([ζ_λ]))
    L += logdet(jac([ζ_ϕ]))
end
```

    ELBO (generic function with 1 method)

Pseudocode of ELBO:

L = 0

---- first pass: sample pts from posterior ---
for s in sampling statements:
    switch dim(s):
        scalar: d_s = Normal(x[i], exp(x[i + 1]))
                update i counter
        vector: d_s = MvNormal(x[length(s)], Sigma)
                # need way to make MvNormal from Chol L:
                # U = Base.LinAlg.Cholesky(UpperTriangular(x[flat]), :U)
                # Sigma = PDMat(U)
                # or can overload MvNormal(mu::Vector{T}, L::LowerTriangular{S})

    xi = rand(d_s)

    sampled_var_name = constrain(xi, sampled_dist)
        # (e.g., y ~ Gamma ==> y = constrain(xi, Gamma)
        # constrain(xi, Gamma) = constrain_positive(xi)
        # in reality, if v ~ p_1 and y ~ p_2(v, w)
        # need to ensure v is in the intersection of the support of p_1 and the
        # valid parameter range of p_2
        # or just pick one and throw if it doesn't make sense
        # for now, just use RHS distribution; eventually, figure out support

    ---- comput H and logdet(J)
    L += H(d_s)
    L += logdet(jacobian(constrain mapping(xi))

    ---- other code inside macro that defines nonrandom variables ----

---- second pass: estimate E[log p] ----
for s in sampling statements:
    # sampling statement LHS ~ RHS
    L += sum(logpdf(RHS, LHS))

return L

```julia
ELBO(x)
```

    -2.348429282943737e7

```julia
x0 = randn(npars)
```

    4-element Array{Float64,1}:
      1.10703
      0.984969
     -1.23284
     -2.0394

```julia
∇L = ForwardDiff.gradient(ELBO)
```

    g (generic function with 1 method)

```julia
ELBO(x)
```

    -23462.09802678982

```julia
eps = 0.1
decay = 0.9
x = x0
avg_sq_grad = nothing
elbo = Float64[]
```

    0-element Array{Float64,1}

```julia
for idx in 1:200
    gg = ∇L(x)
    if avg_sq_grad != nothing
        avg_sq_grad = avg_sq_grad * decay + gg.^2 * (1 - decay)
    else
        avg_sq_grad = gg.^2
    end
    x += eps * gg ./ (sqrt(avg_sq_grad) + 1e-8)
    L = ELBO(x)
    display(L)
    push!(elbo, L)
end
```

    -259467.14851884887

    -104712.94635494947

    -58632.48848806808

    -159340.65142167587

    -420202.95073496655

    -31348.73669751738

    -4.304555593385892e6

    -28491.087877935403

    -39175.57786083637

    -26096.25757050557

    -37020.81825680765

    -332876.1910224278

    -23606.395617488466

    -22848.06187115289

    -18518.963890748622

    -16974.00105141263

    -19781.800140425355

    -13000.087753695332

    -12107.753659825376

    -18784.99404432926

    -19414.02264483325

    -13925.4992851195

    -18737.864508068877

    -19775.03959703437

    -18849.351632978203

    -19134.687041518202

    -12513.856063299072

    -11984.320442045335

    -11570.403539979565

    -13092.41832623739

    -16610.616157791475

    -11123.232085549944

    -18012.240556460823

    -13252.956674209849

    -20444.245052400725

    -22341.53133167563

    -13319.840919264772

    -16201.324590325858

    -14257.674155706272

    -12273.649502478409

    -19944.23946710261

    -18465.045530201274

    -18570.730502720668

    -14167.195791895738

    -14365.59918942045

    -16187.705759739858

    -12667.58909800838

    -14843.245311422324

    -13551.482048605963

    -10557.216460332682

    -13548.431925763945

    -12840.762389416408

    -14817.71133840832

    -16366.324505012453

    -16031.789219215365

    -15516.824464014317

    -10270.424838048779

    -14448.72397126706

    -12242.671279859649

    -10338.89131437726

    -12978.165906238704

    -15491.466045994572

    -13463.316432325837

    -14963.684763113219

    -13542.96337338789

    -48376.03934415692

    -12332.214422308609

    -10638.274685682933

    -9803.53341780212

    -10056.548360402578

    -9715.129655267172

    -9648.535948301229

    -13818.337245615503

    -9677.259915671413

    -11440.192321683398

    -11375.772494810904

    -10427.80579994973

    -9908.894949842188

    -11852.696449707342

    -9685.520917295396

    -10759.530101184544

    -9783.680787616484

    -10327.801516656249

    -10507.145328818691

    -12040.167904104675

    -10262.487247519808

    -18199.416638427305

    -9824.990544369077

    -10300.108374554811

    -9909.172568595237

    -11491.75722943876

    -9801.024514162626

    -11232.428354680824

    -10625.233890931762

    -10700.717732733776

    -10256.127798261878

    -10177.690342480515

    -10537.885752227476

    -11102.98732081736

    -10260.769550797579

    -10027.732487651301

    -11652.05519940559

    -11520.486293355341

    -11222.528068128355

    -10121.815554343415

    -9831.366877959557

    -9807.428074428743

    -9915.538102030572

    -11055.00937927732

    -10187.13920693125

    -11828.190534301564

    -10770.462699255166

    -10531.812346334456

    -11355.253074972652

    -10247.545020150112

    -11597.5587382508

    -11677.45496257474

    -10764.618244626696

    -10348.620144715802

    -10816.124369503876

    -10470.797308249292

    -10843.025551586174

    -10908.697401603322

    -9750.859058770438

    -12938.766879920473

    -10602.52918418354

    -10202.370816024892

    -11392.281100439606

    -10423.27878044632

    -10012.848474515169

    -10579.072487982343

    -10295.856031863084

    -10109.433697155253

    -10177.609060687608

    -9837.671851653486

    -10358.362120827112

    -10411.694607637439

    -9787.785095717343

    -9789.240575700189

    -9748.246264014368

    -9801.036050582701

    -11217.87967433564

    -10673.946563709378

    -9736.064507732342

    -10106.633797698178

    -9998.343683160407

    -10451.718273910816

    -9814.730707583327

    -10528.558737123183

    -9722.920067731542

    -10180.296733921401

    -10071.553083960398

    -10503.954111018838

    -10177.17334946473

    -9884.890216791971

    -10711.45517467584

    -9833.325014449072

    -9647.056200073459

    -9925.803474421678

    -9647.13721232162

    -9751.089227367764

    -9672.459501385145

    -10405.514941758956

    -9662.300532282734

    -9871.08588170735

    -10101.380612941179

    -10042.639768503726

    -10059.61764642266

    -9931.702899266655

    -9923.90927523879

    -9745.06903699962

    -9720.788412862325

    -9638.286007720257

    -9637.501542837952

    -9650.54894897395

    -9760.475667767394

    -9724.987056514514

    -9667.08135791788

    -9638.214485953484

    -9722.714278283926

    -9646.115156826132

    -9648.168322880521

    -9801.413808199042

    -9671.588133164661

    -9765.691467592029

    -9742.630981305552

    -9701.182225604383

    -9651.842621030737

    -9766.06428161239

    -9674.414143634805

    -9645.52648416861

    -9648.217096505019

    -9637.216886250519

    -9646.339392922038

    -9637.207242140781

    -9660.878234175232

    -9665.843342879914

    -9645.243106775712

    -9656.164908157283

    -9647.296355439577

```julia
plot(elbo)
plt[:ylim](-2e4, -9000)
```

```julia
∇L(x)
```

    4-element Array{Float64,1}:
     1484.72
     -293.359
       65.4173
        1.17444

```julia
x
```

    4-element Array{Float64,1}:
      0.746792
     -2.26861
      0.54344
     -4.51826

```julia
exp(x[1]), exp(x[3])
```

    (2.110219537960809,1.721920267633047)

```julia
μ, σ
```

    (2.215222085416203,1.6399410610591019)

```julia

```


# MEK4420 - Obligatorisk oppgave1
#### Ana Costa Conrado

Click on a cell and click "SHIFT + ENTER" to execute it

```python
#Loading python packages:
import sympy
import numpy
import matplotlib.pyplot
%matplotlib inline
import math
from __future__ import division
```

Function defined to integrate with Gaussian quadrature (2 points).
N is the number of points at the circumference.
nn is the normal

```python
def func(xp, yp, xm, ym, barx, bary, nn, N):

    ds = numpy.sqrt((xp - xm)**2 + (yp-ym)**2)
    hh = numpy.zeros(N)
    dtheta = numpy.zeros((N,N))
    for i in range(N):
        helpv = 0

        for j in range(N):

            if j-i == 0:
                dtheta[i][j] = - math.pi
            else:
                a1 = xm[j] - barx[i]
                a2 = ym[j] - bary[i]
                b1 = xp[j] - barx[i]
                b2 = yp[j] - bary[i]

                cosan = (a1*b1+a2*b2)/numpy.sqrt((a1**2+a2**2)*(b1**2+b2**2))
                if cosan > 1:
                    cosan = 1
                if cosan < -1:
                    cosan = -1

                arg = numpy.arccos(cosan)
                dtheta[i][j] = -arg

            x1g = 0.5*(xp[j]-xm[j])/math.sqrt(3)+barx[j]
            y1g = 0.5*(yp[j]-ym[j])/math.sqrt(3)+bary[j]
            x2g = -0.5*(xp[j]-xm[j])/math.sqrt(3)+barx[j]
            y2g = -0.5*(yp[j]-ym[j])/math.sqrt(3)+bary[j]

            hh0 = numpy.log((x1g-barx[i])**2+(y1g-bary[i])**2)

            hh0 += numpy.log((x2g-barx[i])**2+(y2g-bary[i])**2)

            hh0 *= 0.5

            helpv += hh0*nn[j]*ds[j]/2.

        hh[i] = helpv

    pdcr = numpy.linalg.lstsq(dtheta, hh.conj().transpose())

    da = sum(pdcr[0].conj().transpose() * nn * ds)
    return pdcr, da
```

```python
R0 = 1
sympy.geometry.ellipse.Ellipse(center=sympy.geometry.Point(0,0), hradius=R0, vradius=R0, eccentricity=None)
```

```python
matplotlib.pyplot.rc('text', usetex=True)
matplotlib.pyplot.rc('font', family='serif')
fig = matplotlib.pyplot.figure(figsize=[40,10])
R0 = 1
a11 = []
rho = 1
m_ana11 = math.pi*rho*R0**2
vector=[ 100, 200, 400]
for l, N in enumerate(vector):
    thetap = numpy.linspace(2*math.pi/N, 2*math.pi, N)
    thetam = numpy.linspace(0, 2*math.pi*(N-1)/N, N)
    xp = R0*numpy.cos(thetap)
    yp = R0*numpy.sin(thetap)
    xm = R0*numpy.cos(thetam)
    ym = R0*numpy.sin(thetam)
    barx = 0.5*(xp + xm)
    bary = 0.5*(yp + ym)
    ds = numpy.sqrt((xp - xm)**2 + (yp-ym)**2)

    nn1 = -barx/numpy.sqrt(barx**2 + bary**2)

    pdcr1, a11j = func(xp, yp, xm, ym, barx, bary, nn1, N)

    a11.append(a11j/m_ana11)

    pan = - barx/(barx**2 + bary**2)
    ax= matplotlib.pyplot.subplot(1,len(vector),l+1)
    ax.axis([0, 2*numpy.pi, -1.1, 1.1])
    ax.plot(0.5*(thetap + thetam), pdcr1[0], '.', 0.5*(thetap + thetam), pan, linestyle='solid')#
    matplotlib.pyplot.ylabel(r'$\phi_1$', fontsize= 20, rotation='horizontal')
    matplotlib.pyplot.xlabel(r' $\theta  $ ', fontsize= 20)

    xlabel = 'N = ', str(N)
    ax.set_title(xlabel)
fig.savefig('Circle_potential.pdf')
matplotlib.pyplot.show()
```

```python
fig2 = matplotlib.pyplot.figure(figsize=[10,10])
matplotlib.pyplot.plot(1/numpy.array(vector), a11, '*')
matplotlib.pyplot.ylabel(r'$\frac{m_{11}}{\rho \pi R_0^2}$', fontsize= 20, rotation='horizontal')
matplotlib.pyplot.xlabel(r' $\frac{1}{N}  $ ', fontsize= 20)

fig2.savefig('Circle.pdf')

matplotlib.pyplot.show()
```

```python
print('a11 : ', a11)
```

    a11 :  [0.99091058490373, 0.9954559810564692, 0.99772807765119]

```python
a0= 2
b0= 1
sympy.geometry.ellipse.Ellipse(center=sympy.geometry.Point(0,0), hradius=a0, vradius=b0, eccentricity=None)
```

```python
#ellipse of major half axis a0 and minor half axis b0

a0 = 2
b0 = 1
rho = 1
m_ana11 = math.pi*rho*b0**2
m_ana22 = math.pi*rho*a0**2
m_ana66 = (1./8)*1*math.pi*(a0**2-b0**2)**2

matplotlib.pyplot.rc('text', usetex=True)
matplotlib.pyplot.rc('font', family='serif')

fig = matplotlib.pyplot.figure(figsize=[10,10])

a11 = []
a22 = []
a66 = []
vector=[100, 200, 400]
for l, N in enumerate(vector):

    thetap = numpy.linspace(2*math.pi/N, 2*math.pi, N)
    thetam = numpy.linspace(0, 2*math.pi*(N-1)/N, N)

    xp = a0*numpy.cos(thetap)
    yp = b0*numpy.sin(thetap)
    xm = a0*numpy.cos(thetam)
    ym = b0*numpy.sin(thetam)
    barx = 0.5*(xp + xm)
    bary = 0.5*(yp + ym)
    ds = numpy.sqrt((xp - xm)**2 + (yp-ym)**2)

    nn1 = -(barx)/numpy.sqrt(barx**2 + bary**2*a0**4/b0**4)

    nn2 = -(bary)/numpy.sqrt(bary**2 + barx**2*b0**4/a0**4)

    nn6 = barx*nn2 - bary*nn1

    pdcr1, a11j = func(xp, yp, xm, ym, barx, bary, nn1, N)

    pdcr2, a22j = func(xp, yp, xm, ym, barx, bary, nn2, N)
    pdcr6, a66j = func(xp, yp, xm, ym, barx, bary, nn6, N)

    a11.append(a11j/m_ana11)
    a22.append(a22j/m_ana22)
    a66.append(a66j/m_ana66)

matplotlib.pyplot.plot(1/numpy.array(vector),a11,'^',1/numpy.array(vector), a22,'v',1/numpy.array(vector),a66,'o')
matplotlib.pyplot.xlabel(r' $\frac{1}{N}  $ ', fontsize= 20)
matplotlib.pyplot.legend([r'$\frac{m_{11}}{\pi \rho b_0^2}$',r"$\frac{m_{22}}{\pi\rho a_0^2}$",
                          r"$\frac{m_{66}}{8\pi\rho(a_0^2-b_0^2)^2}$"])
matplotlib.pyplot.title('Ellipse $b_{0}/a_{0}$ = 0.5')

fig.savefig('Ellipse1.pdf')
matplotlib.pyplot.show()
```

```python
print('a11 : ', a11, 'a22 :', a22, 'a66 :', a66)
```

    a11 :  [0.9931043676683802, 0.9965718814567534, 0.998290974861119] a22 : [0.9865223453770564, 0.9932240682780765, 0.9966022674807866] a66 : [0.9786321564960416, 0.9895431240318305, 0.9948295117927394]

```python
a0= 10
b0= 1
sympy.geometry.ellipse.Ellipse(center=sympy.geometry.Point(0,0), hradius=a0, vradius=b0, eccentricity=None)
```

```python
#ellipse of major half axis a0 and minor half axis b0

a0 = 10
b0 = 1
rho = 1
m_ana11 = math.pi*rho*b0**2
m_ana22 = math.pi*rho*a0**2
m_ana66 = (1./8)*1*math.pi*(a0**2-b0**2)**2

matplotlib.pyplot.rc('text', usetex=True)
matplotlib.pyplot.rc('font', family='serif')

fig = matplotlib.pyplot.figure(figsize=[10,10])

a11 = []
a22 = []
a66 = []
vector=[100, 200, 400]
for l, N in enumerate(vector):

    thetap = numpy.linspace(2*math.pi/N, 2*math.pi, N)
    thetam = numpy.linspace(0, 2*math.pi*(N-1)/N, N)

    xp = a0*numpy.cos(thetap)
    yp = b0*numpy.sin(thetap)
    xm = a0*numpy.cos(thetam)
    ym = b0*numpy.sin(thetam)
    barx = 0.5*(xp + xm)
    bary = 0.5*(yp + ym)
    ds = numpy.sqrt((xp - xm)**2 + (yp-ym)**2)
    #print(ds)
    nn1 = -(barx)/numpy.sqrt(barx**2 + bary**2*a0**4/b0**4)

    nn2 = -(bary)/numpy.sqrt(bary**2 + barx**2*b0**4/a0**4)

    nn6 = barx*nn2 - bary*nn1

    pdcr1, a11j = func(xp, yp, xm, ym, barx, bary, nn1, N)

    pdcr2, a22j = func(xp, yp, xm, ym, barx, bary, nn2, N)
    pdcr6, a66j = func(xp, yp, xm, ym, barx, bary, nn6, N)

    a11.append(a11j/m_ana11)
    a22.append(a22j/m_ana22)
    a66.append(a66j/m_ana66)

matplotlib.pyplot.plot(1/numpy.array(vector),a11,'^',1/numpy.array(vector), a22,'v',1/numpy.array(vector),a66,'o')
matplotlib.pyplot.xlabel(r' $\frac{1}{N}  $ ', fontsize= 20)
matplotlib.pyplot.legend([r'$\frac{m_{11}}{\pi \rho b_0^2}$',r"$\frac{m_{22}}{\pi\rho a_0^2}$",
                          r"$\frac{m_{66}}{8\pi\rho(a_0^2-b_0^2)^2}$"])
matplotlib.pyplot.title('Ellipse $b_{0}/a_{0}$ = 0.1')

fig.savefig('Ellipse2.pdf')
matplotlib.pyplot.show()
```

```python
print('a11 : ', a11, 'a22 :', a22, 'a66 :', a66)
```

    a11 :  [0.9948613644723051, 0.9974647059009988, 0.9987412968909483] a22 : [0.951372431074255, 0.9753626981557668, 0.987594988065203] a66 : [0.9446691958631221, 0.9724248763873804, 0.9862342854489561]

```python
a0= 1.
sympy.geometry.RegularPolygon(c=sympy.geometry.Point(0,0), r= a0*math.sqrt(2), n=4, rot=-math.pi/4)
```

```python
fig = matplotlib.pyplot.figure(figsize=[10,10])
a0= 1.
m_ana11 = 4.754*a0**2
m_ana22 = m_ana11
m_ana66 = 0.725*a0**4
nr = 0
vector=[50,100,200, 400] #number of points around the circumference of the square
N_inv = numpy.zeros(len(vector))
a11 = numpy.zeros(len(vector))
a22 = numpy.zeros(len(vector))
a66 = numpy.zeros(len(vector))
vector_ = []
for N in vector:

    n1 = int(N/8.)   #check if N is multiple of 8. If not, choose a multiple of 8.
    n = n1*2
    vector_.append(n*4)
    N_inv[nr] = 1./(n*4)
    x = []
    y = []

    x.extend(numpy.zeros(n)+a0)
    x.extend(numpy.linspace(a0, -a0*(n-2.)/n, n))
    x.extend(numpy.zeros(n)-a0)
    x.extend(numpy.linspace(-a0, a0*(n-2.)/n, n))
    y.extend(numpy.linspace(-a0, a0*(n-2.)/n, n))
    y.extend(numpy.zeros(n)+a0)
    y.extend(numpy.linspace(a0, -a0*(n-2.)/n, n))
    y.extend(numpy.zeros(n)-a0)

    #print('lenx',len(x))
    #print('x', x)
    #print('y', y)
    xm, ym = numpy.roll(x, -n1), numpy.roll(y,-n1)
    xp, yp = numpy.roll(xm,-1), numpy.roll(ym,-1)

    barx = 0.5*(xp + xm)
    bary = 0.5*(yp + ym)

    nn1 = numpy.zeros(n*4)
    nn2 = numpy.zeros(n*4)
    nn6 = numpy.zeros(n*4)
    for i in range(n*4):
            if round(barx[i],5) == round(a0,5):
                nn1[i] = -1
            if round(barx[i],5) == - round(a0,5):
                nn1[i] = 1
            if round(bary[i],5) == round(a0,5):
                nn2[i] = - 1
            if round(bary[i],5) == - round(a0,5):
                nn2[i] = 1

    nn6 = barx*nn2 - bary*nn1

    pdcr1, a11j = func(xp, yp, xm, ym, barx, bary, nn1, 4*n)

    pdcr2, a22j = func(xp, yp, xm, ym, barx, bary, nn2, 4*n)
    pdcr6, a66j = func(xp, yp, xm, ym, barx, bary, nn6, 4*n)

    a11[nr] = a11j/m_ana11
    a22[nr] = a22j/m_ana22
    a66[nr] = a66j/m_ana66

    nr = nr +1

pan = -a0**2*((4.754/2)+2)/math.pi*barx/(barx**2+bary**2)

matplotlib.pyplot.plot(N_inv,a11,'^',N_inv, a22,'v',N_inv,a66,'o')
matplotlib.pyplot.xlabel(r' $\frac{1}{N}  $ ', fontsize= 20)
matplotlib.pyplot.legend([r'$\frac{m_{11}}{4.754\rho a_0^2}$',r"$\frac{m_{22}}{4.754\rho a_0^2}$",
                          r"$\frac{m_{66}}{0.725\rho a_0^4}$"])
matplotlib.pyplot.title('Square ')
matplotlib.pyplot.show()
fig.savefig('Square.pdf')
```

```python
print('a11 : ', a11, 'a22 :', a22, 'a66 :', a66)
```

    a11 :  [0.98495137 0.99114504 0.99513619 0.99730372] a22 : [0.98495137 0.99114504 0.99513619 0.99730372] a66 : [0.9395786  0.96503663 0.98029679 0.98860915]

```python

```