# Practical Session 1: Data exploration and regression algorithms

*Notebook by Ekaterina Kochmar*

## 0.1. Dataset

The California House Prices Dataset is originally obtained from the StatLib repository. This dataset contains the collected information on the variables (e.g., median income, number of households, precise geographical position) using all the block groups in California from the 1990 Census. A block group is the smallest geographical unit for which the US Census Bureau publishes sample data, and on average it includes $1425.5$ individuals living in a geographically compact area. The [original data](http://www.dcc.fc.up.pt/~ltorgo/Regression/cal_housing.html) contains $20640$ observations on $9$ variables, with the *median house value* being the dependent variable (or *target attribute*). The [modified dataset](https://www.kaggle.com/camnugent/california-housing-prices) from Aurelien Geron, *Hands-On Machine Learning with Scikit-Learn and TensorFlow* contains an additional categorical variable.

For more information on the original data, please refer to Pace, R. Kelley and Ronald Barry, *Sparse Spatial Autoregressions*, Statistics and Probability Letters, 33 (1997) 291-297. For the information on the modified dataset, please refer to Aurelien Geron, *Hands-On Machine Learning with Scikit-Learn and TensorFlow*, O′Reilly (2017), ISBN: 978-1491962299.

## 0.2. Understanding your task

You are given a dataset that contains a range of attributes describing the houses in California. Your task is to predict the median price of a house based on its attributes. That is, you should train a machine learning (ML) algorithm on the available data, and the next time you get new information on some housing in California, you can use your trained algorithm to predict its price.

The questions to ask yourself before starting a new ML project:
- Does the task suggest a supervised or an unsupervised approach?
- Are you trying to predict a discrete or a continuous value?
- Which ML algorithm is most suitable?

Try to answer these questions before you start working on this task, using the following hints:
- *Supervised* approaches rely on the availability of target label annotation in data; examples include regression and classification approaches. *Unsupervised* approaches don't use annotated data; clustering is a good example of such approach.
- *Discrete* variables are associated with classes and imply classification approach. *Continuous* variables are associated with regression.

## 0.3. Machine Learning check-list

In a typical ML project, you need to:

- Get the dataset
- Understand the data, the attributes and their correlations
- Split the data into training and test set
- Apply normalisation, scaling and other transformations to the attributes if needed
- Build a machine learning model
- Evaluate the model and investigate the errors
- Tune your model to improve performance

This practical will show you how to implement the above steps.

## 0.4. Prerequisites

Some of you might have used Jupiter notebooks with the following libraries before in the [CL 1A Scientific Computing course](https://www.cl.cam.ac.uk/teaching/1920/SciComp/materials.html).

To run the notebooks on your machine, check if `Python 3` is installed. In addition, you will need the following libraries:

- `Pandas` for easy data uploading and manipulation. Check installation instructions at https://pandas.pydata.org/pandas-docs/stable/getting_started/install.html
- `Matplotlib`: for visualisations. Check installation instructions at https://matplotlib.org/users/installing.html
- `NumPy` and `SciPy`: for scietinfic programming. Check installation instruction at https://www.scipy.org/install.html
- `Scikit-learn`: for machine learning algorithms. Check installation instructions at http://scikit-learn.org/stable/install.html

Alternatively, a number of these libraries can be installed in one go through [Anaconda](https://www.anaconda.com/products/individual) distribution.

## 0.5. Learning objectives

In this practical you will learn how to:

- upload and explore a dataset
- visualise and explore the correlations between the variables
- structure a machine learning project
- select the training and test data in a random and in a stratified way
- handle missing values
- handle categorical values
- implement a custom data transformer
- build a machine learning pipeline
- implement a regression algorithm
- evaluate a regression algorithm performance

In addition, you will learn about such common machine learning concepts as:
- data scaling and normalisation
- overfitting and underfitting
- cross-validation
- hyperparameter setting with grid search

## Step 1: Uploading and inspecting the data

First let's upload the dataset using `Pandas` and defining a function pointing to the location of the `housing.csv` file:

```python
import pandas as pd
import os

def load_data(housing_path):
    csv_path = os.path.join(housing_path, "housing.csv")
    return pd.read_csv(csv_path)
```

Now, let's run `load_data` using the path where you stored your `housing.csv` file. This function will return a `Pandas` DataFrame object containing all the data. It is always a good idea to take a quick look into the uploaded dataset and make sure you understand the data you are working with. For example, you can check the top rows of the uploaded data and get the general information about the dataset using `Pandas` functionality as follows:

```python
housing = load_data("housing/")
housing.head()
```

Remember that each row in this table represents a block group (housing district), and each column an attribute. How many attributes does the dataset contain?

Another way to get the summary information about the number of instances and attributes in the dataset is using `info` function. It also shows each attribute's type and number of non-null values:

```python
housing.info()
```

Before proceeding further, think about the following:
- How is the data represented?
- What do the attribute types suggest?
- Are there any missing values in the dataset? If so, should you do anything about them?

You must have worked with numerical values before, and the data types like `float64` should look familiar. However, *ocean\_proximity* attribute has values of a different type. You can inspect the values of a particular attribute in the DataFrame using the following code:

```python
housing["ocean_proximity"].value_counts()
```

The above suggests that the values are categorical: there are $5$ categories that define ocean proximity. ML algorithms prefer to work with numerical data, besides all the other attributes are represented using numbers. Keep that in mind, as this suggests that you will need to cast the categorical data as numerical.

For now, let's have a general overview of the attributes and distribution of their values (note *ocean_proximity* is excluded from this summary):

```python
housing.describe()
```

To make sure you understand the structure of the dataset, try answering the following questions:
- How can you interpret the values in the table above?
- What do the percentiles (e.g., $25\%$ or $50\%$) tell you about the distribution of values in this dataset (you can select one particular attribute to explain)?
- How are the missing values handled?

Remember that you can always refer to [`Pandas`](https://pandas.pydata.org/pandas-docs/stable/reference/index.html) documentation.

Another good way to get an overview of the values distribution is to plot histograms. This time, you'll need to use `matplotlib`:

```python
%matplotlib inline
#so that the plot will be displayed in the notebook
import matplotlib.pyplot as plt

housing.hist(bins=50, figsize=(20,15))
plt.show()
```

Two observations about this graphs are worth making:
- the *median_income*, *housing_median_age* and the *median_house_value* have been capped by the team that collected the data: that is, the values for the *median_income* are scaled by dividing the income by \\$10000 and capped so that they range between $[0.4999, 15.0001]$ with the incomes lower than $0.4999$ and higher than $15.0001$ binned together; similarly, the *housing_median_age* values have been scaled and binned to range between $[1, 52]$ years and the *median_house_value* – to range between $[14999, 500001]$. Data manipulations like these are not unusual in data science but it's good to be aware of how the data is represented;
- several other attributes are "tail heavy" – they have a long distribution tail with many decreasingly rare values to the right of the mean. In practice that means that you might consider using the logarithms of these values rather than the absolute values.

## Step 2: Splitting the data into training and test sets

In this practical, you are working with a dataset that has been collected and thoroughly labelled in the past. Each instance has a predefined set of values and the correct price label assigned to it. After training the ML model on this dataset you hope to be able to predict the prices for new houses, not contained in this dataset, based on their characteristics such as geographical position, median income, number of rooms and so on. How can you check in advance whether your model is good in making such predictions?

The answer is: you set part of your dataset, called *test set*, aside and use it to evaluate the performance of your model only. You train and tune your model using the rest of the dataset – *training set* – and evaluate the performance of the model trained this way on the test set. Since the model doesn't see the test set during training, this perfomance should give you a reasonable estimate of how well it would perform on new data. Traditionally, you split the data into $80\%$ training and $20\%$ test set, making sure that the test instances are selected randomly so that you don't end up with some biased selection leading to over-optimistic or over-pessimistic results on your test set.

For example, you can select your test set as the code below shows. To ensure random selection of the test items, use `np.random.permutation`. However, if you want to ensure that you have a stable test set, and the same test instances get selected from the dataset in a random fashion in different runs of the program, select a random seed, e.g. using `np.random.seed(42)`.

```python
import numpy as np
np.random.seed(42)

def split_train_test(data, test_ratio):
    shuffled_indices = np.random.permutation(len(data))
    test_set_size = int(len(data) * test_ratio)
    test_indices = shuffled_indices[:test_set_size]
    train_indices = shuffled_indices[test_set_size:]
    return data.iloc[train_indices], data.iloc[test_indices]

train_set, test_set = split_train_test(housing, 0.2)
print(len(train_set), "training instances +", len(test_set), "test instances")
```

Note that `scikit-learn` provides a similar functionality to the code above with its `train_test_split` function. Morevoer, you can pass it several datasets with the same number of rows each, and it will split them into training and test sets on the same indices (you might find it useful if you need to pass in a separate DataFrame with labels):

```python
from sklearn.model_selection import train_test_split

train_set, test_set = train_test_split(housing, test_size=0.2, random_state=42)
print(len(train_set), "training instances +", len(test_set), "test instances")
```

So far, you have been selecting your test set using random sampling methods. If your data is representative of the task at hand, this should help ensure that the results of the model testing are informative. However, if your dataset is not very large and the data is skewed on some of the attributes or on the target label (as is often the case with the real-world data), random sampling might introduce a sampling bias. *Stratified sampling* is a technique that helps make sure that the distributions of the instance attributes or labels in the training and the test sets are similar, meaning that the proportion of instances drawn from each *stratum* in the dataset is similar in the training and test data.

Sampling bias may express itself both in the distribution of labels and in the distribution of the attribute values.  For instance, take a look at the *median_income* attribute value distribution. Suppose for now (and you might find a confirmation to that later in the practical) that this attribute is predictive of the house price, however its values are unevenly distributed across the range of $[0.4999, 15.0001]$ with a very long tail. If random sampling doesn't select enough instances for each *stratum* (each range of incomes) the estimate of the under-represented strata's importance will be biased.

First, to limit the number of income categories (strata), particularly at the long tail, let's apply further binning to the income values: e.g., you can divide the income by $1.5$, round up the values using `ceil` to have discrete categories (bins), and merge all the categories greater than $5$ into category $5$. The latter can be achieved using `Pandas`' `where` functionality, keeping the original values when they are smaller than $5$ and converting them to $5$ otherwise:

```python
housing["income_cat"] = np.ceil(housing["median_income"] / 1.5)
housing["income_cat"].where(housing["income_cat"] < 5, 5.0, inplace = True)

housing["income_cat"].hist()
plt.show()
```

Now you have a much smaller number of categories of income, with the instances more evenly distributed, so you can hope to get enough data to represent the tail. Next, let's split the dataset into training and test sets making sure both contain similar proportion of instances from each income category. You can do that using `scikit-learn`'s `StratifiedShuffleSplit` specifying the condition on which the data should be stratified (in this case, income category):

```python
from sklearn.model_selection import StratifiedShuffleSplit

split = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
for train_index, test_index in split.split(housing, housing["income_cat"]):
    strat_train_set = housing.loc[train_index]
    strat_test_set = housing.loc[test_index]
```

Let's compare the distribution of the income values in the randomly selected train and test sets and the stratified train and test sets against the full dataset. To better understand the effect of random sampling versus stratified sampling, let's also estimate the error that would be introduced in the data by such splits:

```python
train_set, test_set = train_test_split(housing, test_size=0.2, random_state=42)

def income_cat_proportions(data):
    return data["income_cat"].value_counts() / len(data)

compare_props = pd.DataFrame({
    "Overall": income_cat_proportions(housing),
    "Stratified tr": income_cat_proportions(strat_train_set),
    "Random tr": income_cat_proportions(train_set),
    "Stratified ts": income_cat_proportions(strat_test_set),
    "Random ts": income_cat_proportions(test_set),
})
compare_props["Rand. tr %error"] = 100 * compare_props["Random tr"] / compare_props["Overall"] - 100
compare_props["Rand. ts %error"] = 100 * compare_props["Random ts"] / compare_props["Overall"] - 100
compare_props["Strat. tr %error"] = 100 * compare_props["Stratified tr"] / compare_props["Overall"] - 100
compare_props["Strat. ts %error"] = 100 * compare_props["Stratified ts"] / compare_props["Overall"] - 100

compare_props.sort_index()
```

As you can see, the distributions in the stratified training and test sets are much closer to the original distribution of categories as well as being much closer to each other.

Note, that to help you split the data, you had to introduce a new category – *income_cat* – which contains the same information as the original attribute *median_income* binned in a different way:

```python
strat_train_set.info()
```

Before proceeding further let's remove the *income_cat* attribute so the data is back to its original state. Here is how you can do that:

```python
for set_ in (strat_train_set, strat_test_set):
    set_.drop("income_cat", axis=1, inplace=True)

strat_train_set.info()
```

## Step 3: Exploring the attributes

The next step is to look more closely into the attributes and gain insights into the data. In particular, you should try to answer the following questions:
- Which attributes look most informative?
- How do they correlate with each other and the target label?
- Is any further normalisation or scaling needed?

The most informative ways in which you can answer the questions above are by *visualising* the data and by *collecting additional statistics* on the attributes and their relations to each other.

First, remember that from now on you're only looking into and gaining insights from the training data. You will use the test data at the evaluation step only, thus ensuring no data leakage between the training and test sets occurs and the results on the test set are a fair evaluation of your algorithm's performance. Let's make a copy of the training set that you can experiment with without a danger of overwriting or changing the original data:

```python
housing = strat_train_set.copy()
```

### Visualisations

The first two attributes describe the geographical position of the houses. Let's apply further visualisations and look into the geographical area that is covered: for that, use a scatter plot plotting longitude against latitude coordinates. To make the scatter plot more informative, use `alpha` option to highlight high density points:

```python
housing.plot(kind='scatter', x='longitude', y='latitude', alpha=0.2)
```

You can experiment with `alpha` values to get a better understanding, but it should be obvious from these plots that the areas in the south and along the coast of California are more densely populated (roughly corresponding to the Bay Area, Los Angeles, San Diego, and the Central Valley).

Now, what does geographical position suggest about the housing prices? In the following code, the size of the circles represents the size of the population, and the color represents the price, ranging from blue for low prices to red for high prices (this color scheme is specified by the preselected `cmap` type):

```python
housing.plot(kind='scatter', x='longitude', y='latitude', alpha=0.5,
            s=housing["population"]/100, label="population", figsize=(10,7),
            c=housing["median_house_value"], cmap=plt.get_cmap("jet"), colorbar="True",
            )
plt.legend()
```

This plot suggests that the housing prices depend on the proximity to the ocean and on the population size. What does this suggest about the informativeness of the attributes for your ML task?

### Correlations

Let's also look into how the attributes correlate with each other:

```python
corr_matrix = housing.corr()
corr_matrix
```

Since you are trying to predict the house value, the last column in this table is the most informative. Let's make the output clearer:

```python
corr_matrix["median_house_value"].sort_values(ascending=False)
```

This makes it clear that the *median_income* is most strongly positively correlated with the price. There is small positive correlation of the price with *total_rooms* and *housing_median_age*, and small negative correlation with *latitude*, which suggests that the prices go up with the increase in income, number of rooms and house age, and go down when you go north. `Pandas`' `scatter_matrix` function allows you to visualise the correlation of attributes with each other (note that since the correlation of an attribute with itself will result in a straight line, `Pandas` uses a histogram instead – that's what you see along the diagonal):

```python
from pandas.plotting import scatter_matrix
# If the above returns an error, use the following:
#from pandas.tools.plotting import scatter_matrix

attributes = ["median_house_value", "median_income", "total_rooms", "housing_median_age", "latitude"]
scatter_matrix(housing[attributes], figsize=(12,8))
```

These plots confirm that the income attribute is the most promising one for predicting house prices, so let's zoom in on this attribute:

```python
housing.plot(kind="scatter", x="median_income", y="median_house_value", alpha=0.3)
```

There are a couple of observations to be made about this plot:
- The correlation is indeed quite strong: the values follow the upward trend and are not too dispersed otherwise;
- You can clearly see a line around $500000$ which covers a full range of income values and is due to the fact that the house prices above that value were capped in the original dataset. However, the plot suggests that there are also some other less obvious groups of values, most visible around $350000$ and $450000$, that also cover a range of different income values. Since your ML algorithm will learn to reproduce such data quirks, you might consider looking into these matters further and removing these districts from your dataset (after all, in any real-world application, one can expect a certain amount of noise in the data and clearing the data is one of the steps in any practical application).

The next thing to notice is that a number of attributes from the original dataset, including *total_rooms*, 	*total_bedrooms* and *population*, do not actually describe each house in particular but rather represent the cumulative counts for *all households* in the block group. At the same time, the task at hand requires you to predict the house price for *each individual household*. In addition, an attribute that measures the proportion of bedrooms against the total number of rooms might be informative. Therefore, the following transformed attributes might be more useful for the prediction:

```python
housing["rooms_per_household"] = housing["total_rooms"] / housing["households"]
housing["bedrooms_per_household"] = housing["total_bedrooms"] / housing["households"]
housing["bedrooms_per_rooms"] = housing["total_bedrooms"] / housing["total_rooms"]
housing["population_per_household"] = housing["population"] / housing["households"]
```

A good way to check whether these transformations have any effect on the task is to check attributes correlations again:

```python
corr_matrix = housing.corr()
corr_matrix["median_house_value"].sort_values(ascending=False)
```

You can see that the number of rooms per household is more strongly correlated with the house price – the more rooms the more expensive the house, while the proportion of bedrooms is more strongly correlated with the price than either the number of rooms or bedrooms in the household – since the correlation is negative, the lower the bedroom-to-room ratio, the more expensive the property.

## Step 4: Data preparation and transformations for machine learning algorithms

Now you are almost ready to implement a regression algorithm for the task at hand. However, there are a couple of other things to address, in particular:
- handle missing values if there are any;
- convert all attribute values (e.g. categorical, textual) into numerical format;
- scale / normalise the feature values if necessary.

First, let's separate the labels you're trying to predict (*median_house_value*) from the attributes in the dataset that you will use as *features*. The following code will keep a copy of the labels and the rest of the attributes separate (note that `drop()` will create a copy of the data and will not affect `strat_train_set` itself):

```python
housing = strat_train_set.drop("median_house_value", axis=1)
housing_labels = strat_train_set["median_house_value"].copy()
```

You can add the transformed features that you found useful before with the additional function as shown below. Then you can run `add_features(housing)` to add the features:

```python
def add_features(data):
    # add the transformed features that you found useful before
    data["rooms_per_household"] = data["total_rooms"] / data["households"]
    data["bedrooms_per_household"] = data["total_bedrooms"] / data["households"]
    data["bedrooms_per_rooms"] = data["total_bedrooms"] / data["total_rooms"]
    data["population_per_household"] = data["population"] / data["households"]

# add_features(housing)
```

You will learn shortly about how to implement your own *data transformers* and will be able to re-implement addition of these features as a data transfomer.

### Handling missing values

In Step 1 above, when you took a quick look into the dataset, you might have noticed that all attributes but one have $20640$ values in the dataset; *total_bedrooms* has $20433$, so some values are missing. ML algorithms cannot deal with missing values, so you'll need to decide how to replace these values. There are three possible solutions:

1. remove the corresponding housing blocks from the dataset (i.e., remove the rows in the dataset)
2. remove the whole attribute (i.e., remove the column)
3. set the missing values to some predefined value (e.g., zero value, the mean, the median, the most frequent value of the attribute, etc.)

The following `Pandas` functionality will help you implement each of these options:

```python
## option 1:
# housing.dropna(subset=["total_bedrooms"])
## option 2:
# housing.drop("total_bedrooms", axis=1)
# option 3:
median = housing["total_bedrooms"].median()
housing["total_bedrooms"].fillna(median, inplace=True)
```

Although, all three options are possible, keep in mind that in the first two cases you are throwing away either some valuable attributes (e.g., as you've seen earlier, *bedrooms_per_rooms* correlates well with the label you're trying to predict) or a number of valuable training examples. Option 3, therefore, looks more promising. Note, that for that you estimate a mean or median based on the training set only (as, in general, your ML algorithm has access to the training data only during the training phase), and then store the mean / median values to replace the missing values in the test set (or any new dataset, to that effect). In addition, you might want to calculate and store the mean / median values for all attributes as in a real-life application you can never be sure if any of the attributes will have missing values in the future.

Here is how you can calculate and store median values using `sklearn` (note that you'll need to exclude `ocean_proximity` attribute from this calculation since it has non-numerical values):

```python
# for earlier versions of sklearn use:
#from sklearn.preprocessing import Imputer
#imputer = Imputer(strategy="median")

from sklearn.impute import SimpleImputer

imputer = SimpleImputer(strategy="median")
housing_num = housing.drop("ocean_proximity", axis=1)
imputer.fit(housing_num)
```

You can check the median values stored in the `imputer` as follows:

```python
imputer.statistics_
```

and also make sure that they exactly coincide with the median values for all numerical attributes:

```python
housing_num.median().values
```

Finally, let's replace the missing values in the training data:

```python
X = imputer.transform(housing_num)
housing_tr = pd.DataFrame(X, columns=housing_num.columns)
housing_tr.info()
```

### Handling textual and categorical attributes

Another aspect of the dataset that should be handled is the textual / categorical values of the *ocean_proximity* attribute. ML algorithms prefer working with numerical data, so let's use `sklearn`'s functionality and cast the categorical values as numerical values as follows:

```python
from sklearn.preprocessing import LabelEncoder

encoder = LabelEncoder()
housing_cat_encoded = encoder.fit_transform(housing["ocean_proximity"])
housing_cat_encoded
```

The code above mapped the categories to numerical values. You can check what the numerical values correspond to in the original data using:

```python
encoder.classes_
```

One problem with the encoding above is that the ML algorithm will automatically assume that the numerical values that are close to each other encode similar concepts, which for this data is not quite true: for example, value $0$ corresponding to *$<$1H OCEAN* category is actually most similar to values $3$ and $4$ (*NEAR BAY* and *NEAR OCEAN*) and not to value $1$ (*INLAND*).

An alternative to this encoding is called *one-hot encoding* and it runs as follows: for each category, it creates a separate binary attribute which is set to $1$ (hot) when the category coincides with the attribute, and $0$ (cold) otherwise. So, for instance, *$<$1H OCEAN* will be encoded as a one-hot vector $[1, 0, 0, 0, 0]$ and *NEAR OCEAN* will be encoded as $[0, 0, 0, 0, 1]$. The following `sklearn`'s functionality allows to convert categorical values into one-hot vectors:

```python
from sklearn.preprocessing import OneHotEncoder

encoder = OneHotEncoder()
# fit_transform expects a 2D array, but housing_cat_encoded is a 1D array.
# Reshape it using NumPy's reshape functionality where -1 simply means "unspecified" dimension
housing_cat_1hot = encoder.fit_transform(housing_cat_encoded.reshape(-1,1))
housing_cat_1hot
```

Note that the data format above says that the output is a sparse matrix. This means that the data structure only stores the location of the non-zero elements, rather than the full set of vectors which are mostly full of zeros. You can check the [documentation on sparse matrices](https://docs.scipy.org/doc/scipy/reference/generated/scipy.sparse.csr_matrix.html) if you'd like to learn more. If you'd like to see how the encoding looks like you can also convert it back into a dense NumPy array using:

```python
housing_cat_1hot.toarray()
```

The steps above, including casting text categories to numerical categories and then converting them into 1-hot vectors, can be performed using `sklearn`'s `LabelBinarizer`:

```python
from sklearn.preprocessing import LabelBinarizer

encoder = LabelBinarizer()
housing_cat_1hot = encoder.fit_transform(housing["ocean_proximity"])
housing_cat_1hot
```

The above produces dense array as an output, so if you'd like to have a sparse matrix instead you can specify it in the `LabelBinarizer` constructor:

```python
encoder = LabelBinarizer(sparse_output=True)
housing_cat_1hot = encoder.fit_transform(housing["ocean_proximity"])
housing_cat_1hot
```

### Data transformers

A useful functionality of `sklearn` is [data transformers](http://scikit-learn.org/stable/data_transforms.html): you will see them used in preprocessing very often. For example, you have just used one to impute the missing values. In addition, you can implement your own custom data transformers. In general, a transformer class needs to implement three methods:
- a constructor method;
- a `fit` method that learns parameters (e.g. mean and standard deviation for a normalization transformer) or returns `self`; and
- a `transform` method that applies the learned transformation to the new data.

Whenever you see `fit_transform` method, it means that the method uses an optimised combination of `fit` and `transform`. Here is how you can implement a data transformer that will convert categorical values into 1-hot vectors:

```python
from sklearn.base import TransformerMixin # TransformerMixin allows you to use fit_transform method

class CustomLabelBinarizer(TransformerMixin):
    def __init__(self, *args, **kwargs):
        self.encoder = LabelBinarizer(*args, **kwargs)
    def fit(self, X, y=0):
        self.encoder.fit(X)
        return self
    def transform(self, X, y=0):
        return self.encoder.transform(X)
```

Similarly, here is how you can wrap up adding new transformed features like bedroom-to-room ratio with a data transformer:

```python
from sklearn.base import BaseEstimator, TransformerMixin
# BaseEstimator allows you to drop *args and **kwargs from you constructor
# and, in addition, allows you to use methods set_params() and get_params()

rooms_id, bedrooms_id, population_id, household_id = 3, 4, 5, 6

class CombinedAttributesAdder(BaseEstimator, TransformerMixin):
    def __init__(self, add_bedrooms_per_rooms = True): # note no *args and **kwargs used this time
        self.add_bedrooms_per_rooms = add_bedrooms_per_rooms
    def fit(self, X, y=None):
        return self
    def transform(self, X, y=None):
        rooms_per_household = X[:, rooms_id] / X[:, household_id]
        bedrooms_per_household = X[:, bedrooms_id] / X[:, household_id]
        population_per_household = X[:, population_id] / X[:, household_id]
        if self.add_bedrooms_per_rooms:
            bedrooms_per_rooms = X[:, bedrooms_id] / X[:, rooms_id]
            return np.c_[X, rooms_per_household, bedrooms_per_household,
                         population_per_household, bedrooms_per_rooms]
        else:
            return np.c_[X, rooms_per_household, bedrooms_per_household,
                         population_per_household]

attr_adder = CombinedAttributesAdder()
housing_extra_attribs = attr_adder.transform(housing.values)
housing_extra_attribs
```

If you'd like to explore the new attributes, you can convert the `housing_extra_attribs` into a `Pandas` DataFrame and apply the functionality as before:

```python
housing_extra_attribs = pd.DataFrame(housing_extra_attribs, columns=list(housing.columns)+
                                     ["rooms_per_household", "bedrooms_per_household",
                                      "population_per_household", "bedrooms_per_rooms"])
housing_extra_attribs.head()
```

```python
housing_extra_attribs.info()
```

### Feature scaling

Finally, ML algorithms do not typically perform well when the feature values cover significantly different ranges of values. For example, in the dataset at hand, the income ranges from $0.4999$ to $15.0001$, while population ranges from $3$ to $35682$. Taken at the same scale, these values are not directly comparable. The data transformation that should be applied to these values is called *feature scaling*.

One of the most common ways to scale the data is to apply *min-max scaling* (also often referred to as *normalisaton*). Min-max scaling puts all values on the scale of $[0, 1]$ making the ranges directly comparable. For that, you need to subtract the min from the actual value and divide by the difference between the maximum and minimum values, i.e.:

\begin{equation}
f_{scaled} = \frac{f - F_{min}}{F_{max} - F_{min}}
\end{equation}

where $f \in F$ is the actual feature value of a feature type $F$, and $F_{min}$ and $F_{max}$ are the minumum and maximum values for the feature of type $F$.

Another common approach is *standardisation*, which subtracts the mean value (so the standardised values have a zero mean) and divides by the variance (so the standardised values have unit variance). Standardisation does not impose a specific range on the values and is more robust to the outliers: i.e., a noisy input or an incorrect income value of $100$ (when the rest of the values lie within the range of $[0.4999, 15.0001]$) will introduce a significant skew in the data after min-max scaling. At the same time, standardisation does not bind values to the same range of $[0, 1]$, which might be problematic for some algorithms.

`Scikit-learn` has an implementation for the `MinMaxScaler`, `StandardScaler`, as well as [other scaling approaches](http://scikit-learn.org/stable/modules/preprocessing.html#preprocessing-scaler), i.e.:

```python
from sklearn.preprocessing import StandardScaler, MinMaxScaler

scaler = StandardScaler()
housing_tr_scaled = scaler.fit_transform(housing_tr)
```

### Putting all the data transformations together

Another useful functionality of `sklearn` is pipelines. These allow you to stack several separate transformations together. For example, you can apply the numerical transformations such as missing values handling and data scaling as follows:

```python
from sklearn.pipeline import Pipeline

num_pipeline = Pipeline([
    #('imputer', Imputer(strategy="median")),
    ('imputer', SimpleImputer(strategy="median")),
    ('std_scaler', StandardScaler()),
])

housing_num_tr = num_pipeline.fit_transform(housing_num)
housing_num_tr.shape
```

Pipelines are useful because they help combining several steps together, so that the output of one data transformer (e.g., `Imputer`) is passed on as an input to the next one (e.g., `StandardScaler`) and so you don't need to worry about the intermediate steps. Besides, it makes the code look more concise and readable. However:
- the code above doesn't handle categorical values;
- we started with `Pandas` DataFrames because they are useful for data uploading and inspection, but the `Pipeline` expects `NumPy` arrays as input, and at the moment, `sklearn`'s `Pipeline` cannot handle `Pandas` DataFrames.

In fact, there is a way around the two issues above. Let's implement another custom data transformer that will allow you to select specific attributes from a `Pandas` DataFrame:

```python
from sklearn.base import BaseEstimator, TransformerMixin

# Create a class to select numerical or categorical columns
# since Scikit-Learn doesn't handle DataFrames yet
class DataFrameSelector(BaseEstimator, TransformerMixin):
    def __init__(self, attribute_names):
        self.attribute_names = attribute_names
    def fit(self, X, y=None):
        return self
    def transform(self, X):
        return X[self.attribute_names].values
```

The transformer above allows you to select a predefined set of attributes from a DataFrame, dropping the rest and converting the selected ones into a `NumPy` array. This is quite useful because now you can select the numerical attributes and apply one set of transformations to them, and then select categorical attributes and apply another set of transformation to them, i.e.:

```python
num_attribs = list(housing_num)
cat_attribs = ["ocean_proximity"]

num_pipeline = Pipeline([
        ('selector', DataFrameSelector(num_attribs)),
        #('imputer', Imputer(strategy="median")),
        ('imputer', SimpleImputer(strategy="median")),
        ('attribs_adder', CombinedAttributesAdder()),
        ('std_scaler', StandardScaler()),
    ])

cat_pipeline = Pipeline([
        ('selector', DataFrameSelector(cat_attribs)),
        ('label_binarizer', CustomLabelBinarizer()),
    ])
```

Finally, to merge the output of the two separate data transformers back together, you can use `sklearn`'s `FeatureUnion` functionality: it runs the two pipelines' `fit` methods and the two `transform` methods in parallel, and then concatenates the output. I.e.:

```python
from sklearn.pipeline import FeatureUnion

full_pipeline = FeatureUnion(transformer_list=[
        ("num_pipeline", num_pipeline),
        ("cat_pipeline", cat_pipeline),
    ])

housing = strat_train_set.drop("median_house_value", axis=1)
housing_labels = strat_train_set["median_house_value"].copy()

housing_prepared = full_pipeline.fit_transform(housing)
print(housing_prepared.shape)
housing_prepared
```

## Step 5:  Implementation, evaluation and fine-tuning of a regression model

Now that you've explored and prepared the data, you can implement a regression model to predict the house prices on the test set.

### Training and evaluating the model

Let's train a [Linear Regression](http://scikit-learn.org/stable/modules/linear_model.html) model first. During training, a Linear Regression model tries to find the optimal set of weights $w=(w_{1}, w_{2}, ..., w_{n})$ for the features (attributes) $X=(x_{1}, x_{2}, ..., x_{n})$ by minimising the residual sum of squares between the responses predicted by such linear approximation $Xw$ and the observed responses $y$ in the dataset, i.e. trying to solve:

\begin{equation}
min_{w} ||Xw - y||_{2}^{2}
\end{equation}

```python
from sklearn.linear_model import LinearRegression

lin_reg = LinearRegression()
lin_reg.fit(housing_prepared, housing_labels)
```

First, let's try the model on some instances from the training set itself:

```python
some_data = housing.iloc[:5]
some_labels = housing_labels.iloc[:5]
# note the use of transform, as you'd like to apply already learned (fitted) transformations to the data
some_data_prepared = full_pipeline.transform(some_data)

print("Predictions:", list(lin_reg.predict(some_data_prepared)))
print("Actual labels:", list(some_labels))
```

The above shows that the model is able to predict some price values, however they don't seem to be very accurate. How can you measure the performance of your model in a more comprehensive way?

Typically, the output of the regression model is measured in terms of the error in prediction. There are two error measures that are commonly used. *Root Mean Square Error (RMSE)* measures the average deviation of the model's prediction from the actual label, but note that it gives a higher weight for large errors:

\begin{equation}
RMSE(X, h) = \sqrt{\frac{1}{m} \sum_{i=1}^{m} (h(x^{(i)}) - y^{(i)})^{2}}
\end{equation}

where $m$ is the number of instances, $h$ is the model (hypothesis), $X$ is the matrix containing all feature values, $x^{(i)}$ is the feature vector describing instance $i$, and $y^{(i)}$ is the actual label for instance $i$.

Because *RMSE* is highly influenced by the outliers (i.e., large errors), in some situations *Mean Absolute Error (MAE)* is preferred. You may note that its estimation is somewhat similar to the estimation of *RMSE*:

\begin{equation}
MAE(X, h) = \frac{1}{m} \sum_{i=1}^{m} |h(x^{(i)}) - y^{(i)}|
\end{equation}

Let's measure the performance of the linear regression model using these error estimations:

```python
from sklearn.metrics import mean_squared_error

housing_predictions = lin_reg.predict(housing_prepared)
lin_mse = mean_squared_error(housing_labels, housing_predictions)
lin_rmse = np.sqrt(lin_mse)
lin_rmse
```

Given that the majority of the districts' housing values lie somewhere between $[\$100000, \$300000]$ an estimation error of over \\$68000 is very high. This shows that the regression model *underfits* the training data: it doesn't capture the patterns in the training data well enough because it lacks the descriptive power either due to the features not providing enough information to make a good prediction or due to the model itself being not complex enough. The ways to fix this include:
- using more features and/or more informative features, for example applying log to some of the existing features to address the long tail distributions;
- using more complex models;
- reducing the constraints on the model.

The model that you used above is not constrained (or, *regularised* – more on this in later lectures), so you should try using more powerful models or work on the feature set.

For example, *polynomial regression* models the relationship between the $X$ and $y$ as an $n$-th degree polynomial. Polynomial regression extends simple linear regression by constructing polynomial features from the existing ones. For simplicity, assume that your data has only $2$ features rather than $8$, i.e. $X=[x_{1}, x_{2}]$. The linear regression model above tries to learn the coefficients (weights) $w=[w_{0}, w_{1}, w_{3}]$ for the linear prediction (a plane) $\hat{y} = w_{0} + w_{1}x_{1} + w_{2}x_{2}$ that minimises the residual sum of squares between the prediction and actual label as you've seen above.

If you want to fit a paraboloid to the data instead of a plane, you can combine the features in second-order polynomials, so that the model looks like this:

\begin{equation}
\hat{y} = w_{0} + w_{1}x_{1} + w_{2}x_{2} + w_{3}x_{1}x_{2} + w_{4}x_{1}^2 + w_{5}x_{2}^2
\end{equation}

This time, the model tries to learn an optimal set of weights $w=[w_{0}, ..., w_{5}]$ (note that $w_{0}$ is called an intercept).

Note that polynomial regression still employs a linear model. For instance, you can define a new variable $z = [x_1, x_2, x_1x_2, x_1^2, x_2^2]$ and rewrite the polynomial above as:

\begin{equation}
\hat{y} = w_{0} + w_{1}z_{0} + w_{2}z_{1} + w_{3}z_{2} + w_{4}z_{3} + w_{5}z_{4}
\end{equation}

For that reason, the polynomial regression in `sklearn` is addressed at the `preprocessing` steps – that is, first the second-order polynomials are estimated on the features, and then the same `LinearRegression` model as above is applied. For instance, use a second- and third-order polynomials and compare the results (feel free to use higher order polynomials, though keep in mind that as the complexity of the model increases, so does the processing time, the number of weights to be learned, and the chance that the model *overfits* to the training data). For more information, refer to `sklearn` [documentation](http://scikit-learn.org/stable/auto_examples/linear_model/plot_polynomial_interpolation.html):

```python
from sklearn.preprocessing import PolynomialFeatures

model = Pipeline([('poly', PolynomialFeatures(degree=3)),
                  ('linear', LinearRegression())])

model = model.fit(housing_prepared, housing_labels)
housing_predictions = model.predict(housing_prepared)
lin_mse = mean_squared_error(housing_labels, housing_predictions)
lin_rmse = np.sqrt(lin_mse)
lin_rmse
```

How does the performance of the polynomial regression model compare to the first-order linear regression? You see that the performance improves as the complexity of the feature space increases. However, note that the more complex the model becomes, the more accurately it learns to replicate the training data, and the less likely it will generalise to the new pattern, i.e. in the test data. This phenomenon of learning to replicate the patterns from the training data too closely is called *overfitting*, and it is an opposite of *underfitting* when the model does not learn enough about the pattern from the training data due to its simplicity.

Just to give you a flavor of the problem, here is an example of a complex model from the `sklearn` suite called `DecisionTreeRegressor` (Decision Trees are outside of the scope of this course, so don't worry if this looks unfamiliar to you. `sklearn` has implementation for a wide range of ML algorithms, so do check the [documentation](http://scikit-learn.org/stable/auto_examples/tree/plot_tree_regression.html) if you want to learn more). Note that the `DecisionTreeRegressor` learns to predict the values in the training data perfectly well (resulting in the error of $0$!) which usually means that it won't work well on the new data – e.g., check this later on the test data:

```python
from sklearn.tree import DecisionTreeRegressor

tree_reg = DecisionTreeRegressor()
tree_reg = tree_reg.fit(housing_prepared, housing_labels)
housing_predictions = tree_reg.predict(housing_prepared)
tree_mse = mean_squared_error(housing_labels, housing_predictions)
tree_mse = np.sqrt(tree_mse)
tree_mse
```

### Learning to better evaluate you model using cross-validation

Obviously, one of the problems with overfitting above is caused by the fact that you're training and testing on the same (training) set (remember, that you should do all model tuning and optimisation on the training data, and only then apply the best model to the test data). So how can you measure the level of overfitting *before* you apply this model to the test data?

There are two possible solutions. You can either reapply `train_test_split` function from Step 2 to set aside part of the training set as a *development* (or *validation*) set, and then train the model on the smaller training set and tune it on the development set, before applying your best model to the test set. Or you can use *cross-validation*.

With *K-fold cross-validation* strategy, the training data gets randomly split into $k$ distinct subsets (*splits*). Then the model gets trained $10$ times, in each run being tested on a different fold and trained on the other $9$ folds. That way, the algorithm is evaluated on each data point in the training set, but during training is not exposed to the data points that it gets tested on later. The result is an array of $10$ evaluation scores, which can be averaged for better understanding and model comparison, i.e.:

```python
from sklearn.model_selection import cross_val_score

def analyse_cv(model):
    scores = cross_val_score(model, housing_prepared, housing_labels,
                             scoring = "neg_mean_squared_error", cv=10)

    # cross-validation expects utility function (greater is better)
    # rather than cost function (lower is better), so the scores returned
    # are negative as they are the opposite of MSE
    sqrt_scores = np.sqrt(-scores)
    print("Scores:", sqrt_scores)
    print("Mean:", sqrt_scores.mean())
    print("Standard deviation:", sqrt_scores.std())

analyse_cv(tree_reg)
```

This shows that the `DecisionTreeRegression` model does not actually perform well when tested on a set different from the one it was trained on. What about the other models? E.g.:

```python
analyse_cv(lin_reg)
```

Let's try one more model – [`RandomForestRegressor`](http://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestRegressor.html) that implements many Decision Trees (similar to above) on random subsets of the features. This type of models are called *ensemble learning* models and they are very powerful because they benefit from combining the decisions of multiple algorithms:

```python
from sklearn.ensemble import RandomForestRegressor

forest_reg = RandomForestRegressor()
analyse_cv(forest_reg)
```

### Fine-tuning the model

Some learning algorithms have *hyperparameters* – the parameters of the algorithms that should be set up prior to training and don't get changed during training. Such hyperparameters are usually specified for the `sklearn` algorithms in brackets, so you can always check the list of parameters specified in the documentation. For example, whether the [`LinearRegression`](http://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LinearRegression.html) model should calculate the intercept or not should be set prior to training and does not depend on the training itself, and so does the number of helper algorithms (decision trees) that should be combined in a [`RandomForestRegressor`](http://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestRegressor.html) for the final prediction. `RandomForestRegressor` has $16$ parameters, so if you want to find the *best* setting of the hyperparametes for `RandomForestRegressor`, it will take you a long time to try out all possible combinations.

The code below shows you how the best hyperparameter setting can be automatically found for an `sklearn` ML algorithm using a `GridSearch` functionality. Let's use the example of `RandomForestRegressor` and focus on specific hyperparameters: the number of helper algorithms (decision trees in the forest, or `n_estimators`) and the number of features the regressor considers in order to find the most informative subsets of instances to each of the helper algorithms (`max_features`):

```python
from sklearn.model_selection import GridSearchCV

# specify the range of hyperparameter values for the grid search to try out
param_grid = {'n_estimators': [3, 10, 30], 'max_features': [2, 4, 6, 8]}

forest_reg = RandomForestRegressor()
grid_search = GridSearchCV(forest_reg, param_grid, cv=5,
                          scoring="neg_mean_squared_error")
grid_search.fit(housing_prepared, housing_labels)

grid_search.best_params_
```

You can also monitor the intermediate results as shown below. Note also that if the best results are achieved with the maximum value for each of the parameters specified for exploration, you might want to keep experimenting with even higher values to see if the results improve any further:

```python
cv_results = grid_search.cv_results_
for mean_score, params in zip(cv_results["mean_test_score"], cv_results["params"]):
    print(np.sqrt(-mean_score), params)
```

One more insight you can gain from the best estimator is the importance of each feature (expressed in the weight the best estimator learned to assign to each of the features). Here is how you can do that:

```python
feature_importances = grid_search.best_estimator_.feature_importances_
feature_importances
```

If you also want to display the feature names, you can do that as follows:

```python
extra_attribs = ['rooms_per_household', 'bedrooms_per_household', 'population_per_household', 'bedrooms_per_rooms']
cat_one_hot_attribs = ['<1H OCEAN', 'INLAND', 'ISLAND', 'NEAR BAY', 'NEAR OCEAN']
attributes = num_attribs + extra_attribs + cat_one_hot_attribs
sorted(zip(feature_importances, attributes), reverse=True)
```

How do these compare with the insights you gained earlier (e.g., during data exploration in Step 1, or during attribute exporation in Step 3)?

### At last, evaluating your best model on the test set!

Finally, let's take the best model you built and tuned on the training set and apply in to the test set:

```python
final_model = grid_search.best_estimator_

X_test = strat_test_set.drop("median_house_value", axis=1)
y_test = strat_test_set["median_house_value"].copy()

X_test_prepared = full_pipeline.transform(X_test)
final_predictions = final_model.predict(X_test_prepared)

final_mse = mean_squared_error(y_test, final_predictions)
final_rmse = np.sqrt(final_mse)

final_rmse
```

# Assignments

**For the tick session**:

## 1.
Familiarise yourself with the code in this practical. During the tick session, be prepared to discuss the different steps and answer questions (as well as ask questions yourself).

## 2.
Experiment with the different steps in the ML pipeline:
- try dropping less informative features from the feature set and test whether it improves performance
- use other options in preprocessing: e.g., different imputer strategies, min-max rather than standardisation for scaling, feature scaling vs. no feature scaling, and compare the results
- evaluate the performance of the simple linear regression model on the test set. What is the `final_rmse` for this model?
- estimate different feature importance weights with the simple linear regression model (if unsure how to extract the feature weights, check [documentation](http://scikit-learn.org/stable/modules/linear_model.html)). How do these compare to the (1) feature importance weights with the best estimator, and (2) feature correlation scores with the target value from Step 3?
- [`RandomizedSearchCV`](http://scikit-learn.org/stable/modules/generated/sklearn.model_selection.RandomizedSearchCV.html), as opposed to the `GridSearchCV` used in the practical, does not try out each parameter values combination. Instead it only tries a fixed number of parameter settings sampled from the specified distributions. As a result, it allows you to try out a wider range of parameter values in a less expensive way than `GridSearchCV`. Apply `RandomizedSearchCV` and compare the best estimator results.

Finally, if you want to have more practice with regression tasks, you can **work on the following optional task**:

## 3. (Optional)

Use the bike sharing dataset (`./bike_sharing/bike_hour.csv`, check `./bike_sharing/Readme.txt` for the description), apply the ML steps and gain insights from the data. What data transformations should be applied? Which attributes are most predictive? What additional attributes can be introduced? Which regression model performs best?

```python

```


# Condições Gerais

Esta avaliação tem como objetivo avaliar os conhecimentos adquiridos durante a disciplina de Mecânica dos Sólidos.

Essa forma de avaliação tem por objetivo promover a discussão dos exercícios entre os membros do grupo (e eventualmente entre grupos) e ampliar a diversidade de exercícios a serem realizados.

---

As condicões abaixo devem ser observadas:

1. Serão formadas equipes e cada uma delas com no mínimo 3 e no máximo 4 integrantes.

2. A avaliação será realizada por meio da entrega de uma cópia deste notebook com as soluções desenvolvidas até a data estipulada de entrega.

3. Da entrega da avaliação.
  * Os documentos necessários para a entrega do trabalho são (1) os códigos  desenvolvidos pela equipe.
  * A equipe deve usar este modelo de notebook para desenvolver os códigos.
  * Os códigos podem ser desenvolvidos combinado a linguagem LaTeX e computação simbólica via python quando necessário.

4. Da distribuição das questões.
  * Serão atribuídas para cada grupo até 9 questões referentes ao capítulo 2 do
  livro texto.
  * A quantidade de questões será a mesma para cada grupo.
  * A distribuição das questões será aleatória.
  * A pontuacão referente a cada questão será igualitária e o valor total da avaliação será 100 pontos.

5. As equipes devem ser formadas até às **18 horas o dia 23/11/2021** por meio do preenchimento da planilha [[MAC005] Formação das Equipes](https://docs.google.com/spreadsheets/d/1j59WVAl1cMzXgupwG86WFNGAQhbtVtc0b5aIQSbqGQE/edit?usp=sharing).

6. A formação das equipes pode ser acompanhada arquivo [[MAC005] Formação das Equipes](https://docs.google.com/spreadsheets/d/1j59WVAl1cMzXgupwG86WFNGAQhbtVtc0b5aIQSbqGQE/edit?usp=sharing). Cada equipe será indentificada por uma letra em ordem alfabética seguida do número 1 (A1, B1, C1, e assim por diante). O arquivo está aberto para edição e pode ser alterado pelos alunos até a data estipulada.

7. Equipes formadas após a data estabelecida para a formação das equipes terão a nota da avaliação multiplicada por um coeficiente de **0.80**.

8. A equipe deve indicar no arquivo [[MAC005] Formação das Equipes](https://docs.google.com/spreadsheets/d/1j59WVAl1cMzXgupwG86WFNGAQhbtVtc0b5aIQSbqGQE/edit?usp=sharing) um responsável pela entrega do projeto.
  * Somente o responsável pela entrega deve fazer o upload do arquivo na plataforma

9. A entrega dos projetos deve ocorrer até às **23:59 do dia 30/11/2021** na plataforma da disciplina pelo responsável pela entrega.
  * Caso a entrega seja feita por outro integrante diferente daquele indicado pela pela equipe a avaliação será desconsiderada e não será corrigida até que a a condição de entrega seja satisfeita.

10. Quaisquer dúvidas ou esclarecimentos devem ser encaminhadas pela sala de aula virtual.

#Exercicios

2.3, 2.5, 2.7, 2.10, 2.19, 2.21, 2.25, 2.27, 2.46

[Link do Livro](http://fn.iust.ac.ir/files/fnst/ssadeghzadeh_52bb7/files/Introduction_to_continuum_mechanics_-Lai-2010-4edition%281%29.pdf)

## Solução do problema 2.3 (inserir número e enunciado)

###a) 1 - Montamos e resolvemos o sistema de equações para a primeira equação: $b_i = B_{ij} a_j$

$b_1 = B_{1j} a_j$

$b_2 = B_{2j} a_j$

$b_3 = B_{3j} a_j$

$b_1 = B_{11} a_1 + B_{12} a_2 + B_{13} a_3$

$b_2 = B_{21} a_1 + B_{22} a_2 + B_{23} a_3$

$b_3 = B_{31} a_1 + B_{32} a_2 + B_{33} a_3$

$b_1 = 2*1 + 3*0 + 0*2 = 2$

$b_2 = 0*1 + 5*0 + 1*2 = 2$

$b_3 = 0*1 + 2*0 + 1*2 = 2$

$
b =
\begin{bmatrix}
  b_1 \\
  b_2 \\
  b_3
\end{bmatrix}
=
\begin{bmatrix}
  2 \\
  2 \\
  2
\end{bmatrix}
$

2 - Multiplicamos as matrizes para a segunda equação: $[b] = [B][a]$

```python
import numpy as np
import sympy as sp
sp.init_printing()

B = np.matrix([[2,3,0],[0,5,1],[0,2,1]])
a = np.matrix([[1],[0],[2]])
b = sp.Matrix(B*a)

print("Dessa forma, as duas equações (do passo 1 e 2) são equivalentes")
b
```

    Dessa forma, as duas equações (do passo 1 e 2) são equivalentes

$\displaystyle \left[\begin{matrix}2\\2\\2\end{matrix}\right]$

###b) 1 - Montamos e resolvemos o sistema de equações para a primeira equação: $s = B_{ij} a_i a_j$

$s = B_{11} a_1 a_1 + B_{12} a_1 a_2 + B_{13} a_1 a_3 + B_{21} a_2 a_1 + B_{22} a_2 a_2 + B_{23} a_2 a_3 + B_{31} a_3 a_1 + B_{32} a_3 a_2 + B_{33} a_3 a_3 $

$s = 2*1*1 + 3*1*0 + 0*1*2 + 0*0*1* + 5*0*0 + 1*0*2 + 0*2*1 + 2*2*0 + 1*2*2 $

$s = 2 + 4 = 6$

2 - Multiplicamos as matrizes para a segunda equação: $s=[a]^t[B][a]$

```python
import numpy as np
import sympy as sp
sp.init_printing()

B = np.matrix([[2,3,0],[0,5,1],[0,2,1]])
a = np.matrix([[1],[0],[2]])
at = np.transpose(a)

#calculo da segunda equação
b = sp.Matrix(at*B*a)

print("Dessa forma, as duas equações (do passo 1 e 2) são equivalentes")
b
```

    Dessa forma, as duas equações (do passo 1 e 2) são equivalentes

$\displaystyle \left[\begin{matrix}6\end{matrix}\right]$

## Solução do problema 2.5 (inserir número e enunciado)

(a)

$s = A_1^{2} + A_2^{2} + A_3^{2}$ \\
$s = (A_1.A_1) + (A_2.A_2) + (A_3.A_3)$ \\
$s = A_iA_i$

(b)

$
\frac{\partialΦ}{\partial x_1^2} + \frac{\partialΦ}{\partial x_2^2} +
\frac{\partialΦ}{\partial x_3^2}$

$
\frac{\partialΦ}{\partial x_1 . x_1} + \frac{\partialΦ}{\partial x_2 . x_2} +
\frac{\partialΦ}{\partial x_3 . x_3} = $
$\frac{\partialΦ}{\partial x_i . x_i}$

## Solução do problema 2.7 (inserir número e enunciado)

### Para escrever $a_i$ em forma longa temos que expandir a equação $a_i = ∂v_i/∂t + v_j∂v_i/∂x_j$ para os indices $j = 1, j = 2, j = 3$:
$a_1 = ∂v_1/∂t + v_1∂v_1/∂x_1 + ∂v_1/∂t + v_2∂v_1/∂x_2 + ∂v_1/∂t + v_3∂v_1/∂x_3$
$a_2 = ∂v_2/∂t + v_1∂v_2/∂x_1 + ∂v_2/∂t + v_2∂v_2/∂x_2 + ∂v_2/∂t + v_3∂v_2/∂x_3$
$a_3 = ∂v_3/∂t + v_1∂v_3/∂x_1 + ∂v_3/∂t + v_2∂v_3/∂x_2 + ∂v_3/∂t + v_3∂v_3/∂x_3$

## Solução do problema 2.10 (inserir número e enunciado)

### 1 - Primeiro vamos encontrar a matriz $[d{i}]$, sabendo que $d_k = ε_{ijk} a_i b_j$:

$d_1 = ε_{ij1} a_i b_j$

$d_2 = ε_{ij2} a_i b_j$

$d_3 = ε_{ij1} a_i b_j$

$d_1 = ε_{231} a_2 b_3 + ε_{321} a_3 b_2$

$d_2 = ε_{312} a_3 b_1 + ε_{132} a_1 b_3$

$d_3 = ε_{123} a_1 b_2 + ε_{213} a_2 b_1$

### Sabemos que $ε_{ijk}$ é o simbolo de permutação é:
$ε_{123}$ = $ε_{231}$ = $ε_{312}$ = +1
$ε_{213}$ = $ε_{321}$ = $ε_{132}$ = -1
$ε_{111}$ = $ε_{222}$ = $ε_{333}$ = 0
 Então os valores de $d_1,d_2,d_3$ ficam :
$d_1 = a_2 b_3 - a_3 b_2 = (2)(3) - (0)(2) = 6$

$d_2 = a_3 b_1 - a_1 b_3 = (0)(0) - (1)(3) = -3$

$d_3 = a_1 b_2 - a_2 b_1 = (1)(2) - (2)(0) = 2$

### Portanto:
$
[d_i] =
\begin{bmatrix}
  d_1 \\
  d_2 \\
  d_3
\end{bmatrix}
=
\begin{bmatrix}
   6 \\
  -3 \\
   2
\end{bmatrix}
$

### 2 - Agora encontraremos a matriz $[d{i}]$, sabendo que $d_k = (a$ x $b). e_k$ :
 Sabemos que $a$ x $b$ = $(a_ie_i)$ x $(b_je_j) = a_ib_jε_{ijk}e_k$, então:
$d_1 = a_ib_jε_{ij1}e_1.e_1$

$d_2 = a_ib_jε_{ij2}e_2.e_2$

$d_3 = a_ib_jε_{ij3}e_3.e_3$

$d_1 = a_2b_3ε_{231}e_1.e_1 + a_3b_2ε_{321}e_1.e_1$

$d_2 = a_1b_3ε_{132}e_2.e_2 + a_3b_1ε_{312}e_2.e_2$

$d_3 = a_1b_2ε_{123}e_3.e_3 + a_2b_1ε_{213}e_3.e_3$

$d_1 = a_2b_3 - a_3b_2 = (2)(3) - (0)(2) = 6$

$d_2 = a_3b_1 - a_1b_3 = (0)(0) - (1)(3) = -3$

$d_3 = a_1 b_2 - a_2 b_1 = (1)(2) - (2)(0) = 2$

### Resultando em:
$
[d_i] =
\begin{bmatrix}
  d_1 \\
  d_2 \\
  d_3
\end{bmatrix}
=
\begin{bmatrix}
   6 \\
  -3 \\
   2
\end{bmatrix}
$

### Comparando as matrizes encontradas em 1 e 2 vemos que os resultados são iguais.

## Solução do problema 2.19 (inserir número e enunciado)

### Uma transformação T opera em qualquer vetor $a$ para dar $Ta = \frac{a}{|a|}$, onde $|a|$ é a magnitude de $a$. Mostre que T não é uma transformação linear.

Como $Ta = \frac{a}{|a|}$ para todos os valores de $a$, então podemos fazer:

$T(a + b) =  \frac{(a + b)}{|a+b|}$

Com isso, escrevemos:

$Ta + Tb = \frac{a}{|a|} + \frac{b}{|b|}$

Nesse momento, já podemos notar que $T(a + b) \neq Ta + Tb$

Ou seja, T não é uma transformação linear.

## Solução do problema 2.21 (inserir número e enunciado)

### Usar propriedade linear de $T$ para achar:
### A) $Ta$

Usando as informações passadas no enunciado, podemos fazer

$Ta = T(2e1 + 3e2)$

$T(2e1 + 3e2) = 2Te1 + 3Te2$

Como $Te1 = e1 + e2$ e $Te2 = e1 − e2$, então fazemos:

$2Te1 + 3Te2 = 2(e1 + e2) + 3(e1 − e2)$

$2(e1 + e2) + 3(e1 − e2) = 2e1 + 2e2 + 3e1 - 3e2$

O que nos dá $5e1 -e2$. Ou seja, $Ta = 5e1 -e2$.

### B) $Tb$

$Tb = T(3e1 + 2e2)$

$T(3e1 + 2e2) = 3Te1 + 2Te2$

Como $Te1 = e1 + e2$ e $Te2 = e1 − e2$, então fazemos:

$3Te1 + 2Te2 = 3(e1 + e2) + 2(e1 − e2)$

$3(e1 + e2) + 2(e1 − e2) = 3e1 + 3e2 + 2e1 - 2e2$

O que nos dá $5e1 + e2$. Ou seja, $Tb = 5e1 + e2$.

### B) $T(a+b)$

$T(a+b) = T(2e1 + 3e2 + 3e1 + 2e2)$

$T(a+b) = T(5e1 + 5e2)$

Como $T(5e1 + 5e2) = 5(Te1 + Te2)$, então $5(Te1 + Te2) = 5(e1 + e2 + e1 − e2)$

Dessa forma, temos $5(e1 + e2 + e1 − e2) = 5e1 + 5e1 + 5e2 - 5e2$

Portanto, $T(a+b) = 10e1$

## Solução do problema 2.25 (inserir número e enunciado)

(a)
$ e_i' = Re_i = R_{mi}e_m\\
 e_1' = R_{11}e_1 +  R_{21}e_2 +  R_{31}e_3 \\
 e_2' = R_{12}e_1 +  R_{22}e_2 +  R_{32}e_3 \\
 e_3' = R_{13}e_1 +  R_{23}e_2 +  R_{33}e_3 \\
 \text{Dessa forma:}   \\
 R_{im}R_{jm} = R_{mi}R_{mj} = \delta_{ij} \\
 R_{11} = e_1.Re_1 = e_1 . e_1' = cos(e_1,e_1') \\
 R_{12} = e_1.Re_2 = e_1 . e_2' = cos(e_1,e_2') \\
 R_{13} = e_1.Re_3 = e_1 . e_3' = cos(e_1,e_3')
  \text{, logo:}   \\
  R_{ij} = cos(e_i,e_j') \\
\text{De acordo com a demonstração acima:} \\
R =
\begin{bmatrix}
R_{11} & R_{12} & R_{13}\\
 R_{21} & R_{22} & R_{23}
 \\ R_{31} & R_{32} & R_{33}
\end{bmatrix} \text{, será:} \\
R =
\begin{bmatrix}
1 & 0 & 0\\
0 & cos\theta   & sen\theta
 \\ 0 & -sen\theta & cos\theta
\end{bmatrix}
$

(b)

$
\text{Analogamente à letra "a":} \\
R =
\begin{bmatrix}
cos\theta & 0 & -sen\theta\\
0 & 1   & 0
 \\ sen\theta & 0 & cos\theta
\end{bmatrix}
$

## Solução do problema 2.27 (inserir número e enunciado)

### Pelo produto diádico temos que :
$Tr = r - 2(r.n)n = r - 2(nn)r$
### Multiplicando pela matriz Identidade $I$ :
$Tr = (Ir - 2(nn)r) = (I - 2nn)r$

$T = I - 2nn$

### Agora vamos encontrar a matriz $T$ :
 Como foi dito no enunciado :
$n = (e_1 + e_2 + e_3)/√3$
###Então :
$
[2nn] = 2/3
\begin{bmatrix}
  1 \\
  1 \\
  1
\end{bmatrix}
\begin{bmatrix}
   1 & 1 & 1\\
\end{bmatrix}
= 2/3
\begin{bmatrix}
  1 & 1 & 1 \\
  1 & 1 & 1 \\
  1 & 1 & 1
\end{bmatrix}
$

###Substituindo na equação $T = I - 2nn$ :
$
[T] = [I] - 2/3
\begin{bmatrix}
  1 & 1 & 1 \\
  1 & 1 & 1 \\
  1 & 1 & 1
\end{bmatrix}
$

$
[T] =
\begin{bmatrix}
  1 & 0 & 0 \\
  0 & 1 & 0 \\
  0 & 0 & 1
\end{bmatrix} -
\begin{bmatrix}
  2/3 & 2/3 & 2/3 \\
  2/3 & 2/3 & 2/3 \\
  2/3 & 2/3 & 2/3
\end{bmatrix}
$

$
[T] =
\begin{bmatrix}
  1/3 & -2/3 & -2/3 \\
  -2/3 & 1/3 & -2/3 \\
  -2/3 & -2/3 & 1/3
\end{bmatrix} = 1/3
\begin{bmatrix}
  1 & -2 & -2 \\
  -2 & 1 & -2 \\
  -2 & -2 & 1
\end{bmatrix}
$

## Solução do problema 2.46 (inserir número e enunciado)

###a) Dado qualquer vetor $a$ e qualquer tensor $T$, mostrar que $a T^A a = 0$, onde $T^A$ e $T^S$ são simétricos e antisimétricos por parte de T.

R: Dado que $T^A$ é antissimétrico, então $(T^A)^T = -T^A$. Dessa forma, podemos calcular:

$aT^Aa = a(T^A)^Ta$

$aT^Aa = -aT^Aa$

$2aT^Aa = 0$

$aT^Aa = 0$

###b) Dado qualquer vetor $a$ e qualquer tensor $T$, mostrar que $a T a = a T^S a$, onde $T^A$ e $T^S$ são simétricos e antisimétricos por parte de T.

R: Dado que qualquer Tensor $T$ pode ser decomposto na soma de um tensor simétrico $T^S$ e um antissimétrico $T^A$. Temos: $T = T^S + T^A$

Sendo assim, podemos calcular:

$a T a = a(T^S + T^A)a$

$a T a = (a T^S + a T^A) a$

$a T a = a T^S a + a T^A a$

Substituindo o valor encontrado na alternativa a ($aT^Aa = 0$), temos:

$a T a = a T^S a + 0$

$a T a = a T^S a$


```python
%matplotlib notebook
%matplotlib inline
import math
import matplotlib.pyplot as plt
import numpy as np
```

# Nuclear Models

## Learning objectives

- Summarize the history of atomic theory development.
- Recognize the radiation signatures that drove early atomic theory.
- List atomic models: Plum Pudding, Rutherford, Bohr, Bohr with Elliptical Orbits, Quantum Mechanical
- Differentiate various atomic models by name and physics.
- List nuclear models: Proton-electron, Proton-neutron, Liquid-Drop, Shell
- Differentiate nuclear models by name and physics.
- Identify the physics captured by various nuclear models.
- Explain the reason for the structure of most likely decays in the chart of the nuclides

## Discovery of Radioactivity

Electrically charged plates impose a magnetic field (out of the page).

21.3 Radioactive Decay by Rice University is licensed under a Creative Commons Attribution 4.0 International License, except where otherwise noted. (https://opentextbc.ca/chemistry/chapter/21-3-radioactive-decay/)

- Alpha $(\alpha)$ particles are attracted to the negative plate and deflected by a relatively small amount.
- Beta $(\beta)$ particles are attracted to the positive plate and deflected by a larger amount.
- Gamma $(\gamma)$ particles seem to be unaffected.

### Exercise: Think-pair-share

Based on these experimental results:

1. What can one determine about the charges of the alpha, beta, and gamma particles?
2. What can one determine about the weights of the particles?

### Plum Pudding Model

J.J. Thomson correctly identified the beta rays as electrons.
So, electrons must be somewhere in the atom. However, atoms seemed be generally electrically neutral.
Being british, J.J. Thomson equated this to plum pudding:

He had no reason to believe that the charges were not uniformly distributed.

Explains:

- electrically neutral atoms
- size of the atom $(10^{-10} m)$
- an ion is an atom from which an electron has been lost
- charge of a singly ionized atom is exactly one
- number of electrons equals approximately half of the atomic weight of the atom

### Rutherford Model

Gold foil experiment by Geiger and Marsden. Alpha particles bombarded a very thin gold sheet/foil. Reflected alphas were very unlikely to be observed if the Thomson model was correct.

In **1911** Rutherford surmised that a dense, positively charged thing must be at the center of the atom, perhaps with a diameter of $10^{-14}m$ or less.

Deficiency of Rutherford’s model:
The accelerating charge (electron) would radiate away their kinetic energy and the atom will collapse.

### Bohr Model

In the 1880s, Balmer, Rydberg, and others had observed **discrete lines** in the wavelength spectrum of atomic hydrogen when atoms are excited.

The equation explaining these emission lines was well known by the early 1900s.

\begin{align}
\frac{1}{\lambda} &= R_H \left[\frac{1}{n_o^2} - \frac{1}{n^2} \right]\\
\mbox{where  }&\\
\lambda &= \mbox{the wavelength of electromagnetic radiation emitted in vacuum}\\
R_H &= \mbox{the Rydberg constant } \\
&\simeq  1.097373156850865 \times 10^7 m^{-1} \\
\implies \frac{1}{R} &=912 \mbox{ angstrom}\\
\end{align}

In the Bohr model, then, electrons were allowed only in certain orbits. Specifically only those orbits whose angular momentum satisfied the following relation:

\begin{align}
\frac{m_ev^2}{r} &= \frac{Ze^2}{4\pi\epsilon_or^2}\\
L &\equiv m_evr = n\frac{h}{2\pi}\\
\mbox{where }&\\
n&=1,2,3...\\
L&=\mbox{angular momentum}\\
v&=\mbox{velocity of the electron}\\
r&=\mbox{radius of the electron orbit}\\
\epsilon_o&=\mbox{permittivity of free space}\\
&= 8.85418782 \times 10^{-12} m^{-3} kg^{-1} s^4 A^2
\end{align}

Solving those relations for $r$ and $v$ :

\begin{align}
v_n &= \frac{Ze^2}{2\epsilon_onh}\\
r_n &= \frac{n^2h^2\epsilon_o}{\pi m_e Z e^2}
\end{align}

This explained the discreteness, as electrons might radiate energy only when moving from one allowed orbit to another.

Electron’s total energy under a certain orbit:

\begin{align}
E_n &= \frac{-m_e(Ze^2)^2}{8\epsilon_o^2 n^2 h^2}\\
\end{align}

Energy difference between two allowed orbits:

\begin{align}
\Delta E_{n\rightarrow n_o} &= h\nu_{n\rightarrow n_o} \\
 &=  \frac{-m_e(Ze^2)^2}{8\epsilon_o^2 h^2}\left[\frac{1}{n_o^2} - \frac{1}{n^2}\right]\\
\end{align}

This model explained a lot.

### Bohr Model : Elliptic Orbits

Fine structure: Spectral lines are consisted of a number of lines very close together.

- Except the quantum number $n$, there are some energy levels lying close to one another.
- Sommerfeld postulated elliptic orbits as well as circular orbits and introduced another quantum number to describe the **angular momentum** of orbits.

However, Sommerfeld’s theory predicted more lines than were observed in experiments.

Thus, a new quantum number  $n$  needed an _ad hoc_ selection rule to limit the number of predicted lines.

Splitting of the spectral lines was observed: A third quantum number $m$ needed to be introduced to revise Bohr’s model. also predicted more lines

The theory failed to applied to more complicated atoms (multiplet structure is observed).

Further change of Bohr’s theory can no longer resolve the difficulties.

### Quantum Mechanical Model

The electrons are no longer modeled as particles moving in orbits. Instead, they are modeled as a standing wave around the nucleus. The magnitude of that wave reflects the probability of finding the electron in that locations.

- Schrodinger’s new approach:  **the wave function**
- The electrons are no longer point particles; they are visualized as a standing wave around the nucleus.

The three quantum numbers ($n$, $l$ , $m$) arose from the theory naturally - no ad hoc selection rules were needed.

A fourth quantum number $m_s$ was introduced to explain:
- Multiple fine-line structure
- Splitting of lines in a strong magnetic field ( the **anomalous Zeeman effect**)

The $m_s$ quantum number accounted for the inherent angular momentum of the electron equal to $\pm\frac{h}{2\pi}. Later, in 1928, Dirac would show that this fourth quantum number also arises from the wave equation.

## Nuclear Models

### Fundamental properties of the nucleus

Some simple facts were determined about the mass of the nucleus.

1. The **masses of atoms** were very nearly whole numbers if one defines the atomic mass unit as:
\begin{align}
1 u = \frac{1}{12}M(^{12}C)
\end{align}
2. The **mass contribution from electrons** is quite **small**
  - In $^{12}C$, the six electrons together only weigh $0.00329u$

Also, electron scattering experiments yielded details about the density of the nucleus and its components.
**The density of protons** inside the spherical nuclei was well understood to be a function of the radius, dropping off appreciably at the boundary of the nucleus.

\begin{align}
\rho_p(r) &= \frac{\rho_p^o}{1+e^{\frac{(r-R)}{a}}} \left[\frac{\mbox{protons}}{fm^3}\right]\\
\mbox{where }&\\
r &= \mbox{distance to center of nucleus}\\
R &= \mbox{total 'radius' of the nucleus}\\
a &= \mbox{surface thickness}\\
\rho_p^o &= \int\int\int \rho_p(r)dV\\
&= 4\pi\int_0^\infty r^2\rho_p(r)dr\\
&= Z
\end{align}

Also, the R value, the radius of the nucleus was seen empirically to be proportional to $A^{1/3}$.

```python
def rho_p(r, rho_po, radius, a):
    denom = 1 + math.exp((r - radius)/a)
    return rho_po/denom
```

```python
# For 16O (from table 3.2)
o_rho_po = 0.156 # fm^{-3}
o_radius = 2.61 # fm
o_a = 0.513

# For 109Ag (from table 3.2)
ag_rho_po = 0.157 # fm^{-3}
ag_radius = 5.33 # fm
ag_a = 0.523

# For 208Pb (from table 3.2)
pb_rho_po = 0.159 # fm^{-3}
pb_radius = 6.65 # fm
pb_a = 0.526

r = np.arange(0, 10, 0.1)
to_plot_o = np.arange(0., 100.)
to_plot_ag = np.arange(0., 100.)
to_plot_pb = np.arange(0., 100.)

for i in range(0, 100):
    to_plot_o[i]  = rho_p(r[i], o_rho_po, o_radius, o_a)
    to_plot_ag[i] = rho_p(r[i], ag_rho_po, ag_radius, ag_a)
    to_plot_pb[i] = rho_p(r[i], pb_rho_po, pb_radius, pb_a)

plt.plot(r, to_plot_o, label="$^{16}O$")
plt.plot(r, to_plot_ag, label="$^{109}Ag$")
plt.plot(r, to_plot_pb, label="$^{208}Pb$")
plt.ylabel("Proton density ($fm^{-3}$)")
plt.xlabel("distance from center $(fm)$")
plt.legend()
```

### Proton Electron Model

This model sought primarily to explain the wholeness of mass numbers. To do so, it simply assumed that all heavier nuclei were composed of multiples of the hydrogen nucleus (assumed to be a single proton), which has the smallest mass(1 amu).

For this to match what was known about atomic charge, electrons would need to be in the nucleus to cancel some, but not all, of the positive charge from the protons.

**In this model** an atom $^A_ZX$ would have a nucleus containing A protons and (A − Z) electrons with Z electrons surrounding the nucleus. This postulated extra electrons -- to explain it, the mass of the electrons was assumed to make a negligible contribution.

Two difficulties with this P-E Model:

1. Predicted angular momentum (spin) of the nuclei did not always agree with experiment.
   **Protons and electrons both have half integer spin**, so when an even number are combined, whole integer spin should result. For example, **the model predicted integer spin for Beryllium, while experiments predicted half-integer spin.** Similarly, **the model predicted half-integer spin in nitrogen but experimental results show nitrogen has integer spin.**

2. Uncertainty Principle

\begin{align}
\Delta p \Delta x &\ge \frac{h}{4\pi}
\end{align}

If an electron is in the nucleus, then $\Delta x\simeq10^{-14}m$. Accordingly:
\begin{align}
min(\Delta_p) = 1.1\times 10^{-20}J m^{-1}s
\end{align}
Since the electron’s total energy is
\begin{align}
E &= T +m_oc^2 \\
&=	\sqrt{p^2c^2 +m^2_oc^4}\\
\implies &\\
&\forall p = \Delta p : E \simeq T = 20 MeV\\
\end{align}

Since the electron's rest-mass energy is 0.51 MeV and beta particles emitted by atoms seldom have energies above a few MeV, something was wrong with this calculation.

You can do the same calculation for the proton. Because it has a much higher mass, there is no discrepancy. The energy of a free proton confined to a nucleus is its rest-mass energy (931 MeV), with $T<1MeV$.

### 1932: Chadwick discovers the neutron

Chadwick discovers the existence of a chargeless particle with a mass just slightly greater than that of the proton.

\begin{align}
m_n &= 1.008665 u\\
m_p &= 1.007276 u
\end{align}

### Proton-Neutron Model

- **1932** Chadwick discovered the neutron.
- **1932** Heisenberg first suggested every nucleus is composed of only protons and neutrons.

**In this model**, a nucleus with a mass number A contains Z protons and N = A − Z neutrons.

- This P-N Model avoids the failures of the P-E model.
- Also is consistent with experimental results regarding radioactivity
- Since the neutron has half-integral spin, the A neutrons and protons give appropriate spin for the total atom in all cases.

Challenges for this model:

- Because of Coulombic repulsive forces in the nucleus, a 'nuclear force' must hold the nucleus together
- To hold the protons and neutrons together:
      nuclear force : p-n , n-n , p-p
      1.  inside the nucleus: nuclear force (attractive)
      2. outside the nucleus: Coulombic force (repulsive)

The energy needed to separate nucleus into $p_s$ & $n_s$ : binding energy

In the image below, the nuclear potential well is shown.

**The above image was reproduced from Shultis, J. K. and Faw, R. E. “Fundamentals of Nuclear Science and Engineering” (2016).**

## Nuclear Stability

**Figure** Graph of isotopes by type of nuclear decay. Orange and blue nuclides are unstable, with the black squares between these regions representing stable nuclides. The unbroken line passing below many of the nuclides represents the theoretical position on the graph of nuclides for which proton number is the same as neutron number. The graph shows that elements with more than 20 protons must have more neutrons than protons, in order to be stable.

**The above image was reproduced from Shultis, J. K. and Faw, R. E. “Fundamentals of Nuclear Science and Engineering” (2016).**

- many more stable isotopes with even N and/or Z
- in a heavy nucleus, the neutrons and protons tend to group themselves into subunits of 2 neutrons and 2 protons.
- when either Z or N equals 8, 20, 50, 82, or 126, there are relatively greater numbers of stable nuclides.

### Liquid Drop Model

1. volume term: volume binding energy is proportional to the number of nucleons (i.e. # of nucleons.)

2. Surface term: Nucleons near the surface of the nucleus are not completely surrounded by other nucleons as interior nucleons.

3. Coulomb term: Coulombic repulsive force decrease the stability of nucleons (reduces the BE ).

4. asymmetry term: a departure from symmetry (N=Z) tends to reduce nuclear stability. (Fig.3.10 )

5. pairing term : pairing neutrons and protons are more stable. (even N or Z; odd N or Z ; even N (Z), odd Z (N)) (Fig.3.11 & Fig.3.12)

Higher binding energy $\implies$ a more stable nuclide.

### Shell Model

The liquid drop model cannot explain the abnormal high stable nuclides(magic numbers:2,8,20,28,50,82,126).
The shell model assume: (Shrodinger’s wave eq.)
Each nucleon moves independently.
Each nucleon moves in a potential well.

When the model’s quantum-mechanical wave eq. is solved, the nucleons are found to distribute themselves into a number of energy levels.
Filled shells are indicated by large gaps between each adjacent energy level.

```python

```

```python

```