# TOC

__Lab 04 - Linear regression & feature selection__
1. [Import](#Import)
1. [Linear regression basics](#Linear-regression-basics)
    1. [Example 1 - simple linear regression](#Linear-regression-basics-Example1)
1. [Linear regression w/ gradient descent](#Linear-regression-w/-gradient-descent)
    1. [Example 1 - full gradient descent](#Linear-regression-w/-gradient-descent-Example1)
    1. [Example 2 - stochastic / batch gradient descent](#Linear-regression-w/-gradient-descent-Example2)
1. [Feature selection](#Feature-selection)
    1. [Example 1 - SelectKBest for linear regression - compact](#Feature-selection-Example1)
    1. [Example 2 - SelectKBest for linear regression - detailed](#Feature-selection-Example2)
    1. [Example 3 - feature selection step within pipeline](#Feature-selection-Example3)
1. [Handling missing data](#Handling-missing-data)
    1. [Example 1 - null observations and features](#Handling-missing-data-Example1)
1. [Handling object features](#Handling-object-features)
    1. [Example 1 - mapping strategies](#Handling-object-features-Example1)
1. [Standardizing data: bring features onto same scale](#Standardizing-data:-bring-features-onto-same-scale)
    1. [Example 1 - standardization strategies](#Standardizing-data:-bring-features-onto-same-scale-Example1)

# Import

```python
import numpy as np

np.set_printoptions(threshold=np.inf, suppress=True)
import pandas as pd
import matplotlib.pyplot as plt

%matplotlib inline
import seaborn as sns
import warnings

warnings.simplefilter("ignore")
from IPython.core.display import display, HTML

display(HTML("<style>.container { width:78% !important; }</style>"))

import sys
import csv

from sklearn.linear_model import LinearRegression
from sklearn.datasets import load_boston
from sklearn.feature_selection import SelectKBest, f_regression
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import (
    StandardScaler,
    LabelEncoder,
    OneHotEncoder,
    MinMaxScaler,
    Imputer,
)
from sklearn.metrics import accuracy_score, mean_squared_error

from mpl_toolkits.mplot3d import Axes3D
from matplotlib import cm
from matplotlib import colors

%matplotlib inline
%reload_ext autoreload
%autoreload 2
```

# Linear regression basics

Linear regression seeks to fit a line (in 2 dimensions), a plane (in 3 dimensions) or a hyperplane (in 4+ dimensions) to a dataset. The line is fit to a set of $m$ total observations. Each observation has $n$ total features, or dimensions, that describe the observation's characteristics. Typically, the observations are represented as rows in a datset and the features are represented as columns and are captured in an $m \space x \space n$ matrix $X$.

All feature values of a single observation are captured in the vector $\textbf{x}_i$, and each $j$ feature is paired with a weight $w_j$. The full set of weights is contained in a weight vector $\textbf{w}$. The feature values for each observation are combined with their respective weights to produce a prediction for that observation's outcome. The equation for determining the prediction of a single outcome is:

$$
\hat{y}_i = \textbf{w}^T\textbf{x}_i + b
\\
\textrm{where } b \textrm{ is the bias term}
$$

The error, or residual, of each prediction is the different between the predicted outcome $\hat{y}_i$ and the actual outcome $y_i$. The line that fits best is the line that minimizes the cumulative error across all predictions. This overall error is captured by a loss (or objective) function, and the goal is to optimize the equation by finding the parameters which minimize the total loss. A typical method for calculating loss is mean squared error (MSE):

$$
\begin{equation}
\frac{1}{m}\sum_{i=1}^{m}\left[ (\mathbf{w}^T\mathbf{x}_i + b) - y_i\right]^2,\\
\textrm{where } m \textrm{ is the # of observations}
\end{equation}
$$

The bias term $b$ can be removed from the equation:

$$
\begin{equation}\tag{1.3}
\frac{1}{m}\sum_{i=1}^{m}\left[ \mathbf{w}^T\mathbf{x}_i - y_i\right]^2
\end{equation}
$$

by 'augmenting' the weight vector and observation feature vectors. To augment the weight vector, the bias unit is inserted into the weight vector $\textbf{w}$ at $w_1$, and observation each sample vector has a 1 placed at $x_{i1}$:

$$
\mathbf{x_i} =
\begin{bmatrix}
\mathbf{1}\\
\mathbf{x_{i2}}\\
\mathbf{...}\\
\mathbf{x_{in+1}}\\
\end{bmatrix},\quad
\boldsymbol{\mathbf{w}} =
\begin{bmatrix}
\mathbf{b}\\
\mathbf{w_2}\\
\mathbf{...}\\
\mathbf{w_{n+1}}\\
\end{bmatrix}
$$

## Example 1 - simple linear regression

- Calculate ordinary least squares loss on small, made up dataset
- Visualize the line created by the model

__Custom functions__

```python
# custom functions
def plot_2d_models(data, models=[], names=[], title=None):
    fig, ax = plt.subplots()

    ax.plot(data[:, 0], data[:, 1], "o")
    domain = [min(data[:, 0]), max(data[:, 0])]

    for W, label in zip(models, names):
        b, m = W[0], W[1]
        yvals = [m * x + b for x in domain]
        ax.plot(domain, yvals, linewidth=1, label=label)
    if models:
        plt.legend(bbox_to_anchor=(1.05, 1.00), loc=2, borderaxespad=0.0)
    if title:
        plt.title(title)

def augment(X):
    return np.insert(arr=X, obj=0, values=1.0, axis=1)

def ols_loss(X, y, model):
    N = len(X)
    W = np.array(model)
    return 1 / float(N) * sum((W.dot(X.T) - y) ** 2)
```

```python
# load and inspect data
def getData():
    dfData = {"x": [1, 3, 5, 4, 2], "y": [2, 4, 5, 3, 3]}
    return pd.DataFrame.from_dict(dfData).values

points = getData()
```

```python
# review observations
X = augment(X=points)[:, :-1]
X
```

    array([[1, 1],
           [1, 3],
           [1, 5],
           [1, 4],
           [1, 2]])

```python
# review labels
y = points[:, -1]
y
```

    array([2, 4, 5, 3, 3])

```python
# calculate loss
print("Loss: {0}".format(ols_loss(X, y, model=[0, 1])))
```

    Loss: 0.8

```python
# visualize line fit
plot_2d_models(data=points, models=[[0, 1]], names=["model"], title="small example")
```

# Linear regression w/ gradient descent

When determing the best model, it is helpful to understand that the data in $X$ is static, and we are observing how the loss of a model varies as we change the weight in $\theta$.

Calculus tells us that the minimums and maximums (when they exists) occur where our function's first derivative = 0. This rule can be extended in higher dimensions by considering a function's gradient vector, which is comprised of partial derivatives. Again, we want all partial derivatives in the gradient to = 0.

A first derivative of 0 tells us that we've reached a minimum or maximum, and the second derivative tells us which one. If the sign of the second derivative is  positive, this indicates that the function is curving upwards and we've reached a minimum. If the sign of the second derivative is negative, we've reached a maximum.

## Example 1 - full gradient descent

To start this process, let's recall our mean squared error (MSE) loss function from above:

$$
\begin{equation}
LOSS_{MSE}(\boldsymbol{\theta}) = \frac{1}{m}\sum_{i=1}^{m}\left[ \boldsymbol{\theta}\cdot\mathbf{x}'_i - y_i\right]^2
\\
\textrm{where } \theta \textrm{ is equivalent to the } \textbf{w}^T \textrm{ term used previously}
\end{equation}
$$

The first derivative of our loss function gives us our gradient:

$$
\begin{equation}
\nabla_{ LOSS_{MSE}}(\boldsymbol{\theta}) = \frac{2}{m}\,\sum_{i=1}^{m}\left[ \boldsymbol{\theta}\cdot\mathbf{x}'_i - y_i\right] \cdot \mathbf{x}'
\end{equation}
$$

With gradient descent, we don't need to know the entire surface to determine the optiminum. A way to consider gradient descent is to think about the process of walking down a hill blindfolded. We start at a random location on the error surface, and feel our way down the hill in small steps. We assess the ground around our immediate location and walk in the direction of steepest descent, reassessing along the way. We know we have reached the bottom of the hill (or minimum loss) once there we find a location where the surface around us only slopes upwards.

In the function, ther size of our steps is controlled by a hyperparamter called the learning rate. This process can be summarized in three steps:

1. Initialize our weight vector $\boldsymbol{\theta}$ to a random set of values.
2. Compute the gradient $\nabla_{\theta}f(\boldsymbol{\theta})$
3. Update the parameters: $\theta_{new} = \theta_{old} - \eta \cdot \nabla_{\theta}f(\boldsymbol{\theta})$

__Custom functions__

```python
# custom functions
def ols_gradient(X, y, model):
    """
    the dot product of the weights and X yields a prediction vector of len(X).
    find the error between the prediction and the actual outcome, a vector of len(X).
    the dot product of errors and the features points toward steepest descent.
    multiply this by 2 divided by the sample size for the update.
    """
    M = len(X)
    W = np.array(model)
    return 2.0 / M * (W.dot(X.T) - y).dot(X)

def gradient_descent_update(X, y, n_iter, init_model, eta, verbose=False):
    models = [init_model]
    loss = [ols_loss(X, y, init_model)]

    for idx in range(n_iter):
        gradient = ols_gradient(X, y, models[-1])
        update = eta * gradient
        newModel = models[-1] - update
        if verbose:
            print(
                "\nModel {0}: [{1}, {2}]".format(
                    idx, round(models[-1][0], 2), round(models[-1][1], 2)
                )
            )
            print("Loss: {0}".format(loss[-1]))
            print("     >>> gradient: {0}".format(gradient))
            print("     >>> update: {0}".format(update))
        models.append(newModel)
        loss.append(ols_loss(X, y, newModel))
    if verbose:
        print(
            "\nModel {0}: [{1}, {2}]".format(
                idx, round(models[-1][0], 2), round(models[-1][1], 2)
            )
        )
        print("Loss: {0}".format(loss[-1]))
    return np.array(models), loss

def plot_gradient_descent_progress(data, models, loss, lossFunc=ols_loss, show_contours=True):
    # Create figure w/ two subplots
    fig = plt.figure(figsize=plt.figaspect(0.35))
    ax1 = plt.subplot(1, 2, 1)
    ax1.grid(color="grey", linestyle="-", linewidth=0.5, alpha=0.5)
    ax2 = plt.subplot(1, 2, 2)
    ax2.grid(color="grey", linestyle="-", linewidth=0.5, alpha=0.5)
    fig.subplots_adjust(wspace=0.6)

    ##### Problem Domain Space #####

    # plot data
    ax1.plot(data[:, 0], data[:, 1], "o")
    ax1.set_title("Problem Domain Space", fontsize=18)
    ax1.set_xlabel("Input Data")
    ax1.set_ylabel("Output Value")
    domain = [min(data[:, 0]), max(data[:, 0])]

    # plot models
    for idx, W in enumerate(models):
        b, m = W[0], W[1]
        yvals = [m * x + b for x in domain]
        name = "m{0}: {1}".format(idx, round(loss[idx], 2))
        ax1.plot(domain, yvals, label=name, linewidth=1)
    ax1.legend(bbox_to_anchor=(1.05, 1), loc=2, borderaxespad=0.0)

    ##### Model Parameter Space #####

    # plot loss for our models
    w1, w0 = models.T
    ax2.plot(w0, w1)
    ax2.plot(w0, w1, "s")
    ax2.set_title("Model Parameter Space", fontsize=18)
    ax2.set_xlabel("W_0 (slope)")
    ax2.set_ylabel("W_1 (intercept)")

    # plot contour lines
    if show_contours:
        # grid parameters -- just a bit larger than models
        w0_min, w0_max = min(w0) * 0.9, max(w0) * 1.1
        w1_min, w1_max = min(w1) * 0.9, max(w1) * 1.2
        w0_step = (w0_max - w0_min) / 20
        w1_step = (w1_max - w1_min) / 20
        # create loss grid for contour plot
        grid_w0, grid_w1 = np.mgrid[w0_min:w0_max:w0_step, w1_min:w1_max:w1_step]
        grid_loss = [
            lossFunc(augment(data)[:, :2], data[:, 1], model)
            for model in zip(grid_w0.flatten(), grid_w1.flatten())
        ]
        grid_loss = np.array(grid_loss).reshape(20, 20)
        # plot loss contours
        topo_levels = np.logspace(
            min(np.log(min(loss)), 0.1), min(np.log(max(loss)) / 10, 20)
        )
        CS = ax2.contour(
            grid_w0,
            grid_w1,
            grid_loss,
            levels=topo_levels,
            cmap="rainbow",
            linewidths=2.0,
            alpha=0.35,
        )
```

```python
# load  data
points = getData()
X = augment(points)[:, :-1]
y = points[:, -1]
```

```python
# review observations
X
```

    array([[1, 1],
           [1, 3],
           [1, 5],
           [1, 4],
           [1, 2]])

```python
# review labels
y
```

    array([2, 4, 5, 3, 3])

```python
# execute full gradient descent
n_steps = 5
eta = 0.01
orig_model = [0, 1]

models, loss = gradient_descent_update(X, y, n_steps, orig_model, eta, verbose=True)

print("Searched {0} models...".format(len(models)))
print(
    "Best model: {0}, Loss of best model: {1}".format(
        models[np.argmin(loss)], loss[np.argmin(loss)]
    )
)
```

    Model 0: [0, 1]
    Loss: 0.8
         >>> gradient: [-0.8 -0.8]
         >>> update: [-0.008 -0.008]

    Model 1: [0.01, 1.01]
    Loss: 0.788352
         >>> gradient: [-0.736 -0.576]
         >>> update: [-0.00736 -0.00576]

    Model 2: [0.02, 1.01]
    Loss: 0.7802907648000001
         >>> gradient: [-0.68672 -0.40512]
         >>> update: [-0.0068672 -0.0040512]

    Model 3: [0.02, 1.02]
    Loss: 0.7743283143475198
         >>> gradient: [-0.6486784 -0.2747904]
         >>> update: [-0.00648678 -0.0027479 ]

    Model 4: [0.03, 1.02]
    Loss: 0.7695974695071257
         >>> gradient: [-0.61921741 -0.17541581]
         >>> update: [-0.00619217 -0.00175416]

    Model 4: [0.03, 1.02]
    Loss: 0.7655928235757341
    Searched 6 models...
    Best model: [0.03490616 1.02231326], Loss of best model: 0.7655928235757341

```python
# visualize optimization
show_contours = False
plot_gradient_descent_progress(points, models, loss, show_contours=show_contours)
```

## Example 2 - stochastic / batch gradient descent

Full gradient descent completes its step only after calculating then gradient of the entiren data set. This is a high-precision approach, but can be resource intensive. An alternative is stochastic descent which uses a subset of the features. This subset can be as small as size 1.

$$
\begin{equation}
\nabla f(\boldsymbol{\theta}) \approx \nabla_{\text{batch}\,\,} f(\boldsymbol{\theta}) = \frac{2}{m}\sum_{i=1}^{m}\left(\mathbf{x}'_{a_i}\cdot \boldsymbol{\theta} - y_{a_i}\right)\cdot \mathbf{x}'_{a_i}
\end{equation}
$$

where $a_i$ is an array of indices of objects which are in this batch. After each iteration, it important to shuffle the samples.

__Custom functions__

```python
# custom function
def stochastic_gradient_descent_update(X, y, n_iter, B, ]=[, eta, verbose=False):
    models = [init_model]
    loss = [ols_loss(X, y, init_model)]

    m = len(X)
    for idx in range(n_iter):
        j = (idx * B) % m
        batchX, batchy = X[j : j + B], y[j : j + B]
        gradient = ols_gradient(batchX, batchy, models[-1]) / m
        update = eta * gradient
        newModel = models[-1] - update
        if verbose:
            print(
                "\nModel {0}: [{1}, {2}]".format(
                    idx, round(models[-1][0], 2), round(models[-1][1], 2)
                )
            )
            print("Loss: {0}".format(loss[-1]))
            print("     >>> gradient: {0}".format(gradient))
            print("     >>> update: {0}".format(update))
        models.append(newModel)
        loss.append(ols_loss(X, y, newModel))
    if verbose:
        print(
            "\nModel {0}: [{1}, {2}]".format(
                idx, round(models[-1][0], 2), round(models[-1][1], 2)
            )
        )
        print("Loss: {0}".format(loss[-1]))
    return np.array(models), loss
```

```python
# load  data
points = getData()
X = augment(points)[:, :-1]
y = points[:, -1]
```

```python
# review observations
X
```

    array([[1, 1],
           [1, 3],
           [1, 5],
           [1, 4],
           [1, 2]])

```python
# review labels
y
```

    array([2, 4, 5, 3, 3])

```python
# execute stochastic gradient descent
n_steps = 5
eta = 0.1
orig_model = [0, 1]
batch_size = 1

models, loss = stochastic_gradient_descent_update(X, y, n_steps, batch_size, orig_model, eta, verbose=True)

print("Searched {0} models...".format(len(models)))
print(
    "Best model: {0}, Loss of best model: {1}".format(
        models[np.argmin(loss)], loss[np.argmin(loss)]
    )
)
```

    Model 0: [0, 1]
    Loss: 0.8
         >>> gradient: [-0.4 -0.4]
         >>> update: [-0.04 -0.04]

    Model 1: [0.04, 1.04]
    Loss: 0.7648
         >>> gradient: [-0.336 -1.008]
         >>> update: [-0.0336 -0.1008]

    Model 2: [0.07, 1.14]
    Loss: 0.9141452800000004
         >>> gradient: [0.31104 1.5552 ]
         >>> update: [0.031104 0.15552 ]

    Model 3: [0.04, 0.99]
    Loss: 0.7782153256960003
         >>> gradient: [0.3934464 1.5737856]
         >>> update: [0.03934464 0.15737856]

    Model 4: [0.0, 0.83]
    Loss: 1.2577106818564097
         >>> gradient: [-0.5364183  -1.07283661]
         >>> update: [-0.05364183 -0.10728366]

    Model 4: [0.06, 0.94]
    Loss: 0.8337672467910437
    Searched 6 models...
    Best model: [0.04 1.04], Loss of best model: 0.7648

```python
# visualize optimization
show_contours = False
plot_gradient_descent_progress(points, models, loss, show_contours=show_contours)
```

# Feature selection

Feature selection pertains to strategic variable selection which aims to limit the original data set to only relevant variables. This is advantageous for several reasons:

1. Simpler models are easier to interpet.
2. Model training time is shorter.
3. Avoids curse of dimensionality.
4. Reduces tendency towards high variance due to increased model complexity.

## Example 1 - SelectKBest for linear regression - compact

SelectKBest for linear regression employs a test called f_regression.

Then first step is to compute the correlation between each feature and the target.

$$
{\displaystyle \rho _{X,Y}=\mathrm {corr} (X,Y)={\mathrm {cov} (X,Y) \over \sigma _{X}\sigma _{Y}}={E[(X-\mu _{X})(Y-\mu _{Y})] \over \sigma _{X}\sigma _{Y}},}
$$

Python code for this might look something like:

```python
((X[:, i] - mean(X[:, i])) (y - mean_y)) / (std(X[:, i]) std(y))
```

The second step is to use the correlation to calculate the F-score, which is used to calculate the p-value for each feature.

```python
# load data
boston = load_boston()
X = pd.DataFrame(boston.data, columns=boston.feature_names)
y = boston.target
```

```python
# train/test split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=9)
```

```python
# perform SelectKBest
best = SelectKBest(f_regression, k=5)  # k is number of features.
X_trainSel = best.fit_transform(X_train, y_train)
```

```python
# evaluate feature selection
X_trainSel[0:4, :]
```

    array([[  2.46 ,   6.144, 193.   ,  17.8  ,   9.45 ],
           [ 25.65 ,   5.961, 188.   ,  19.1  ,  17.93 ],
           [  2.89 ,   7.416, 276.   ,  18.   ,   6.19 ],
           [  6.2  ,   6.951, 307.   ,  17.4  ,   9.71 ]])

```python
# review observations
X_train.head()
```

      CRIM
      ZN
      INDUS
      CHAS
      NOX
      RM
      AGE
      DIS
      RAD
      TAX
      PTRATIO
      B
      LSTAT

      181
      0.06888
      0.0
      2.46
      0.0
      0.488
      6.144
      62.2
      2.5979
      3.0
      193.0
      17.8
      396.90
      9.45

      122
      0.09299
      0.0
      25.65
      0.0
      0.581
      5.961
      92.9
      2.0869
      2.0
      188.0
      19.1
      378.09
      17.93

      99
      0.06860
      0.0
      2.89
      0.0
      0.445
      7.416
      62.5
      3.4952
      2.0
      276.0
      18.0
      396.90
      6.19

      220
      0.35809
      0.0
      6.20
      1.0
      0.507
      6.951
      88.5
      2.8617
      8.0
      307.0
      17.4
      391.70
      9.71

      474
      8.05579
      0.0
      18.10
      0.0
      0.584
      5.427
      95.4
      2.4298
      24.0
      666.0
      20.2
      352.58
      18.14

>  Features retained: INDUS, RM, TAX, PTRATIO, LSAT

## Example 2 - SelectKBest for linear regression - detailed

Using Boston housing market dataset:
- Use custom function to perform SelectKBest and log supporting statistics including F-scores and p-values in a DataFrame
- Visualize feature importance according to F-score

__Custom functions__

```python
# custom function
def select_k_best_ref(dataFrame, target, k=5):
    """

    """
    featSelector = SelectKBest(f_regression, k=k)
    _ = featSelector.fit(dataFrame.drop(target, axis=1), dataFrame[target])

    feat_scores = pd.DataFrame()
    feat_scores["F score"] = featSelector.scores_
    feat_scores["P value"] = featSelector.pvalues_
    feat_scores["Support"] = featSelector.get_support()
    feat_scores["Attribute"] = dataFrame.drop(target, axis=1).columns

    return feat_scores
```

```python
# load data
boston = load_boston()
X = pd.DataFrame(boston.data, columns=boston.feature_names)
y = boston.target
```

```python
# train/test split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=9)
```

```python
# combine train data info DataFrame
df = pd.DataFrame(X_train, columns=boston.feature_names)
df["MEDV"] = y_train
```

```python
# perform SelectKBest
feat_scores = select_k_best_ref(df, "MEDV", k=7)
```

```python
# evaluate feature selection
feat_scores_sorted = feat_scores.sort_values(
    ["F score", "P value"], ascending=[False, False]
)
print("Feature Score for a linear regression using correlation \n")
print(feat_scores_sorted)
```

    Feature Score for a linear regression using correlation

           F score       P value  Support Attribute
    12  474.974942  4.373435e-70     True     LSTAT
    5   388.685106  5.050989e-61     True        RM
    10  124.931223  1.930196e-25     True   PTRATIO
    2   116.026814  6.101575e-24     True     INDUS
    9   107.274933  1.931385e-22     True       TAX
    4    85.039143  1.669887e-18     True       NOX
    0    73.732715  1.987020e-16     True      CRIM
    8    64.861115  9.213851e-15    False       RAD
    6    55.681364  5.318242e-13    False       AGE
    1    50.485143  5.502609e-12    False        ZN
    11   46.254397  3.774453e-11    False         B
    7    22.794055  2.529681e-06    False       DIS
    3     7.902266  5.178888e-03    False      CHAS

```python
# visualize top features
feat_importance_sorted = feat_scores_sorted[["F score", "Attribute"]]

plt.figure()
plt.title("Feature importances")

sorted_names = np.array(feat_importance_sorted)[:, 1]
sorted_importance = np.array(feat_importance_sorted)[:, 0]

plt.title("Feature Importances")
plt.barh(range(len(sorted_names)), sorted_importance, color="b", align="center")
plt.yticks(range(len(sorted_names)), sorted_names)
plt.xlabel(
    "Low Importance                                                High Importance"
)
plt.grid()
plt.show()
```

## Example 3 - feature selection step within pipeline

Using Boston housing market dataset:
- With feature importance F-scores determined in previous example, use custom functions to perform SelectKBest within a pipeline
- Evaluate model using test set
- Visualize coefficients of selected columns

__Custom functions__

```python
# custom functions
def indices_of_top_k(arr, k):
    return np.sort(np.argpartition(np.array(arr), -k)[-k:])

class TopFeatureSelector(BaseEstimator, TransformerMixin):
    def __init__(self, featureImportance, k):
        self.featureImportance = featureImportance
        self.k = k

    def fit(self, X, y=None):
        self.featureIndices_ = indices_of_top_k(self.featureImportance, self.k)
        return self

    def transform(self, X):
        return X[:, self.featureIndices_]
```

```python
# load data
boston = load_boston()
X = pd.DataFrame(boston.data, columns=boston.feature_names)
y = boston.target
```

```python
# train/test split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=9)
```

```python
# combine train data info DataFrame
df = pd.DataFrame(X_train, columns=boston.feature_names)
df["MEDV"] = y_train
```

```python
# calculate feature F-scores
feat_scores = select_k_best_ref(df, "MEDV", k=7)
featureFScores = feat_scores[["F score"]].values.flatten()
featureFScores
```

    array([ 73.73271485,  50.48514331, 116.02681424,   7.90226593,
            85.03914291, 388.68510611,  55.6813643 ,  22.79405499,
            64.86111507, 107.27493269, 124.93122263,  46.25439733,
           474.97494162])

```python
# create pipeline - Scale data, performs SelectKBest, and train linear regression model
pipeline = Pipeline(
    [
        ("stdScaler", StandardScaler()),
        (
            "featureSelection",
            TopFeatureSelector(featureFScores, k="featureSelection__k"),
        ),
        ("lin_reg", LinearRegression()),
    ]
)
```

```python
# build parameter grid
param_grid = {"featureSelection__k": np.arange(1, len(featureFScores) + 1)}
param_grid
```

    {'featureSelection__k': array([ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13])}

```python
# run GridSearchCV
grid_search = GridSearchCV(pipeline, param_grid, cv=5, refit=True)
grid_search.fit(X_train, y_train)
```

    GridSearchCV(cv=5, error_score='raise-deprecating',
                 estimator=Pipeline(memory=None,
                                    steps=[('stdScaler',
                                            StandardScaler(copy=True,
                                                           with_mean=True,
                                                           with_std=True)),
                                           ('featureSelection',
                                            TopFeatureSelector(featureImportance=array([ 73.73271485,  50.48514331, 116.02681424,   7.90226593,
            85.03914291, 388.68510611,  55.6813643 ,  22.79405499,
            64.86111507, 107.27493269, 124.93122263,  46.25439733,
           474.97494162]),
                                                               k='featureSelection__k')),
                                           ('lin_reg',
                                            LinearRegression(copy_X=True,
                                                             fit_intercept=True,
                                                             n_jobs=None,
                                                             normalize=False))],
                                    verbose=False),
                 iid='warn', n_jobs=None,
                 param_grid={'featureSelection__k': array([ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13])},
                 pre_dispatch='2*n_jobs', refit=True, return_train_score=False,
                 scoring=None, verbose=0)

```python
# evaluate GridSearchCV
print("Best score: {0}".format(grid_search.best_score_))
print("Best parameter: {0}".format(grid_search.best_params_))
print("Best estimator: {0}".format(grid_search.best_estimator_))
```

    Best score: 0.706679636548946
    Best parameter: {'featureSelection__k': 13}
    Best estimator: Pipeline(memory=None,
             steps=[('stdScaler',
                     StandardScaler(copy=True, with_mean=True, with_std=True)),
                    ('featureSelection',
                     TopFeatureSelector(featureImportance=array([ 73.73271485,  50.48514331, 116.02681424,   7.90226593,
            85.03914291, 388.68510611,  55.6813643 ,  22.79405499,
            64.86111507, 107.27493269, 124.93122263,  46.25439733,
           474.97494162]),
                                        k=13)),
                    ('lin_reg',
                     LinearRegression(copy_X=True, fit_intercept=True, n_jobs=None,
                                      normalize=False))],
             verbose=False)

```python
# test set predictions
y_preds_test = grid_search.predict(X_test)
print(
    "Test accuracy (MSE): {0}".format(
        np.round(mean_squared_error(y_test, y_preds_test), 3)
    )
)
print(
    "Test accuracy (RMSE): {0}".format(
        np.round(np.sqrt(mean_squared_error(y_test, y_preds_test)), 3)
    )
)
```

    Test accuracy (MSE): 23.677
    Test accuracy (RMSE): 4.866

```python
# visualize
model = grid_search.best_estimator_.named_steps["lin_reg"]

feat_scores = pd.DataFrame()
feat_scores["coefficient"] = model.coef_
feat_scores["ABScoefficient"] = np.abs(model.coef_)
feat_scores["Attribute"] = X.columns
feat_scores = feat_scores.sort_values(["ABScoefficient"], ascending=[False])
feat_scores

plt.figure(figsize=(12, 8))
plt.bar(np.arange(model.coef_.shape[0]) - 0.4, feat_scores["coefficient"])
plt.xticks(
    np.arange(model.coef_.shape[0]), feat_scores["Attribute"], rotation="vertical"
)
plt.xlim([-1, model.coef_.shape[0]])
plt.grid()
plt.title("Sklearn model coefficients")
```

# Handling missing data

Observations are often missing data for one or more features. Similarly, features often contain nulls for several observations. Missing data is problematic for machine learning algorithms it can cause errors in calculations. There are several methods to deal with nulls, and while no method is perfect, the context of the nulls can help determine the best course of action.

## Example 1 - null observations and features

Dropping feature and/or observations is as straight forward as it sounds. The downside to this approach is that dropping data means less data is available for the algorithm to model the problem. Dropping features or columns is generally not advised as a first step.

```python
# load and inspect data
import pandas as pd
from io import StringIO
import sys

csv_data = """A,B,C,D
1.0,2.0,3.0,4.0
5.0,6.0,,8.0
10.0,11.0,12.0,"""

df = pd.read_csv(StringIO(csv_data))
df
```

      A
      B
      C
      D

      0
      1.0
      2.0
      3.0
      4.0

      1
      5.0
      6.0
      NaN
      8.0

      2
      10.0
      11.0
      12.0
      NaN

```python
# inspect null counts
df.isnull().sum()
```

    A    0
    B    0
    C    1
    D    1
    dtype: int64

__Strategy 1: Eliminate data__

```python
# observations with any missing values
df.dropna(axis=0)
```

      A
      B
      C
      D

      0
      1.0
      2.0
      3.0
      4.0

```python
# features with any missing values
df.dropna(axis=1)
```

      A
      B

      0
      1.0
      2.0

      1
      5.0
      6.0

      2
      10.0
      11.0

```python
# rows where all columns are null
df.dropna(how="all")
```

      A
      B
      C
      D

      0
      1.0
      2.0
      3.0
      4.0

      1
      5.0
      6.0
      NaN
      8.0

      2
      10.0
      11.0
      12.0
      NaN

```python
# rows must have 3 or more real values populated
df.dropna(thresh=4)
```

      A
      B
      C
      D

      0
      1.0
      2.0
      3.0
      4.0

```python
# only drop rows with nulls in specified column
df.dropna(subset=["C"])
```

      A
      B
      C
      D

      0
      1.0
      2.0
      3.0
      4.0

      2
      10.0
      11.0
      12.0
      NaN

__Strategy 1: Imputing missing values__

Use a predefined strategy to fill in null values with an inferred value. Common strategies include fill feature nulls with the mean, median or most common value.

```python
# review missing values
df.values
```

    array([[ 1.,  2.,  3.,  4.],
           [ 5.,  6., nan,  8.],
           [10., 11., 12., nan]])

```python
# fill nulls with column mean
imr = Imputer(missing_values="NaN", strategy="mean", axis=0)
imr = imr.fit(df.values)
imputed_data = imr.transform(df.values)
imputed_data
```

    array([[ 1. ,  2. ,  3. ,  4. ],
           [ 5. ,  6. ,  7.5,  8. ],
           [10. , 11. , 12. ,  6. ]])

# Handling object features

Machine learning algorithms require numberal data, but we are often provided with features that are described with text values. Such value can be nominal or ordinal:

nominal: Male, Female

ordinal: S, M, L, XL

Nominal and ordinal features need to be handled differently.

## Example 1 - mapping strategies

```python
# load and inspect data

df = pd.DataFrame(
    [
        ["green", "M", 10.1, "class1"],
        ["red", "L", 13.5, "class2"],
        ["blue", "XL", 15.3, "class1"],
    ]
)

df.columns = ["color", "size", "price", "classlabel"]
df
```

      color
      size
      price
      classlabel

      0
      green
      M
      10.1
      class1

      1
      red
      L
      13.5
      class2

      2
      blue
      XL
      15.3
      class1

__Strategy 1: preserve hierarchy of ordinal features__

```python
# map
size_mapping = {"XL": 3, "L": 2, "M": 1}
```

```python
# apply mapping
df["size"] = df["size"].map(size_mapping)
df
```

      color
      size
      price
      classlabel

      0
      green
      1
      10.1
      class1

      1
      red
      2
      13.5
      class2

      2
      blue
      3
      15.3
      class1

```python
# reverse mapping
inv_size_mapping = {v: k for k, v in size_mapping.items()}
df["size"].map(inv_size_mapping)
```

    0     M
    1     L
    2    XL
    Name: size, dtype: object

__Strategy 2: manually encode class labels__

```python
# dynamically build encoding dictionary
class_mapping = {label: idx for idx, label in enumerate(np.unique(df["classlabel"]))}
class_mapping
```

    {'class1': 0, 'class2': 1}

```python
# apply mapping
df["classlabel"] = df["classlabel"].map(class_mapping)
df
```

      color
      size
      price
      classlabel

      0
      green
      1
      10.1
      0

      1
      red
      2
      13.5
      1

      2
      blue
      3
      15.3
      0

```python
# reverse mapping
inv_class_mapping = {v: k for k, v in class_mapping.items()}
df["classlabel"].map(inv_class_mapping)
```

    0    class1
    1    class2
    2    class1
    Name: classlabel, dtype: object

__Strategy 3: encode class labels with sklearn__

```python
# instantiate encoder and apply
class_le = LabelEncoder()
y = class_le.fit_transform(df["classlabel"].values)
y
```

    array([0, 1, 0])

```python
# reverse transformation
class_le.inverse_transform(y)
```

    array([0, 1, 0])

__Strategy 4: one-hot encoding__

```python
# create dummy dataset and encode
df = pd.DataFrame(
    [
        ["green", "M", 10.1, "class1"],
        ["red", "L", 13.5, "class2"],
        ["blue", "XL", 15.3, "class1"],
    ]
)

df.columns = ["color", "size", "price", "classlabel"]

size_mapping = {"XL": 3, "L": 2, "M": 1}
df["size"] = df["size"].map(size_mapping)

X = df[["color", "size", "price"]].values

color_le = LabelEncoder()
X[:, 0] = color_le.fit_transform(X[:, 0])
X
```

    array([[1, 1, 10.1],
           [2, 2, 13.5],
           [0, 3, 15.3]], dtype=object)

```python
# encode using sklearn OneHot Encoder
ohe = OneHotEncoder(object_features=[0])
ohe.fit_transform(X).toarray()
```

    array([[ 0. ,  1. ,  0. ,  1. , 10.1],
           [ 0. ,  0. ,  1. ,  2. , 13.5],
           [ 1. ,  0. ,  0. ,  3. , 15.3]])

__Strategy 5: get dummies__

```python
# encode using get_dummies()
pd.get_dummies(df[["price", "color", "size"]])
```

      price
      size
      color_blue
      color_green
      color_red

      0
      10.1
      1
      0
      1
      0

      1
      13.5
      2
      0
      0
      1

      2
      15.3
      3
      1
      0
      0

```python
# avoid multicollinearity by dropping one of the dummy columns for each mapped feature
pd.get_dummies(df[["price", "color", "size"]], drop_first=True)
```

      price
      size
      color_green
      color_red

      0
      10.1
      1
      1
      0

      1
      13.5
      2
      0
      1

      2
      15.3
      3
      0
      0

# Standardizing data: bring features onto same scale

Standardizing the features ensures that each feature is given equal consideration in the training phase. Without standarization, some features may play a more prominent role in model training simply because the values are higher, but not necessarily because the feature is more important than another feature that happens to have smaller values.

Several machine learning algorithms require data to be scaled.

## Example 1 - standardization strategies

A couple basic standardization strategies.
- Standard scaling - scale the data so that it has the properties of a standard normal distribution - a mean of 0 and a standard deviation of 1
- Min/max scaling - scale the data so that is fits in a set range, usually 0 - 1.

```python
# load data
boston = load_boston()
X = pd.DataFrame(boston.data, columns=boston.feature_names)
y = boston.target
```

```python
# train/test split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=9)
```

__Strategy 1 - min/max scaling__

```python
# instantiate min/max scaler and transform
mms = MinMaxScaler()
X_trainNorm = mms.fit_transform(X_train)
X_testNorm = mms.transform(X_test)
```

__Strategy 2 - standard scaling__

```python
# strategy 2 - standard scaling
stdsc = StandardScaler()
X_train_std = stdsc.fit_transform(X_train)
X_test_std = stdsc.transform(X_test)
```

```python
# compare scaling strategies
ex = np.array([0, 1, 2, 3, 4, 5])

print("standardized:", (ex - ex.mean()) / ex.std())

print("normalized:", (ex - ex.min()) / (ex.max() - ex.min()))
```

    standardized: [-1.46385011 -0.87831007 -0.29277002  0.29277002  0.87831007  1.46385011]
    normalized: [0.  0.2 0.4 0.6 0.8 1. ]


# 指數是什麼？動手算一次就懂

> 寫點 Python 程式計算道瓊工業平均指數與標準普爾 500 指數
>
> 標籤：程式設計，獲取載入

郭耀仁 <yaojenkuo@datainpoint.com> from [datainpoint](https://datainpoint.substack.com/about)

```python
# 載入專案需要使用的套件
import numpy as np
import pandas as pd
from tqdm import tqdm
```

## TL; DR

在這個專案中，我們打算寫點 Python 程式，先將道瓊工業平均指數與標準普爾 500 指數的成分股代號由網頁中抓取出來，接著再取得這些成分股的股價以及流通股數，最後再套用加權計算的公式求解。透過這個專案，我們能夠暸解如何使用 Python 實作網頁資料擷取、數值計算以及陣列運算。

## 取得道瓊工業平均指數成分股

道瓊工業平均指數成分股由 30 間在紐約證券交易所與 NASDAQ 上市的藍籌股（Blue Chip）公司所組成，可以由 <https://en.wikipedia.org/wiki/Dow_Jones_Industrial_Average> 取得這些公司的代號；使用 `pandas` 套件中的 `read_html()` 函式可以將網頁中的 `<table></table>` 標記擷取下來為資料框。

```python
request_url = "https://en.wikipedia.org/wiki/Dow_Jones_Industrial_Average"
html_tables = pd.read_html(request_url)
djia_symbols = [e.replace("\xa0", "").replace("NYSE:", "") for e in html_tables[1]['Symbol'].values]
print(djia_symbols)
print(len(djia_symbols))
```

    ['MMM', 'AXP', 'AMGN', 'AAPL', 'BA', 'CAT', 'CVX', 'CSCO', 'KO', 'DOW', 'GS', 'HD', 'HON', 'IBM', 'INTC', 'JNJ', 'JPM', 'MCD', 'MRK', 'MSFT', 'NKE', 'PG', 'CRM', 'TRV', 'UNH', 'VZ', 'V', 'WBA', 'WMT', 'DIS']
    30

## 取得標準普爾 500 指數成分股

標準普爾 500 指數成分股由 505 間在紐約證券交易所、NASDAQ 與芝加哥期貨選擇權交易所上市的藍籌股（Blue Chip）公司所組成，可以由 <https://en.wikipedia.org/wiki/List_of_S%26P_500_companies> 取得這些公司的代號；使用 `pandas` 套件中的 `read_html()` 函式可以將網頁中的 `<table></table>` 標記擷取下來為資料框。

```python
request_url = "https://en.wikipedia.org/wiki/List_of_S%26P_500_companies"
html_tables = pd.read_html(request_url)
sp500_symbols = list(html_tables[0]['Symbol'].values)
print(sp500_symbols[:10]) # print the first 10 symbols
print(len(sp500_symbols))
```

    ['MMM', 'ABT', 'ABBV', 'ABMD', 'ACN', 'ATVI', 'ADBE', 'AMD', 'AAP', 'AES']
    505

## 取得成分股的股價以及流通股數

使用 `pandas` 套件取得成份股的股價以及流通股數（Float shares），我們撰寫了一個類別 `GetPriceFloatShares` 可以接受美股代號的輸入，回傳 [Yahoo! Finance](https://finance.yahoo.com/) 的資料，以蘋果電腦（美股代號 AAPL）為例：

```python
class GetPriceFloatShares:
    def __init__(self, symbol):
        self._symbol = symbol
        query_str_params = {
            "p": symbol
        }
        self._query_str_params = query_str_params
    def get_price(self):
        request_url = "https://finance.yahoo.com/quote/{}/history".format(self._symbol)
        html_tables = pd.read_html(request_url)
        price = html_tables[0].iloc[0, 4].replace(",", "")
        return price
    def get_float_shares(self):
        request_url = "https://finance.yahoo.com/quote/{}/key-statistics".format(self._symbol)
        html_tables = pd.read_html(request_url)
        float_shares = html_tables[2].set_index(0).loc['Float', :].values[0]
        return float_shares
```

```python
aapl = GetPriceFloatShares("AAPL")
print(aapl.get_price())
print(aapl.get_float_shares())
```

    117.32
    17.09B

```python
html_tables = pd.read_html("https://finance.yahoo.com/quote/AAPL/history?p=AAPL")
```

```python
html_tables[0].iloc[0, 4]
```

    '117.32'

## 計算道瓊工業平均指數

我們寫一個 `calculate_djia()` 函式計算道瓊工業平均指數，依據股價加權計算公式，是將成分股每個公司的股價加總後再除以道瓊除數（Dow divisor）：

\begin{equation}
\text{Dow Jones Industrial Average} = \frac{\sum_i P_i}{\text{Dow divisor}}
\end{equation}

道瓊除數在 2020-08-31 為 0.15198707565833（<https://en.wikipedia.org/wiki/Dow_Jones_Industrial_Average>）。

```python
def calculate_djia(dow_divisor, djia_symbols):
    sum_of_prices = 0
    for symb in tqdm(djia_symbols):
        gpfs = GetPriceFloatShares(symb)
        price = gpfs.get_price()
        sum_of_prices += float(price)
    return sum_of_prices / dow_divisor

djia = calculate_djia(0.15198707565833, djia_symbols)
```

    100%|██████████| 30/30 [00:36<00:00,  1.22s/it]

```python
print(djia)
```

    27940.46784311068

## 計算標準普爾 500 指數

我們寫一個 `calculate_sp500()` 函式計算標準普爾 500 指數，依據市值加權計算公式，將成分股每個公司的股價乘以市值加總後再除以 S&P 500 除數（S&P 500 divisor）：

\begin{equation}
\text{S&P 500} = \frac{\sum_i (P_i \times Q_i)}{\text{S&P 500 divisor}} \\
\text{where} \; Q_i = \text{Float shares of stock i}
\end{equation}

S&P 500 除數目前約為 83 億美元（<https://en.wikipedia.org/wiki/S%26P_500_Index>）。由於從 [Yahoo! Finance](https://finance.yahoo.com/) 擷取 500+ 個成分股資訊要一段時間，因此我們先擷取了 2020-09-09 的資訊，存在 `data/sp500.csv`，可以用 `pandas` 的 `read_csv()` 函式讀入成為資料框。

```python
sp500_df = pd.read_csv("data/sp500.csv")
sp500_df.head()
```

      symbol
      price
      float_shares

      0
      MMM
      163.17
      575.35M

      1
      ABT
      102.84
      1.76B

      2
      ABBV
      90.22
      1.76B

      3
      ABMD
      268.19
      44.04M

      4
      ACN
      232.65
      635.19M

其中在 `float_shares` 欄位我們可以觀察到流通股數資料是以 B 表示 Billion、M 表示 Million，這時我們可以定義一個函式 `unit_transform()` 判斷 B 或 M 之後進行單位的轉換，亦即乘以 1,000,000,000 或 1,000,000。

```python
def unit_transform(x):
    if "B" in x:
        x = x.replace("B", "")
        float_x = float(x)
        return float_x * 1000000000
    elif "M" in x:
        x = x.replace("M", "")
        float_x = float(x)
        return float_x * 1000000
```

`unit_tranform()` 函式目前可以針對單一成分股的流通股數進行單位轉換。

```python
print(unit_transform("575.35M"))
print(unit_transform("1.76B"))
```

    575350000.0
    1760000000.0

若希望能夠將其應用到所有的成分股上，我們可以使用 `np.vectorize()` 將它改變成一個通用函式（Universal function）。

```python
unit_transform_ufunc = np.vectorize(unit_transform)
print(sp500_df['float_shares'].values[:10])
print(unit_transform_ufunc(sp500_df['float_shares'].values[:10]))
```

    ['575.35M' '1.76B' '1.76B' '44.04M' '635.19M' '763.22M' '477.4M' '1.17B'
     '64.61M' '662.4M']
    [5.7535e+08 1.7600e+09 1.7600e+09 4.4040e+07 6.3519e+08 7.6322e+08
     4.7740e+08 1.1700e+09 6.4610e+07 6.6240e+08]

最後依據市值加權計算公式定義 `calculate_sp500()` 函式計算標準普爾 500 指數。

```python
def calculate_sp500(sp500_divisor, sp500_df):
    prices = sp500_df['price'].values
    float_shares = unit_transform_ufunc(sp500_df['float_shares'].values)
    return np.sum(prices * float_shares) / sp500_divisor

sp500 = calculate_sp500(8.3e9, sp500_df)
```

```python
print(sp500)
```

    3397.1794004698795


# The One-Dimensional Particle in a Box

## 🥅 Learning Objectives
- Determine the energies and eigenfunctions of the particle-in-a-box.
- Learn how to normalize a wavefunction.
- Learn how to compute expectation values for quantum-mechanical operators.
- Learn the postulates of quantum mechanics

## Cyanine Dyes

Cyanine dye molecules are often modelled as one-dimension particles in a box. To understand why, start by thinking classically.  You learn in organic chemistry that electrons can more “freely” along alternating double bonds.  If this is true, then you can imagine that the electrons can more from one Nitrogen to the other, almost without resistance.  On the other hand, there are sp3-hybridized functional groups attached to the Nitrogen atom, so once the electron gets to Nitrogen atom, it has to turn around and go back whence it came.   A very, very, very simple model would be to imagine that the electron is totally free between the Nitrogen atoms, and totally forbidden from going much beyond the Nitrogen atoms.  This suggests modeling these systems a potential energy function like:

$$
V(x) =
\begin{cases}
    +\infty & x\leq 0\\
    0       & 0\lt x \lt a\\
    +\infty & a \leq x
\end{cases}
$$

where $a$ is the length of the box. A reasonable approximate formula for $a$ is

$$
a = \left(5.67 + 2.49 (k + 1)\right) \cdot 10^{-10} \text{ m}
$$

## Postulate: The squared magnitude of the wavefunction is proportional to probability
What is the interpretation of the wavefunction? The Born postulate indicates that the squared magnitude of the wavefunction is proportional to the probability of observing the system at that location. E.g., if $\psi(x)$ is the wavefunction for an electron as a function of $x$, then

$$
p(x) = |\psi(x)|^2
$$

is the probability of observing an electron at the point $x$. This is called the Born Postulate.

## The Wavefunctions of the Particle in a Box (boundary conditions)

The nice thing about this “particle in a box” model is that it is easy to solve the time-independent Schrödinger equation in this case.  Because there is no chance that the particle could ever “escape” an infinite box like this (such an electron would have infinite potential energy!), $|\psi(x)|^2$ must equal zero outside the box. Therefore the wavefunction can only be nonzero inside the box. In addition, the wavefunction should be zero at the edges of the box, because otherwise the wavefunction will not be continuous.  So we should have a wavefunction like

$$
\psi(x) =
\begin{cases}
    0& x\le 0\\
    \text{?????}    & 0 < x < a\\
    0 & a \le x
\end{cases}
$$

## Postulate: The wavefunction of a system is determined by solving the Schrödinger equation
How do we find the wavefunction for the particle-in-a-box or, for that matter, any other system? The wavefunction can be determined by solving the time-independent (when the potential is time-independent) or time-dependent (when the potential is time-dependent) Schrödinger equation.

## The Wavefunctions of the Particle in a Box (solution)

To find the wavefunctions for a system, one solves the Schrödinger equation. For a particle of mass $m$ in a one-dimensional box, the (time-independent) Schrödinger equation is:

$$
\left(-\frac{\hbar^2}{2m} \frac{d^2}{dx^2} + V(x) \right)\psi_n(x) = E_n \psi_n(x)
$$

where

$$
V(x) =
\begin{cases}
    +\infty & x\leq 0\\
    0       & 0\lt x \lt a\\
    +\infty & a \leq x
\end{cases}
$$

We already deduced that $\psi(x) = 0$ except when the electron is inside the box ($0 < x  Question: What function(s), when differentiated twice, are proportional to themselves?

This suggests that the eigenfunctions of the 1-dimensional particle-in-a-box must be some linear combination of sine and cosine functions,

$$
\psi_n(x) = A \sin(cx) + B  \cos(dx)
$$

We know that the wavefunction must be zero at the edges of the box, $\psi(0) = 0$ and $\psi(a) = 0$. These are called the *boundary conditions* for the problem. Examining the first boundary condition,

$$
0 = \psi(0) = A \sin(0) + B \cos(0) = 0 + B
$$

indicates that $B=0$. The second boundary condition

$$
0 = \psi(a) = A \sin(ca)
$$

requires us to recall that $\sin(x) = 0$ whenever $x$ is an integer multiple of $\pi$. So $c=n\pi$ where $n=1,2,3,\ldots$. The wavefunction for the particle in a box is thus,

$$
\psi_n(x) = A_n \sin\left(\tfrac{n \pi x}{a}\right) \qquad \qquad n=1,2,3,\ldots
$$

## Normalization of Wavefunctions
As seen in the previous section, if a wavefunction solves the Schrödinger equation, any constant multiple of the wavefunction also solves the Schrödinger equation,

$$
\hat{H} \psi(x) = E \psi(x) \quad \longleftrightarrow \quad \hat{H} \left(A\psi(x)\right) = E \left(A\psi(x)\right)
$$

Owing to the Born postulate, the complex square of the wavefunction can be interpreted as probability. Since the probability of a particle being at *some* point in space is one, we can define the normalization constant, $A$, for the wavefunction through the requirement that:

$$
\int_{-\infty}^{\infty} \left|\psi(x)\right|^2 dx = 1.
$$

In the case of a particle in a box, this is:

$$
\begin{align}
1 &= \int_{-\infty}^{\infty} \left|\psi_n(x)\right|^2 dx  \\
&= \int_0^a \psi_n(x) \psi_n^*(x) dx \\
&= \int_0^a A_n \sin\left(\tfrac{n \pi x}{a}\right) \left(A_n \sin\left(\tfrac{n \pi x}{a}\right) \right)^* dx \\
&= \left|A_n\right|^2\int_0^a \sin^2\left(\tfrac{n \pi x}{a}\right) dx
\end{align}
$$

To evaluate this integral, it is useful to remember some [trigonometric identities](https://en.wikipedia.org/wiki/List_of_trigonometric_identities). (You can learn more about how I remember trigonometric identities [here](../linkedFiles/TrigIdentities.md).) The specific identity we need here is $\sin^2 x = \tfrac{1}{2}(1-\cos 2x)$:

$$
\begin{align}
1 &= \left|A_n\right|^2\int_0^a \sin^2\left(\tfrac{n \pi x}{a}\right) \,dx \\
 &= \left|A_n\right|^2\int_0^a \tfrac{1}{2}\left(1-\cos \left(\tfrac{2n \pi x}{a}\right)\right) \,dx \\
 &=\tfrac{\left|A_n\right|^2}{2} \left( \int_0^a 1 \,dx - \int_0^a \cos \left(\tfrac{2n \pi x}{a}\right)\,dx  \right) \\
 &=\tfrac{\left|A_n\right|^2}{2} \left( \left[ x \right]_0^a - \left[\frac{-a}{2 n \pi}\sin \left(\tfrac{2n \pi x}{a}\right) \right]_0^a  \right) \\
 &=\tfrac{\left|A_n\right|^2}{2} \left( a - 0  \right)
\end{align}
$$

So

$$
 \left|A_n\right|^2 = \tfrac{2}{a}
$$

Note that this does not completely determine $A_n$. For example, any of the following normalization constants are allowed,

$$
A_n = \sqrt{\tfrac{2}{a}}
= - \sqrt{\tfrac{2}{a}}
= i \sqrt{\tfrac{2}{a}}
= -i \sqrt{\tfrac{2}{a}}
$$

In general, any [square root of unity](https://en.wikipedia.org/wiki/Root_of_unity) can be used,

$$
A_n = \left(\cos(\theta) \pm i \sin(\theta) \right) \sqrt{\tfrac{2}{a}}
$$

where $k$ is any real number. The arbitrariness of the *phase* of the wavefunction is an important feature. Because the wavefunction can be imaginary (e.g., if you choose $A_n = i \sqrt{\tfrac{2}{a}}$), it is obvious that the wavefunction is not an observable property of a system. **The wavefunction is only a mathematical tool for quantum mechanics; it is not a physical object.**

Summarizing, the (normalized) wavefunction for a particle with mass $m$ confined to a one-dimensional box with length $a$ can be written as:

$$
\psi_n(x) = \sqrt{\tfrac{2}{a}} \sin\left(\tfrac{n \pi x}{a}\right) \qquad \qquad n=1,2,3,\ldots
$$

Note that in this case, the normalization condition is the same for all $n$; that is an unusual property of the particle-in-a-box wavefunction.

While this normalization convention is used 99% of the time, there are some cases where it is more convenient to make a different choice for the amplitude of the wavefunctions. I say this to remind you that normalization the wavefunction is something we do for convenience; it is not required by physics!

## Normalization Check
One advantage of using Jupyter is that we can easily check our (symbolic) mathematics. Let's confirm that the wavefunction is normalized by evaluating,

$$
\int_0^a \left| \psi_n(x) \right|^2 \, dx
$$

```python
# Execute this code block to import required objects.

# Note: The numpy library from autograd is imported, which behaves the same as
#       importing numpy directly. However, to make automatic differentiation work,
#       you should NOT import numpy directly by `import numpy as np`.

import autograd.numpy as np
from autograd import elementwise_grad as egrad

# import numpy as np
from scipy.integrate import trapz, quad
from scipy import constants

import ipywidgets as widgets

import matplotlib.pyplot as plt

# set the size of the plot
# plt.rcParams['figure.figsize'] = [10, 5]
```

```python
# Define a function for the wavefunction
def compute_wavefunction(x, n, a):
    """Compute 1-dimensional particle-in-a-box wave-function value(s).

    Parameters
    ----------
    x: float or np.ndarray
        Position of the particle.
    n: int
        Quantum number value.
    a: float
        Length of the box.
    """
    # check argument n
    if not (isinstance(n, int) and n > 0):
        raise ValueError("Argument n should be a positive integer.")
    # check argument a
    if a <= 0.0:
        raise ValueError("Argument a should be positive.")
    # check argument x
    if not (isinstance(x, float) or hasattr(x, "__iter__")):
        raise ValueError("Argument x should be a float or an array!")

    # compute wave-function
    value = np.sqrt(2 / a) * np.sin(n * np.pi * x / a)

    # set wave-function values out of the box equal to zero
    if hasattr(x, "__iter__"):
        value[x > a] = 0.0
        value[x < 0] = 0.0
    else:
        if x < 0.0 or x > a:
            value = 0.0
    return value

# Define a function for the wavefunction squared
def compute_probability(x, n, a):
    """Compute 1-dimensional particle-in-a-box probablity value(s).

    See `compute_wavefunction` parameters.
    """
    return compute_wavefunction(x, n, a)**2

#This next bit of code just prints out the normalization error
def check_normalization(a, n):
    #check the computed values of the moments against the analytic formula
    normalization,error = quad(compute_probability, 0, a, args=(n, a))
    print("Normalization of wavefunction = ", normalization)

#Principle quantum number:
n = 1

#Box length:
a = 1

check_normalization(a, n)
```

    Normalization of wavefunction =  1.0000000000000002

## The Energies of the Particle in a Box
How do we compute the energy of a particle in a box? All we need to do is substitute the eigenfunctions of the Hamiltonian, $\psi_n(x)$ back into the Schrödinger equation to determine the eigenenergies, $E_n$. That is, from

$$
\hat{H} \psi_n(x) = E_n \psi_n(x)
$$

we deduce

$$
\begin{align}
-\frac{\hbar^2}{2m} \frac{d^2}{dx^2} \left( A_n \sin \left( \frac{n \pi x}{a}\right) \right)
 &= E_n  \left( A_n \sin \left( \frac{n \pi x}{a}\right) \right) \\
 -A_n \frac{\hbar^2}{2m} \frac{d}{dx} \left( \frac{n \pi}{a} \cos \left( \frac{n \pi x}{a}\right) \right)
 &= E_n  \left( A_n \sin \left( \frac{n \pi x}{a}\right) \right) \\
  A_n \frac{\hbar^2}{2m} \left( \frac{n \pi}{a} \right)^2 \sin \left( \frac{n \pi x}{a}\right)
 &= E_n  \left( A_n \sin \left( \frac{n \pi x}{a}\right) \right) \\
   \frac{\hbar^2 n^2 \pi^2}{2ma^2}
 &= E_n
\end{align}
$$

Using the definition of $\hbar$, we can rearrange this to:

$$
\begin{align}
E_n &= \frac{\hbar^2 n^2 \pi^2}{2ma^2} \qquad \qquad n=1,2,3,\ldots\\
&= \frac{h^2 n^2}{8ma^2}
\end{align}
$$

Notice that only certain energies are allowed. This is a fundamental principle of quantum mechanics, and it is related to the "waviness" of particles. Certain "frequencies" are resonant, and other "frequencies" cannot be observed. *The **only** energies that can be observed for a particle-in-a-box are the ones given by the above formula.*

## Zero-Point Energy
Naïvely, you might expect that the lowest-energy state of a particle in a box has zero energy. (The potential in the box is zero, after all, so shouldn't the lowest-energy state be the state with zero kinetic energy? And if the kinetic energy were zero and the potential energy were zero, then the total energy would be zero.)

But this doesn't happen. It turns out that you can never "stop" a quantum particle; it always has a zero-point motion, typically a resonant oscillation about the lowest-potential-energy location(s). Indeed, the more you try to confine a particle to stop it, the bigger its kinetic energy becomes. This is clear in the particle-in-a-box, which has only kinetic energy. There the (kinetic) energy increases rapidly, as $a^{-2}$, as the box becomes smaller:

$$
T_n = E_n = \frac{h^2n^2}{8ma^2}
$$

The residual energy in the electronic ground state is called the **zero-point energy**,

$$
E_{\text{zero-point energy}} = \frac{h^2}{8ma^2}
$$

The existence of the zero-point energy, and the fact that zero-point kinetic energy is always positive, is a general feature of quantum mechanics.
> **Zero-Point Energy Principle:** Let $V(x)$ be a nonnegative potential. The ground-state energy is always greater than zero.

More generally, for any potential that is bound from below,

$$
V_{\text{min}}= \min_x V(x)
$$

the ground-state energy of the system satisfies $E_{\text{zero-point energy}} > V_{\text{min}}$.
>Nuance: There is a tiny mathematical footnote here; there are some $V(x)$ for which there are *no* bound states. In such cases, e.g., $V(x) = \text{constant}$, it is possible for $E = V_{\text{min}}$.)

## Atomic Units
Because Planck's constant and the electron mass are tiny numbers, it is often useful to use [atomic units](https://en.wikipedia.org/wiki/Hartree_atomic_units) when performing calculations. We'll learn more about atomic units later but, for now, we only need to know that, in atomic units, $\hbar$, the mass of the electron, $m_e$, the charge of the electron, $e$, and the average (mean) distance of an electron from the nucleus in the Hydrogen atom, $a_0$, are all defined to be equal to 1.0 in atomic units.

$$
\begin{align}
\hbar &= \frac{h}{2 \pi} = 1.0545718 \times 10^{-34} \text{ J s}= 1 \text{ a.u.} \\
m_e &= 9.10938356 \times 10^{-31} \text{ kg} = 1 \text{ a.u.} \\
e &= 1.602176634 \times 10^-19 \text{ C} = 1  \text{ a.u.} \\
a_0 &= 	5.291772109 \times 10^{-11} \text{ m} = 1 \text{ Bohr} = 1 \text{ a.u.}
\end{align}
$$

The unit of energy in atomic units is called the Hartree,

$$
E_h =4.359744722 \times 10^{-18} \text{ J} = 1 \text{ Hartree} = 1 \text{ a.u.}
$$

and the ground-state (zero-point) energy of the Hydrogen atom is $-\tfrac{1}{2} E_h$.

We can now define functions for the eigenenergies of the 1-dimensional particle in a box:

```python
# Define a function for the energy of a particle in a box
# with length a and quantum number n [in atomic units!]
# The length is input in Bohr (atomic units)
def compute_energy(n, a):
    "Compute 1-dimensional particle-in-a-box energy."
    return n**2 * np.pi**2 / (2 * a**2)

# Define a function for the energy of an electron in a box
# with length a and quantum number n [in SI units!].
# The length is input in meters.
def compute_energy_si(n, a):
    "Compute 1-dimensional particle-in-a-box energy."
    return n**2 * constants.h**2 / (8 * constants.m_e* a**2)

#Define variable for atomic unit of length in meters
a0 = constants.value('atomic unit of length')

#This next bit of code just prints out the energy in atomic and SI units
def print_energy(a, n):
    print(f'The energy of an electron in a box of length {a:.2f} a.u. with '
       f'quantum number {n} is {compute_energy(n, a):.2f} a.u..')
    print(f'The energy of an electron in a box of length {a*a0:.2e} m with '
       f'quantum number {n} is {compute_energy_si(n, a*a0):.2e} Joules.')

#Principle quantum number:
n = 1

#Box length:
a = 0.1

print_energy(a, n)
```

    The energy of an electron in a box of length 0.10 a.u. with quantum number 1 is 493.48 a.u..
    The energy of an electron in a box of length 5.29e-12 m with quantum number 1 is 2.15e-15 Joules.

#### 📝 Exercise: Write a function that returns the length, $a$, of a box for which the lowest-energy-excitation of the ground state, $n = 1 \rightarrow n=2$, corresponds to the system absorbing light with a given wavelength, $\lambda$. The input is $\lambda$; the output is $a$.

## Postulate: The wavefunction contains all the physically meaningful information about a system.
While the wavefunction is not itself observable, all observable properties of a system can be determined from the wavefunction. However, just because the wavefunction encapsulates all the *observable* properties of a system does not mean that it contains *all information* about a system. In quantum mechanics, some things are not observable. Consider that for the ground ($n=1$) state of the particle in a box, the root-mean-square average momentum,

$$
\bar{p}_{rms} = \sqrt{2m \cdot T} = \sqrt{(2m)\frac{h^2n^2}{8ma^2}} = \frac{hn}{2a}
$$

increases as you squeeze the box. That is, the more you try to constrain the particle in space, the faster it moves. You can't "stop" the particle no matter how hard you squeeze it, so it's impossible to exactly know where the particle is located. You can only determine its *average* position.

## Postulate: Observable Quantities Correspond to Linear, Hermitian Operators.
The *correspondence* principle says that for every classical observable there is a linear, Hermitian, operator that allows computation of the quantum-mechanical observable. An operator, $\hat{C}$ is linear if for any complex numbers $a$ and $b$, and any wavefunctions $\psi_1(x)$ and $\psi_2(x)$,

$$
\hat{C} \left(a \psi_1(x,t) + b \psi_2(x,t) \right) = a \hat{C} \psi_1(x,t) + b \hat{C} \psi_2(x,t)
$$

Similarly, an operator is Hermitian if it satisfies the relation,

$$
\int \psi_1^*(x,t) \hat{C} \psi_2(x,t) \, dx = \int \left( \hat{C}\psi_1(x,t) \right)^*  \psi_2(x,t) \, dx
$$

or, equivalently,

$$
\int \psi_1^*(x,t) \left( \hat{C} \psi_2(x,t) \right) \, dx = \int \psi_2(x,t)\left( \hat{C} \psi_1(x,t) \right)^* \, dx
$$

That is for a linear operator, the linear operator applied to a sum of wavefunctions is equal to the sum of the linear operators directly applied to the wavefunctions separately, and the linear operator applied to a constant times a wavefunction is the constant times the linear operator applied directly to the wavefunction. A Hermitian operator can apply forward (towards $\psi_2(x,t)$) or backwards (towards $\psi_1(x,t)$). This is very useful, because sometimes it is much easier to apply an operator in one direction.

We've already been introduced to the quantum-mechanical operators for the momentum,

$$
\hat{p} = -i \hbar \tfrac{d}{dx}
$$

and the kinetic energy,

$$
\hat{T} = -\tfrac{\hbar^2}{2m} \tfrac{d^2}{dx^2}
$$

These operators are linear because the derivative of a sum is the sum of the derivatives, and the derivative of a constant times a function is that constant times the derivative of the function. These operators are also Hermitian. For example, to show that the momentum operator is Hermitian:

$$
\begin{align}
\int_{-\infty}^{\infty} \psi_1^*(x,t) \hat{p} \psi_2(x,t) dx &= \int_{-\infty}^{\infty} \psi_1^*(x,t) \left( -i \hbar \tfrac{d}{dx} \right) \psi_2(x,t) dx \\
&= -i \hbar \int_{-\infty}^{\infty} \tfrac{d}{dx} \left(\psi_1^*(x,t)\psi_2(x,t)  \right) - \left(
 \psi_2(x,t) \tfrac{d}{dx} \psi_1^*(x,t)\right)  dx
\end{align}
$$

Here we used the product rule for derivatives, $f(x)\tfrac{dg}{dx} = \tfrac{d f(x) g(x)}{dx} - g(x) \tfrac{df}{dx}$. Using the fundamental theorem of calculus and the fact the probability of observing a particle at $\pm \infty$ is zero, and therefore the wavefunctions at infinity are also zero, one knows that

$$
\int_{-\infty}^{\infty} \tfrac{d}{dx} \left(\psi_1^*(x,t)\psi_2(x,t)  \right) = \left[  \psi_1^*(x,t)\psi_2(x,t)\right]_{-\infty}^{\infty} = 0
$$

Therefore the above equation can be simplified to

$$
\begin{align}
\int_{-\infty}^{\infty} \psi_1^*(x,t) \hat{p} \psi_2(x,t) dx &=i \hbar \int_{-\infty}^{\infty}   \psi_2(x,t) \tfrac{d}{dx} \psi_1^*(x,t)  dx \\
&= \int_{-\infty}^{\infty}   \psi_2(x,t) i \hbar  \tfrac{d}{dx} \psi_1^*(x,t) dx \\
&= \int_{-\infty}^{\infty}  \psi_2(x,t) \left( -i \hbar  \tfrac{d}{dx} \psi_1^*(x,t)\right)  dx \\
&= \int_{-\infty}^{\infty}  \psi_2(x,t) \left( \hat{p} \psi_1(x,t)\right)^*  dx
\end{align}
$$

The expectation value of the momentum of a particle-in-a-box is always zero. This is intuitive, since electrons (on average) are neither moving to the right nor to the left inside the box: if they were, then the box itself would need to be moving. Indeed, for any real wavefunction, the average momentum is always zero. This follows directly from the previous derivation with $\psi_1^*(x,t) = \psi_2(x,t)$. Thus:

$$
\begin{align}
\int_{-\infty}^{\infty} \psi_2(x,t) \hat{p} \psi_2(x,t) dx
&=i \hbar \int_{-\infty}^{\infty}   \psi_2(x,t) \tfrac{d}{dx} \psi_2(x,t)  dx \\
&=-i \hbar \int_{-\infty}^{\infty}  \psi_2(x,t) \left( \tfrac{d}{dx} \psi_2(x,t)\right)  dx \\
&= 0
\end{align}
$$

The last line follows because the only number that is equal to its negative is zero. (That is, $x=-x$ if and only if $x=0$.) It is a subtle feature that the eigenfunctions of a real-valued Hamiltonian operator can always be chosen to be real-valued themselves, so their average momentum is clearly zero. We often denote quantum-mechanical expectation values with the shorthand,

$$
\langle \hat{p} \rangle =0
$$

The momentum-squared of the particle-in-a-box is easily computed from the kinetic energy,

$$
\langle \hat{p}^2 \rangle = \int_0^a \psi_n(x) \hat{p}^2 \psi_n(x) dx = \int_0^a \psi_n(x) \left(2m\hat{T}\right) \psi_n(x) dx = 2m E_n = \frac{h^2n^2}{4a^2}
$$

Intuitively, since the box is symmetric about $x=\tfrac{a}{2}$, the particle has equal probability of being in the first-half and the second-half of the box. So the average position is expected to be

$$
\langle x \rangle =\tfrac{a}{2}
$$

We can confirm this by explicit integration,

$$
\begin{align}
\langle x \rangle &= \int_0^a \psi_n^*(x)\, x \,\psi_n(x) dx \\
&= \int_0^a \left(\sqrt{\tfrac{2}{a}} \sin\left(\tfrac{n \pi x}{a} \right)\right) x \left(\sqrt{\tfrac{2}{a}}\sin\left(\tfrac{n \pi x}{a} \right)\right) dx \\
&= \tfrac{2}{a} \int_0^a  x \sin^2\left(\tfrac{n \pi x}{a} \right) dx \\
&= \tfrac{2}{a} \left[ \tfrac{x^2}{4} - x \tfrac{\sin \tfrac{2n \pi x}{a}}{\tfrac{4 n \pi}{a}}
- \tfrac{\cos \tfrac{2n \pi x}{a}}{\tfrac{8 n^2 \pi^2}{a^2}}
 \right]_0^a \\
 &= \tfrac{2}{a} \left[ \tfrac{a^2}{4} - 0 - 0 \right] \\
 &= \tfrac{a}{2}
\end{align}
$$

Similarly, we expect that the expectation value of $\langle x^2 \rangle$ will be proportional to $a^2$. We can confirm this by explicit integration,

$$
\begin{align}
\langle x^2 \rangle &= \int_0^a \psi_n^*(x)\, x^2 \,\psi_n(x) dx \\
&= \int_0^a \left(\sqrt{\tfrac{2}{a}} \sin\left(\tfrac{n \pi x}{a} \right)\right) x^2 \left(\sqrt{\tfrac{2}{a}}\sin\left(\tfrac{n \pi x}{a} \right)\right) dx \\
&= \tfrac{2}{a} \int_0^a  x^2 \sin^2\left(\tfrac{n \pi x}{a} \right) dx \\
&= \tfrac{2}{a} \left[ \tfrac{x^3}{6}
 - x^2 \tfrac{\sin \tfrac{2n \pi x}{a}}{\tfrac{4 n \pi}{a}}
 - x \tfrac{\cos \tfrac{2n \pi x}{a}}{\tfrac{4 n^2 \pi^2}{a^2}}
 - \tfrac{\sin \tfrac{2n \pi x}{a}}{\tfrac{8 n^3 \pi^3}{a^3}}
 \right]_0^a \\
 &= \tfrac{2}{a} \left[ \tfrac{a^3}{6} - 0 - \tfrac{a}{{\tfrac{4 n^2 \pi^2}{a^2}}} - 0 \right] \\
  &= \tfrac{2}{a} \left[ \tfrac{a^3}{6} - \tfrac{a^3}{4 n^2 \pi^2} \right] \\
  &= a^2\left[ \tfrac{1}{3} - \tfrac{1}{2 n^2 \pi^2} \right]
\end{align}
$$

We can verify these formulas by explicit integration.

```python
#Compute <x^power>, the expectation value of x^power
def compute_moment(x, n, a, power):
    """Compute the x^power moment of the 1-dimensional particle-in-a-box.

    See `compute_wavefunction` parameters.
    """
    return compute_probability(x, n, a)*x**power

#This next bit of code just prints out the values.
def check_moments(a, n):
    #check the computed values of the moments against the analytic formula
    avg_r,error = quad(compute_moment, 0, a, args=(n, a, 1))
    avg_r2,error = quad(compute_moment, 0, a, args=(n, a, 2))
    print(f"<r> computed = {avg_r:.5f}")
    print(f"<r> analytic = {a/2:.5f}")
    print(f"<r^2> computed = {avg_r2:.5f}")
    print(f"<r^2> analytic = {a**2*(1/3 - 1./(2*n**2*np.pi**2)):.5f}")

#Principle quantum number:
n = 1

#Box length:
a = 1

check_moments(a, n)
```

    <r> computed = 0.50000
    <r> analytic = 0.50000
    <r^2> computed = 0.28267
    <r^2> analytic = 0.28267

## Heisenberg Uncertainty Principle
The previous example gives a first example of the more general [Heisenberg Uncertainty Principle](https://en.wikipedia.org/wiki/Uncertainty_principle). One specific manifestation of the Heisenberg Uncertaity Principle is that the variance of the position, $\sigma_x^2 = \langle x^2 \rangle - \langle x \rangle^2$, times the variance of the momentum. $\sigma_p^2 = \langle \hat{p}^2 \rangle - \langle \hat{p} \rangle^2$ is greater than $\tfrac{\hbar^2}{4}$. We can verify this formula for the particle in a box.

$$
\begin{align}
\tfrac{\hbar^2}{4} &\le \sigma_x^2 \sigma_p^2 \\
&= \left( a^2\left[ \tfrac{1}{3} - \tfrac{1}{2 n^2 \pi^2} \right] - \tfrac{a^2}{4} \right)
 \left( \frac{h^2n^2}{4a^2} - 0 \right) \\
&= \left( a^2\left[ \tfrac{1}{3} - \tfrac{1}{2 n^2 \pi^2} \right] - \tfrac{a^2}{4} \right)
 \left( \frac{\hbar^2 \pi^2 n^2}{a^2} \right) \\
&= \hbar^2 \pi^2 n^2 \left(\tfrac{1}{12} - \tfrac{1}{2 n^2 \pi^2} \right)
\end{align}
$$

The right-hand-side gets larger and larger as $n$ increases, so the largest value occurs where:

$$
\begin{align}
\tfrac{\hbar^2}{4} &\le \hbar^2 \pi^2 \left(\tfrac{1}{12} - \tfrac{1}{2 \pi^2} \right) \\
&= 0.32247 \hbar^2
\end{align}
$$

## Double-Checking the Energy of a Particle-in-a-Box
To check the energy of the particle in a box, we can compute the kinetic energy density, then integrate it over all space. That is, we define:

$$
\tau_n(x) = \psi_n^*(x) \left(-\tfrac{\hbar^2}{2m}\tfrac{d^2}{dx^2}\right) \psi_n(x)
$$

and then the kinetic energy (which is the energy for the particle in a box) is

$$
T_n = \int_0^a \tau_n(x) dx
$$

> *Note:* In fact there are several different equivalent definitions of the kinetic energy density, but this is not very important in an introductory quantum chemistry course. All of the kinetic energy densities give the same total kinetic energy. However, because the kinetic energy density, $\tau(x)$, represents the kinetic energy of a particle at the point $x$, and it is impossible to know the momentum (or the momentum-squared, ergo the kinetic energy) exactly at a given point in space according to the Heisenberg uncertainty principle), there can be no unique definition for $\tau(x)$.

```python
#The next few lines just set up the sliders for setting parameters.
#Principle quantum number slider:

def compute_wavefunction_derivative(x, n, a, order=1):
    """Compute 1-dimensional particle-in-a-box kinetic energy density.
    """
    if not (isinstance(order, int) and n > 0):
        raise ValueError("Argument order is expected to be a positive integer!")

    def wavefunction(x):
        v = np.sqrt(2 / a) * np.sin(n * np.pi * x / a)
        return v

    # compute derivative
    deriv = egrad(wavefunction)
    for _ in range(order - 1):
        deriv = egrad(deriv)

    # return zero for x values out of the box
    deriv = deriv(x)
#     deriv[x < 0] = 0.0
#     deriv[x > a] = 0.0

    if hasattr(x, "__iter__"):
        deriv[x > a] = 0.0
        deriv[x < 0] = 0.0
    else:
        if x < 0.0 or x > a:
            deriv = 0.0

    return deriv

def compute_kinetic_energy_density(x, n, a):
    """Compute 1-dimensional particle-in-a-box kinetic energy density.

    See `compute_wavefunction` parameters.
    """
    # evaluate wave-function and its 2nd derivative w.r.t. x
    wf = compute_wavefunction(x, n, a)
    d2 = compute_wavefunction_derivative(x, n, a, order=2)
    return -0.5 * wf * d2

#This next bit of code just prints out the values.
def check_energy(a, n):
    #check the computed values of the moments against the analytic formula
    ke,error = quad(compute_kinetic_energy_density, 0, a, args=(n, a))
    energy = compute_energy(n, a)
    print(f"The energy computed by integrating the k.e. density is {ke:.5f}")
    print(f"The energy computed directly is {energy:.5f}")

#Principle quantum number:
n = 1

#Box length:
a = 17

check_energy(a, n)
```

    The energy computed by integrating the k.e. density is 0.01708
    The energy computed directly is 0.01708

## Visualizing the Particle-in-a-Box Wavefunctions, Probabilities, etc.
In the next code block, the wavefunction, probability density, derivative, second-derivative, and kinetic energy density for the particle-in-a-box are shown. Notice that the kinetic-energy-density is proportional to the probability density, and that the first and second derivatives are not zero at the edge of the box, but the wavefunction and probability density are. It's useful to change the parameters in the below figures to build your intuition for the particle-in-a-box.

```python
#This next bit of code makes the plots and prints out the energy
def make_plots(a, n):
    #check the computed values of the moments against the analytic formula
    energy = compute_energy(n, a)
    print(f"The energy computed directly is {energy:.5f}")
    # sample x coordinates
    x = np.arange(-0.6, a + 0.6, 0.01)

    # evaluate wave-function & probability
    wf = compute_wavefunction(x, n, a)
    pr = compute_probability(x, n, a)

    # evaluate 1st & 2nd derivative of wavefunction w.r.t. x
    d1 = compute_wavefunction_derivative(x, n, a, order=1)
    d2 = compute_wavefunction_derivative(x, n, a, order=2)

    # evaluate kinetic energy density
    kin = compute_kinetic_energy_density(x, n, a)
    #print("Integrate KED = ", trapz(kin, x))

    # set the size of the plot
    plt.rcParams['figure.figsize'] = [15, 10]
    plt.rcParams['font.family'] = 'DejaVu Sans'
    plt.rcParams['font.serif'] = ['Times New Roman']
    plt.rcParams['mathtext.fontset'] = 'stix'
    plt.rcParams['xtick.labelsize'] = 15
    plt.rcParams['ytick.labelsize'] = 15

    # define four subplots
    fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2)
    fig.suptitle(f'a={a}  n={n}  E={compute_energy(n, a):.4f} a.u.', fontsize=35, fontweight='bold')

    # plot 1
    ax1.plot(x, wf, linestyle='--', label=r'$\psi(x)$', linewidth=3)
    ax1.plot(x, pr, linestyle='-', label=r'$\|\psi(x)\|^2$', linewidth=3)
    ax1.legend(frameon=False, fontsize=20)
    ax1.set_xlabel('x coordinates', fontsize=20)

    # plot 2
    ax2.plot(x, d1, linewidth=3, c='k')
    ax2.set_xlabel('x coordinates', fontsize=20)
    ax2.set_ylabel(r'$\frac{d\psi(x)}{dx}$', fontsize=30, rotation=0, labelpad=25)

    # plot 3
    ax3.plot(x, d2, linewidth=3, c='g', )
    ax3.set_xlabel('x coordinates', fontsize=20)
    ax3.set_ylabel(r'$\frac{d^2\psi(x)}{dx^2}$', fontsize=25, rotation=0, labelpad=25)

    # plot 4
    ax4.plot(x, kin, linewidth=3, c='r')
    ax4.set_xlabel('x coordinates', fontsize=20)
    ax4.set_ylabel('Kinetic Energy Density', fontsize=16)

    # adjust spacing between plots
    plt.subplots_adjust(left=0.125,
                    bottom=0.1,
                    right=0.9,
                    top=0.9,
                    wspace=0.35,
                    hspace=0.35)
    #Show Plot
    plt.show()

#Principle quantum number slider:
n = 1

#Box length slider:
a = 1

make_plots(a, n)
```

## 🪞 Self-Reflection
- Can you think of other physical or chemical systems where the particle-in-a-box Hamiltonian would be appropriate?
- Can you think of another property density, besides the kinetic energy density, that cannot be uniquely defined in quantum mechanics?

## 🤔 Thought-Provoking Questions
- How would the wavefunction and ground-state energy change if you made a small change in the particle-in-a-box Hamiltonian, so that the right-hand-side of the box was a little higher than the left-hand-side?
- Any system with a zero-point energy of zero is classical. Why?
- How would you compute the probability of observing an electron at the center of a box if the box contained 2 electrons? If it contained 4 electrons? If it contained 8 electrons? The probability of observing an electron at the center of a box with 3 electrons is sometimes *lower* than the probability of observing an electron at the center of a box with 2 electrons. Why?
- Demonstrate that the kinetic energy operator is linear and Hermitian.
- What is the lowest-energy excitation energy for the particle-in-a-box?
- Suppose you wanted to design a one-dimensional box containing a single electron that absorbed blue light? How long would the box be?

## ❓ Knowledge Tests
- Questions about the Particle-in-a-Box and related concepts. [GitHub Classroom Link](https://classroom.github.com/a/1Y48deKP)

## 👩🏽💻 Assignments
- Compute and understand expectation values by computing moments of the particle-in-a-box [assignment](https://github.com/McMasterQM/PinBox-Moments/blob/main/moments.ipynb). [Github classroom link]https://classroom.github.com/a/9yzWI5Vt.
- This [assignment](https://github.com/McMasterQM/Sudden-Approximation/blob/main/SuddenPinBox.ipynb) on the sudden approximation provides an introduction to time-dependent phenomena. [Github classroom link](https://classroom.github.com/a/yBzABlb-).

## 🔁 Recapitulation
- Write the Hamiltonian, time-independent Schrödinger equation, eigenfunctions, and eigenvalues for the one-dimensional particle in a box.
- Play around with the eigenfunctions and eigenenergies of the particle-in-a-box to build intuition for them.
- How would you compute the uncertainty in $x^4$?
- Practice your calculus by explicitly computing, using integration by parts, $\langle x \rangle$ and $\langle x^2 \rangle$. This can be implemented in a [Jupyter notebook](x4_mocked.ipynb).

## 🔮 Next Up...
- Postulates of Quantum Mechanics
- Multielectron particle-in-a-box.
- Multidimensional particle-in-a-box.
- Harmonic Oscillator

## 📚 References
My favorite sources for this material are:
- [Randy's book](https://github.com/PaulWAyers/IntroQChem/blob/main/documents/DumontBook.pdf) has an excellent treatment of the particle-in-a-box model, including several extensions to the material covered here. (Chapter 3)
- Also see my (pdf) class [notes](https://github.com/PaulWAyers/IntroQChem/blob/main/documents/PinBox.pdf).
- Also see my notes on the [mathematical structure of quantum mechanics](https://github.com/PaulWAyers/IntroQChem/blob/main/documents/LinAlgAnalogy.pdf).
- [Davit Potoyan's](https://www.chem.iastate.edu/people/davit-potoyan) Jupyter-book covers the particle-in-a-box in [chapter 4](https://dpotoyan.github.io/Chem324/intro.html) are especially relevant here.
- D. A. MacQuarrie, Quantum Chemistry (University Science Books, Mill Valley California, 1983)
- [An excellent explanation of the link to the spectrum of cyanine dyes](https://pubs.acs.org/doi/10.1021/ed084p1840)
- Chemistry Libre Text: [one dimensional](https://chem.libretexts.org/Bookshelves/Physical_and_Theoretical_Chemistry_Textbook_Maps/Map%3A_Physical_Chemistry_for_the_Biosciences_(Chang)/11%3A_Quantum_Mechanics_and_Atomic_Structure/11.08%3A_Particle_in_a_One-Dimensional_Box)
and [multi-dimensional](https://chem.libretexts.org/Bookshelves/Physical_and_Theoretical_Chemistry_Textbook_Maps/Supplemental_Modules_(Physical_and_Theoretical_Chemistry)/Quantum_Mechanics/05.5%3A_Particle_in_Boxes/Particle_in_a_3-Dimensional_box)
- [McQuarrie and Simon summary](https://chem.libretexts.org/Bookshelves/Physical_and_Theoretical_Chemistry_Textbook_Maps/Map%3A_Physical_Chemistry_(McQuarrie_and_Simon)/03%3A_The_Schrodinger_Equation_and_a_Particle_in_a_Box)

There are also some excellent wikipedia articles:
- [Particle in a Box](https://en.wikipedia.org/wiki/Particle_in_a_box)
- [Particle on a Ring](https://en.wikipedia.org/wiki/Particle_in_a_ring)
- [Postulates of Quantum Mechanics](https://en.wikipedia.org/wiki/Mathematical_formulation_of_quantum_mechanics#Postulates_of_quantum_mechanics)

```python

```